Files
ForgeLM/tauri-app/src/features/runtime/runtime-utils.ts
T
Evandro Fonseca Junior 77c57c3d45 Add runtime state validation before ForgeAgent HTTP call
- 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}
2026-04-02 14:37:01 -03:00

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",
};
}