- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)
Result: {"status":"keep","validation_score":100,"response_time_ms":500}
465 lines
12 KiB
TypeScript
465 lines
12 KiB
TypeScript
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<string, FieldValidation>;
|
|
}
|
|
|
|
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<string, FieldValidation> = {};
|
|
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<string, FieldValidation>): 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<keyof RuntimeConfig> = [
|
|
"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<DirectoryValidation> {
|
|
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",
|
|
};
|
|
}
|