diff --git a/apps/api/src/lib/feature-status.ts b/apps/api/src/lib/feature-status.ts new file mode 100644 index 00000000..2f0163a7 --- /dev/null +++ b/apps/api/src/lib/feature-status.ts @@ -0,0 +1,425 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import type { FeatureBundleState, FeatureStatus } from "@ashim/shared"; +import { FEATURE_BUNDLES, TOOL_BUNDLE_MAP } from "@ashim/shared"; + +// ── Paths ─────────────────────────────────────────────────────────────── + +const DATA_DIR = process.env.DATA_DIR || "/data"; +const AI_DIR = join(DATA_DIR, "ai"); +const MODELS_DIR = join(AI_DIR, "models"); +const INSTALLED_PATH = join(AI_DIR, "installed.json"); +const INSTALLED_TMP_PATH = `${INSTALLED_PATH}.tmp`; +const LOCK_PATH = join(AI_DIR, "install.lock"); +const MANIFEST_PATH = process.env.FEATURE_MANIFEST_PATH || "/app/docker/feature-manifest.json"; + +export function getAiDir(): string { + return AI_DIR; +} + +export function getModelsDir(): string { + return MODELS_DIR; +} + +export function getManifestPath(): string { + return MANIFEST_PATH; +} + +// ── Directory setup ───────────────────────────────────────────────────── + +export function ensureAiDirs(): void { + mkdirSync(join(AI_DIR, "venv"), { recursive: true }); + mkdirSync(MODELS_DIR, { recursive: true }); + mkdirSync(join(AI_DIR, "pip-cache"), { recursive: true }); +} + +// ── Docker detection ──────────────────────────────────────────────────── + +export function isDockerEnvironment(): boolean { + return existsSync("/.dockerenv") || existsSync(MANIFEST_PATH); +} + +// ── installed.json cache ──────────────────────────────────────────────── + +interface InstalledBundle { + version: string; + installedAt: string; + models: string[]; +} + +interface InstalledData { + bundles: Record; +} + +let installedCache: InstalledData | null = null; + +function readInstalled(): InstalledData { + if (installedCache) return installedCache; + + if (!existsSync(INSTALLED_PATH)) { + installedCache = { bundles: {} }; + return installedCache; + } + + try { + const raw = readFileSync(INSTALLED_PATH, "utf-8"); + installedCache = JSON.parse(raw) as InstalledData; + } catch { + console.warn("[feature-status] installed.json is corrupt or unreadable, treating as empty"); + installedCache = { bundles: {} }; + } + + return installedCache; +} + +function writeInstalled(data: InstalledData): void { + writeFileSync(INSTALLED_TMP_PATH, JSON.stringify(data, null, 2), "utf-8"); + renameSync(INSTALLED_TMP_PATH, INSTALLED_PATH); +} + +export function invalidateCache(): void { + installedCache = null; +} + +// ── Install status queries ────────────────────────────────────────────── + +export function isFeatureInstalled(bundleId: string): boolean { + const data = readInstalled(); + return bundleId in data.bundles; +} + +export function isToolInstalled(toolId: string): boolean { + const bundleId = TOOL_BUNDLE_MAP[toolId]; + if (!bundleId) return false; + return isFeatureInstalled(bundleId); +} + +// ── Install status mutations ──────────────────────────────────────────── + +export function markInstalled(bundleId: string, version: string, models: string[]): void { + const data = readInstalled(); + data.bundles[bundleId] = { + version, + installedAt: new Date().toISOString(), + models, + }; + writeInstalled(data); + invalidateCache(); +} + +export function markUninstalled(bundleId: string): void { + const data = readInstalled(); + delete data.bundles[bundleId]; + writeInstalled(data); + invalidateCache(); +} + +// ── Install lock (file-based) ─────────────────────────────────────────── + +interface LockData { + bundleId: string; + startedAt: string; + pid: number; +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function acquireInstallLock(bundleId: string): boolean { + if (existsSync(LOCK_PATH)) { + try { + const raw = readFileSync(LOCK_PATH, "utf-8"); + const lock = JSON.parse(raw) as LockData; + if (isPidAlive(lock.pid)) { + return false; + } + // PID is dead — stale lock, remove it + unlinkSync(LOCK_PATH); + } catch { + // Corrupt lock file, remove it + unlinkSync(LOCK_PATH); + } + } + + const lock: LockData = { + bundleId, + startedAt: new Date().toISOString(), + pid: process.pid, + }; + writeFileSync(LOCK_PATH, JSON.stringify(lock, null, 2), "utf-8"); + return true; +} + +export function releaseInstallLock(): void { + try { + unlinkSync(LOCK_PATH); + } catch { + // Lock already gone + } +} + +export function getInstallingBundle(): { + bundleId: string; + startedAt: string; +} | null { + if (!existsSync(LOCK_PATH)) return null; + + try { + const raw = readFileSync(LOCK_PATH, "utf-8"); + const lock = JSON.parse(raw) as LockData; + + if (!isPidAlive(lock.pid)) { + unlinkSync(LOCK_PATH); + return null; + } + + return { bundleId: lock.bundleId, startedAt: lock.startedAt }; + } catch { + // Corrupt lock file + try { + unlinkSync(LOCK_PATH); + } catch { + // already gone + } + return null; + } +} + +// ── Progress tracking (in-memory, for SSE) ────────────────────────────── + +let currentProgress: { + bundleId: string; + progress: { percent: number; stage: string } | null; + error: string | null; +} | null = null; + +export function setInstallProgress( + bundleId: string | null, + progress: { percent: number; stage: string } | null, + error: string | null, +): void { + if (!bundleId) { + currentProgress = null; + return; + } + currentProgress = { bundleId, progress, error }; +} + +// ── Manifest reading ──────────────────────────────────────────────────── + +interface ManifestModel { + id: string; + path?: string; + minSize?: number; +} + +interface ManifestBundle { + models: ManifestModel[]; +} + +interface Manifest { + bundles: Record; +} + +function readManifest(): Manifest | null { + if (!existsSync(MANIFEST_PATH)) return null; + try { + return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as Manifest; + } catch { + return null; + } +} + +// ── Startup recovery ──────────────────────────────────────────────────── + +function deleteDownloadingFiles(dir: string): void { + if (!existsSync(dir)) return; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".downloading")) { + const fullPath = join(entry.parentPath ?? entry.path, entry.name); + try { + unlinkSync(fullPath); + console.info(`[feature-status] Deleted partial download: ${fullPath}`); + } catch { + // best-effort + } + } + } +} + +export function recoverInterruptedInstalls(): void { + // 1. Delete partial downloads + deleteDownloadingFiles(MODELS_DIR); + + // 2. Delete stale tmp file + if (existsSync(INSTALLED_TMP_PATH)) { + try { + unlinkSync(INSTALLED_TMP_PATH); + console.info("[feature-status] Deleted stale installed.json.tmp"); + } catch { + // best-effort + } + } + + // 3. Delete bootstrapping venv + const bootstrappingDir = join(AI_DIR, "venv.bootstrapping"); + if (existsSync(bootstrappingDir)) { + try { + rmSync(bootstrappingDir, { recursive: true, force: true }); + console.info("[feature-status] Deleted stale venv.bootstrapping/"); + } catch { + // best-effort + } + } + + // 4. Check stale lock + if (existsSync(LOCK_PATH)) { + try { + const raw = readFileSync(LOCK_PATH, "utf-8"); + const lock = JSON.parse(raw) as LockData; + if (!isPidAlive(lock.pid)) { + unlinkSync(LOCK_PATH); + console.warn( + `[feature-status] Removed stale install lock for "${lock.bundleId}" (PID ${lock.pid} is dead)`, + ); + } + } catch { + try { + unlinkSync(LOCK_PATH); + } catch { + // already gone + } + } + } + + // 5. Verify installed bundles still have their model files + const manifest = readManifest(); + if (manifest) { + const data = readInstalled(); + for (const bundleId of Object.keys(data.bundles)) { + const manifestBundle = manifest.bundles[bundleId]; + if (!manifestBundle) continue; + + for (const model of manifestBundle.models) { + if (!model.path) continue; + const modelPath = join(MODELS_DIR, model.path); + if (!existsSync(modelPath)) { + console.warn(`[feature-status] Bundle "${bundleId}" missing model file: ${model.path}`); + // Don't remove from installed.json — getFeatureStates will surface the error + break; + } + if (model.minSize != null && model.minSize > 0) { + try { + const st = statSync(modelPath); + if (st.size < model.minSize) { + console.warn( + `[feature-status] Bundle "${bundleId}" model "${model.path}" is undersized (${st.size} < ${model.minSize})`, + ); + break; + } + } catch { + // stat failed, treat as missing + console.warn(`[feature-status] Bundle "${bundleId}" cannot stat model: ${model.path}`); + break; + } + } + } + } + } + + invalidateCache(); +} + +// ── Feature states (composite view) ───────────────────────────────────── + +function verifyBundleModels(bundleId: string): string | null { + const manifest = readManifest(); + if (!manifest) return null; + + const manifestBundle = manifest.bundles[bundleId]; + if (!manifestBundle) return null; + + for (const model of manifestBundle.models) { + if (!model.path) continue; + const modelPath = join(MODELS_DIR, model.path); + if (!existsSync(modelPath)) { + return `Missing model file: ${model.path}`; + } + if (model.minSize != null && model.minSize > 0) { + try { + const st = statSync(modelPath); + if (st.size < model.minSize) { + return `Model "${model.path}" is undersized (${st.size} < ${model.minSize})`; + } + } catch { + return `Cannot read model file: ${model.path}`; + } + } + } + + return null; +} + +export function getFeatureStates(): FeatureBundleState[] { + const installed = readInstalled(); + const lock = getInstallingBundle(); + + return Object.values(FEATURE_BUNDLES).map((bundle) => { + const installedBundle = installed.bundles[bundle.id]; + let status: FeatureStatus = "not_installed"; + let error: string | null = null; + let progress: { percent: number; stage: string } | null = null; + + if (lock && lock.bundleId === bundle.id) { + status = "installing"; + if (currentProgress && currentProgress.bundleId === bundle.id) { + progress = currentProgress.progress; + if (currentProgress.error) { + status = "error"; + error = currentProgress.error; + } + } + } else if (installedBundle) { + // Verify model files exist and are properly sized + const modelError = verifyBundleModels(bundle.id); + if (modelError) { + status = "error"; + error = modelError; + } else { + status = "installed"; + } + } else if (currentProgress?.bundleId === bundle.id && currentProgress.error) { + status = "error"; + error = currentProgress.error; + } + + return { + id: bundle.id, + name: bundle.name, + description: bundle.description, + status, + installedVersion: installedBundle?.version ?? null, + estimatedSize: bundle.estimatedSize, + enablesTools: bundle.enablesTools, + progress, + error, + }; + }); +}