import type { ConnectionMode, RuntimeApplyImpact, RuntimeConfig, RuntimeConnection, RuntimeDraftFieldKey, RuntimeLifecycleCommand, RuntimeLifecycleResult, RuntimeModelFile, RuntimeSnapshot, RuntimeWorkspaceHealth, RuntimeWorkspaceHealthTone, RuntimeWorkspaceSummary, } from "./types"; import { scanRuntimeModels } from "./api"; import { DEFAULT_CTX_SIZE } from "./runtime-defaults"; const DEFAULT_LOCAL_URL = "http://127.0.0.1:8080"; // === Phase 4: Source-Mode Validation Types === export type ValidationSeverity = "error" | "warning"; export interface FieldValidation { valid: boolean; severity: ValidationSeverity; message: string | null; } export interface SourceModeValidation { mode: ConnectionMode; valid: boolean; fieldValidations: Record; } export interface PathValidation { path: string; exists: boolean; isValid: boolean; message: string | null; } // === Phase 4: Source-Mode Validation Functions === /** * Validates source-mode configuration based on connection mode. * For local mode, requires model_path. For remote mode, requires server_url. */ export function validateSourceMode(config: RuntimeConfig): SourceModeValidation { const fieldValidations: Record = {}; let valid = true; if (config.connection_mode === "local") { // Validate local mode requirements if (!config.model_path) { fieldValidations.model_path = { valid: false, severity: "error", message: "Select a model file for local runtime", }; valid = false; } // models_dir is optional for local mode } else if (config.connection_mode === "remote") { // Validate remote mode requirements if (!config.server_url) { fieldValidations.server_url = { valid: false, severity: "error", message: "Server URL is required for remote runtime", }; valid = false; } // Validate URL format for remote mode if (config.server_url) { try { const url = new URL(config.server_url); if (!["http:", "https:"].includes(url.protocol)) { fieldValidations.server_url = { valid: false, severity: "error", message: "Server URL must use http or https protocol", }; valid = false; } } catch { fieldValidations.server_url = { valid: false, severity: "error", message: "Invalid URL format", }; valid = false; } } } return { mode: config.connection_mode, valid, fieldValidations, }; } /** * Validates a model path. For now, returns pending status. * Actual existence check happens via scan in plan 04-02. */ export function validateModelPath(path: string | null): PathValidation { if (!path) { return { path: "", exists: false, isValid: false, message: null }; } // For now, return valid - actual existence check happens via scan return { path, exists: true, isValid: true, message: null }; } /** * Get field keys that require validation based on connection mode */ export function getValidatedFields(mode: ConnectionMode): RuntimeDraftFieldKey[] { if (mode === "local") { return ["model_path", "models_dir"]; } return ["server_url", "api_key"]; } /** * Check if draft has any validation errors */ export function hasValidationErrors(fieldValidations: Record): boolean { return Object.values(fieldValidations).some((fv) => !fv.valid); } // === Phase 4: Path and Model File Validation (Async) === export interface DirectoryValidation { valid: boolean; modelCount: number; error: string | null; } export interface ModelFileValidation { valid: boolean; exists: boolean; error: string | null; } const RESTART_REQUIRED_FIELDS: Array = [ "connection_mode", "model_path", "addr", "gpu_layers", "ctx_size", ]; function normalizeUrl(value: string | null | undefined): string { const trimmed = (value || "").trim(); return trimmed ? trimmed.replace(/\/+$/, "") : ""; } export function getRuntimeConnection(snapshot: RuntimeSnapshot): RuntimeConnection { if (snapshot.config.connection_mode === "remote") { return { mode: "remote", serverUrl: normalizeUrl(snapshot.config.server_url), apiKey: snapshot.config.api_key || "", managed: false, }; } return { mode: "local", serverUrl: normalizeUrl(snapshot.resolved_url) || DEFAULT_LOCAL_URL, apiKey: snapshot.config.api_key || "", managed: true, }; } export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string { if (snapshot.config.connection_mode === "remote") { return "Remote API"; } if (snapshot.status === "ready" && !snapshot.ready) { return snapshot.process_running ? "Not ready" : "Local API offline"; } switch (snapshot.status) { case "ready": return "Ready"; case "loading": return "Model loading..."; case "starting": return "Starting local API"; case "stopping": return "Stopping local API"; case "error": return "Local API error"; case "degraded": return "Attention needed"; default: return "Local API offline"; } } export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "success" | "warning" | "danger" { if (snapshot.config.connection_mode === "remote") { return "neutral"; } if (snapshot.status === "ready" && !snapshot.ready) { return snapshot.process_running ? "danger" : "neutral"; } switch (snapshot.status) { case "ready": return "success"; case "loading": case "starting": case "stopping": case "degraded": return "warning"; case "error": return "danger"; default: return "neutral"; } } export interface RuntimeStartGuard { canStart: boolean; reason: string; } export interface RuntimeApplyImpactOptions { savedBaseline: RuntimeConfig; draftConfig: RuntimeConfig; processRunning: boolean; } export function createRuntimeLifecycleResult( command: RuntimeLifecycleCommand = "idle", message = "Runtime idle.", outcome: RuntimeLifecycleResult["outcome"] = "succeeded", ): RuntimeLifecycleResult { return { command, outcome, message, }; } export function getRuntimeApplyImpact({ savedBaseline, draftConfig, processRunning, }: RuntimeApplyImpactOptions): RuntimeApplyImpact { if (savedBaseline.connection_mode !== draftConfig.connection_mode) { return "save_before_apply"; } const needsRestart = RESTART_REQUIRED_FIELDS.some((field) => savedBaseline[field] !== draftConfig[field]); if (processRunning && needsRestart) { return "restart_required"; } return "immediate"; } export function getRuntimeStartGuard(snapshot: RuntimeSnapshot): RuntimeStartGuard { if (snapshot.config.connection_mode === "remote") { return { canStart: false, reason: "Remote mode uses an external API and does not launch the local runtime.", }; } if (!snapshot.binary_path) { return { canStart: false, reason: "Build or bundle the local API binary before starting the managed runtime.", }; } if (!snapshot.config.model_path?.trim()) { return { canStart: false, reason: "Choose a local model before starting the managed runtime.", }; } return { canStart: true, reason: "" }; } export function createDefaultRuntimeSnapshot(): RuntimeSnapshot { return { status: "stopped", ready: false, process_running: false, resolved_url: DEFAULT_LOCAL_URL, binary_path: null, last_error: null, active_command: "idle", last_command_result: createRuntimeLifecycleResult(), config: { connection_mode: "local", start_policy: "manual", server_url: "", api_key: "", model_path: null, models_dir: null, addr: "127.0.0.1:8080", gpu_layers: 0, ctx_size: DEFAULT_CTX_SIZE, extra_model_paths: [], selected_model_id: null, remote_presets: [], hugging_face_token: "", }, }; } // === Phase 3: Unified Runtime Workspace Helpers === function getModelLabel(modelPath: string | null): string | null { if (!modelPath) return null; const parts = modelPath.replace(/\\/g, "/").split("/"); return parts[parts.length - 1] || null; } export function getRuntimeWorkspaceSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceSummary { const connection = getRuntimeConnection(snapshot); const statusLabel = getRuntimeStateLabel(snapshot); const isRemote = snapshot.config.connection_mode === "remote"; return { statusLabel, modeLabel: isRemote ? "remote" : "local", endpointLabel: connection.serverUrl, modelLabel: getModelLabel(snapshot.config.model_path), isReady: snapshot.ready, isRunning: snapshot.process_running, hasError: snapshot.status === "error" || (snapshot.last_error !== null && snapshot.last_error.code !== "poll_warning"), }; } function deriveHealthTone(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealthTone { if (snapshot.config.connection_mode === "remote") { return "neutral"; } switch (snapshot.status) { case "ready": return "success"; case "starting": case "stopping": case "degraded": return "warning"; case "error": return "danger"; default: return "neutral"; } } function deriveHealthHeadline(snapshot: RuntimeSnapshot): string { if (snapshot.config.connection_mode === "remote") { return "Remote API connected"; } switch (snapshot.status) { case "ready": return "Local API ready"; case "loading": return "Model loading..."; case "starting": return "Starting local API..."; case "stopping": return "Stopping local API..."; case "degraded": return "Attention needed"; case "error": return "Local API error"; default: return "Local API offline"; } } function deriveHealthDetail(snapshot: RuntimeSnapshot): string { if (snapshot.config.connection_mode === "remote") { return snapshot.config.server_url || "Configure remote endpoint"; } if (snapshot.last_error) { return snapshot.last_error.message; } if (snapshot.status === "ready" && snapshot.process_running) { return snapshot.resolved_url || "http://127.0.0.1:8080"; } if (snapshot.status === "loading") { return "The local model is warming up. Please wait before sending a new message."; } if (snapshot.status === "stopped") { return "Start the local API to begin"; } return ""; } export function getRuntimeHealthSummary(snapshot: RuntimeSnapshot): RuntimeWorkspaceHealth { return { headline: deriveHealthHeadline(snapshot), detail: deriveHealthDetail(snapshot), tone: deriveHealthTone(snapshot), }; } // === Phase 4: Async Path and Model Validation === /** * Validate models directory by scanning it */ export async function validateModelsDirectory( path: string | null, ): Promise { if (!path) { return { valid: false, modelCount: 0, error: "No models directory selected" }; } try { const models = await scanRuntimeModels(path); return { valid: true, modelCount: models.length, error: null, }; } catch (e) { return { valid: false, modelCount: 0, error: e instanceof Error ? e.message : String(e), }; } } /** * Validate that a model path exists in the scanned models list */ export function validateModelFile( path: string | null, scannedModels: RuntimeModelFile[], ): ModelFileValidation { if (!path) { return { valid: false, exists: false, error: "No model selected" }; } const exists = scannedModels.some((m) => m.path === path); return { valid: exists, exists, error: exists ? null : "Model file not found in current models directory", }; }