import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api"; import { createDefaultRuntimeSnapshot, createRuntimeLifecycleResult, getRuntimeApplyImpact, getRuntimeConnection, getRuntimeHealthSummary, getRuntimeWorkspaceSummary, validateModelFile, validateModelsDirectory, validateSourceMode, } from "./runtime-utils"; import { createRuntimeDraftFieldState, createRuntimeDraftState } from "./types"; import type { RuntimeApplyImpact, RuntimeConfig, RuntimeDraftFieldKey, RuntimeDraftFieldMeta, RuntimeDraftState, RuntimeLifecycleCommand, RuntimeLifecycleResult, RuntimeModelFile, RuntimeSnapshot, RuntimeWorkspaceHealth, RuntimeWorkspaceSummary, } from "./types"; function areConfigsEqual(left: RuntimeConfig, right: RuntimeConfig): boolean { return JSON.stringify(left) === JSON.stringify(right); } function cloneConfig(config: RuntimeConfig): RuntimeConfig { return { ...config, extra_model_paths: [...config.extra_model_paths], remote_presets: config.remote_presets.map((preset) => ({ ...preset })), }; } function applyOperationalSnapshot(current: RuntimeSnapshot, next: RuntimeSnapshot): RuntimeSnapshot { return { ...current, status: next.status, ready: next.ready, process_running: next.process_running, resolved_url: next.resolved_url, binary_path: next.binary_path, last_error: next.last_error, active_command: next.active_command, last_command_result: next.last_command_result, }; } function normalizeSnapshot(snapshot: RuntimeSnapshot): RuntimeSnapshot { return { ...createDefaultRuntimeSnapshot(), ...snapshot, config: { ...createDefaultRuntimeSnapshot().config, ...snapshot.config, }, last_command_result: snapshot.last_command_result ?? createRuntimeLifecycleResult(snapshot.active_command ?? "idle"), active_command: snapshot.active_command ?? "idle", }; } function reconcileDraftState(current: RuntimeDraftState, next: RuntimeSnapshot): RuntimeDraftState { const normalizedNext = normalizeSnapshot(next); if (current.isDirty) { return { ...current, liveSnapshot: applyOperationalSnapshot(current.liveSnapshot, normalizedNext), }; } // Run source-mode validation when reconciling to baseline const validation = validateSourceMode(normalizedNext.config); const updatedFields = createRuntimeDraftFieldState(); for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) { updatedFields[fieldName as RuntimeDraftFieldKey] = { invalid: !fieldValidation.valid, pending: false, message: fieldValidation.message, }; } return { ...current, liveSnapshot: normalizedNext, savedBaseline: cloneConfig(normalizedNext.config), draftConfig: cloneConfig(normalizedNext.config), fields: updatedFields, // Preserve scanned models state during reconciliation scannedModels: current.scannedModels, isScanning: current.isScanning, }; } function blockWhileLifecycleBusy( snapshot: RuntimeSnapshot, command: RuntimeLifecycleCommand, ): RuntimeSnapshot | null { if (snapshot.active_command === "idle") { return null; } const blockedBy = snapshot.active_command; const message = blockedBy === "start" ? "Local API start already in progress." : blockedBy === "stop" ? "Local API stop already in progress." : blockedBy === "save" ? "Runtime settings save already in progress." : "Runtime save and start already in progress."; return { ...snapshot, last_command_result: createRuntimeLifecycleResult(command, message, "blocked"), }; } function startLifecycleSnapshot( snapshot: RuntimeSnapshot, command: RuntimeLifecycleCommand, status: RuntimeSnapshot["status"], ): RuntimeSnapshot { return { ...snapshot, status, process_running: command === "start" ? true : snapshot.process_running, ready: false, last_error: null, active_command: command, }; } function finalizeLifecycleSnapshot( fallbackCommand: RuntimeLifecycleCommand, fallbackMessage: string, next: RuntimeSnapshot, ): RuntimeSnapshot { const normalizedNext = normalizeSnapshot(next); if (normalizedNext.last_command_result.command !== "idle") { return normalizedNext; } return { ...normalizedNext, last_command_result: createRuntimeLifecycleResult(fallbackCommand, fallbackMessage), }; } export function useRuntimeManager() { const [draftState, setDraftState] = useState(() => createRuntimeDraftState(createDefaultRuntimeSnapshot()), ); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const refresh = useCallback(async () => { const next = await getRuntimeSnapshot(); setDraftState((current) => reconcileDraftState(current, next)); return next; }, []); useEffect(() => { let active = true; bootstrapRuntime() .then((next) => { if (active) { setDraftState((current) => reconcileDraftState(current, next)); setLoading(false); } }) .catch(() => { if (active) { setLoading(false); } }); const id = window.setInterval(() => { refresh().catch(() => undefined); }, 3000); return () => { active = false; window.clearInterval(id); }; }, [refresh]); const openDraft = useCallback(() => { setDraftState((current) => { const draftConfig = current.isDirty ? current.draftConfig : cloneConfig(current.savedBaseline); // Run source-mode validation when opening draft const validation = validateSourceMode(draftConfig); // Initialize field validations based on validation results const updatedFields = { ...current.fields }; for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) { updatedFields[fieldName as RuntimeDraftFieldKey] = { invalid: !fieldValidation.valid, pending: false, message: fieldValidation.message, }; } return { ...current, isOpen: true, draftConfig, fields: updatedFields, }; }); }, []); const closeDraft = useCallback(() => { setDraftState((current) => ({ ...current, isOpen: false, })); }, []); const resetDraft = useCallback(() => { setDraftState((current) => { const resetConfig = cloneConfig(current.savedBaseline); // Run source-mode validation when resetting const validation = validateSourceMode(resetConfig); // Initialize field validations based on validation results const updatedFields = createRuntimeDraftFieldState(); for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) { updatedFields[fieldName as RuntimeDraftFieldKey] = { invalid: !fieldValidation.valid, pending: false, message: fieldValidation.message, }; } return { ...current, draftConfig: resetConfig, isDirty: false, fields: updatedFields, }; }); }, []); const updateDraft = useCallback((patch: Partial) => { setDraftState((current) => { const nextDraft = { ...current.draftConfig, ...patch, }; return { ...current, isOpen: true, isDirty: !areConfigsEqual(nextDraft, current.savedBaseline), draftConfig: nextDraft, }; }); }, []); const updateDraftField = useCallback((field: K, value: RuntimeConfig[K]) => { setDraftState((current) => { const nextDraft = { ...current.draftConfig, [field]: value, }; // Run source-mode validation const validation = validateSourceMode(nextDraft); // Update field validations based on validation results const updatedFields = { ...current.fields }; for (const [fieldName, fieldValidation] of Object.entries(validation.fieldValidations)) { updatedFields[fieldName as RuntimeDraftFieldKey] = { invalid: !fieldValidation.valid, pending: false, message: fieldValidation.message, }; } return { ...current, isOpen: true, isDirty: !areConfigsEqual(nextDraft, current.savedBaseline), draftConfig: nextDraft, fields: updatedFields, }; }); }, []); const updateDraftFieldMeta = useCallback( (field: RuntimeDraftFieldKey, patch: Partial) => { setDraftState((current) => ({ ...current, fields: { ...current.fields, [field]: { ...current.fields[field], invalid: false, pending: false, message: null, ...patch, }, }, })); }, [], ); // Phase 4: Model scanning for validation const scanModels = useCallback(async (dirPath: string | null) => { const resolvedDir = dirPath ?? ""; // Set scanning state first setDraftState((current) => ({ ...current, isScanning: true, })); try { const models = await scanRuntimeModels(resolvedDir); // Now update with results setDraftState((current) => { const modelPathInvalid = current.draftConfig.model_path ? !models.some((m) => m.path === current.draftConfig.model_path) : false; const modelPathMessage = current.draftConfig.model_path ? models.some((m) => m.path === current.draftConfig.model_path) ? null : "Model file not found in current models directory" : null; return { ...current, scannedModels: models, isScanning: false, // Validate model_path against scanned models fields: { ...current.fields, model_path: { invalid: modelPathInvalid, pending: false, message: modelPathMessage, }, }, }; }); } catch (error) { setDraftState((current) => ({ ...current, scannedModels: [], isScanning: false, fields: { ...current.fields, models_dir: { invalid: true, pending: false, message: error instanceof Error ? error.message : String(error), }, }, })); } }, []); // Auto-scan models when the configured directory changes useEffect(() => { const modelsDir = draftState.liveSnapshot.config.models_dir; if (modelsDir) { scanModels(modelsDir).catch(() => undefined); } }, [draftState.liveSnapshot.config.models_dir, scanModels]); const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => { setDraftState((current) => ({ ...current, liveSnapshot: next, savedBaseline: cloneConfig(next.config), draftConfig: cloneConfig(next.config), isDirty: false, isOpen: keepOpen, fields: createRuntimeDraftFieldState(), })); }, []); const saveDraft = useCallback(async () => { const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save"); if (blockedSnapshot) { setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot })); return blockedSnapshot; } setBusy(true); setDraftState((current) => ({ ...current, liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status), })); try { const next = await saveRuntimeConfig(draftState.draftConfig); const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next); commitSavedSnapshot(resolvedNext, true); return resolvedNext; } finally { setBusy(false); } }, [commitSavedSnapshot, draftState.draftConfig]); const saveConfig = useCallback(async (patch: Partial) => { const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "save"); if (blockedSnapshot) { setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot })); return blockedSnapshot; } setBusy(true); setDraftState((current) => ({ ...current, liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "save", current.liveSnapshot.status), })); try { const next = await saveRuntimeConfig({ ...draftState.draftConfig, ...patch }); const resolvedNext = finalizeLifecycleSnapshot("save", "Save completed.", next); commitSavedSnapshot(resolvedNext, draftState.isOpen); return resolvedNext; } finally { setBusy(false); } }, [commitSavedSnapshot, draftState.draftConfig, draftState.isOpen]); const startLocal = useCallback(async () => { const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "start"); if (blockedSnapshot) { setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot })); return blockedSnapshot; } setBusy(true); setDraftState((current) => ({ ...current, liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "start", "starting"), })); try { const next = await startRuntime(); const resolvedNext = finalizeLifecycleSnapshot("start", "Local API started.", next); setDraftState((current) => reconcileDraftState(current, resolvedNext)); return resolvedNext; } finally { setBusy(false); } }, [draftState.liveSnapshot]); const stopLocal = useCallback(async () => { const blockedSnapshot = blockWhileLifecycleBusy(draftState.liveSnapshot, "stop"); if (blockedSnapshot) { setDraftState((current) => ({ ...current, liveSnapshot: blockedSnapshot })); return blockedSnapshot; } setBusy(true); setDraftState((current) => ({ ...current, liveSnapshot: startLifecycleSnapshot(current.liveSnapshot, "stop", "stopping"), })); try { const next = await stopRuntime(); const resolvedNext = finalizeLifecycleSnapshot("stop", "Local API stopped.", next); setDraftState((current) => reconcileDraftState(current, resolvedNext)); return resolvedNext; } finally { setBusy(false); } }, [draftState.liveSnapshot]); const connection = useMemo(() => getRuntimeConnection(draftState.liveSnapshot), [draftState.liveSnapshot]); const activeLifecycleCommand = draftState.liveSnapshot.active_command; const lastLifecycleResult: RuntimeLifecycleResult = draftState.liveSnapshot.last_command_result; const lifecycleBusy = activeLifecycleCommand !== "idle"; const applyImpact: RuntimeApplyImpact = useMemo( () => getRuntimeApplyImpact({ savedBaseline: draftState.savedBaseline, draftConfig: draftState.draftConfig, processRunning: draftState.liveSnapshot.process_running, }), [draftState.draftConfig, draftState.liveSnapshot.process_running, draftState.savedBaseline], ); // Phase 3: Unified workspace contract const workspaceSummary: RuntimeWorkspaceSummary = useMemo( () => getRuntimeWorkspaceSummary(draftState.liveSnapshot), [draftState.liveSnapshot], ); const workspaceHealth: RuntimeWorkspaceHealth = useMemo( () => getRuntimeHealthSummary(draftState.liveSnapshot), [draftState.liveSnapshot], ); return { snapshot: draftState.liveSnapshot, liveSnapshot: draftState.liveSnapshot, savedBaseline: draftState.savedBaseline, draftConfig: draftState.draftConfig, draftDirty: draftState.isDirty, draftOpen: draftState.isOpen, draftFields: draftState.fields, connection, loading, busy, activeLifecycleCommand, lastLifecycleResult, lifecycleBusy, applyImpact, // Phase 3: Unified workspace contract workspaceSummary, workspaceHealth, refresh, openDraft, closeDraft, resetDraft, updateDraft, updateDraftField, updateDraftFieldMeta, saveDraft, saveConfig, startLocal, stopLocal, // Phase 4: Model scanning for validation scannedModels: draftState.scannedModels, isScanningModels: draftState.isScanning, scanModels, }; }