Files
SnapOtter/apps/api/src/lib/feature-status.ts
T
ashim-hq 546cf52222 fix: install lock used server PID causing permanent stale lock
The lock file stored process.pid (Node server PID) which is always
alive. When releaseInstallLock wasn't called (e.g., error path),
the lock persisted forever, making the bundle stuck in "installing"
and blocking all future installs.

Fix: remove PID from lock entirely. Use simple file existence as
mutex. On server startup, unconditionally delete any lock file.
2026-04-18 11:39:20 +08:00

395 lines
12 KiB
TypeScript

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<string, InstalledBundle>;
}
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;
}
export function acquireInstallLock(bundleId: string): boolean {
if (existsSync(LOCK_PATH)) {
return false;
}
const lock: LockData = {
bundleId,
startedAt: new Date().toISOString(),
};
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;
return { bundleId: lock.bundleId, startedAt: lock.startedAt };
} catch {
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<string, ManifestBundle>;
}
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. Delete stale lock — if the server is starting up, any previous install is dead
if (existsSync(LOCK_PATH)) {
try {
const raw = readFileSync(LOCK_PATH, "utf-8");
const lock = JSON.parse(raw) as LockData;
unlinkSync(LOCK_PATH);
console.warn(
`[feature-status] Removed stale install lock for "${lock.bundleId}" (server restarted)`,
);
} 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,
};
});
}