/** Health state machine: HEALTHY / DEGRADED / UNAVAILABLE. */ import { existsSync, readFileSync } from "fs"; import { resolve } from "path"; import type { ObsidianRagConfig } from "../utils/config.js"; export type HealthState = "healthy" | "degraded" | "unavailable"; export interface HealthStatus { state: HealthState; ollama_up: boolean; index_exists: boolean; vault_exists: boolean; total_docs: number; total_chunks: number; last_sync: string | null; active_job: { id: string; mode: string; progress: number } | null; } export interface ProbeResult { ollama_up: boolean; index_exists: boolean; vault_exists: boolean; total_docs: number; total_chunks: number; last_sync: string | null; } const REPROBE_INTERVAL_MS = 30_000; export function createHealthMachine(_config: ObsidianRagConfig) { let currentState: HealthState = "unavailable"; let status: ProbeResult = { ollama_up: false, index_exists: false, vault_exists: false, total_docs: 0, total_chunks: 0, last_sync: null, }; let activeJob: { id: string; mode: string; progress: number } | null = null; let reprobeTimer: ReturnType | null = null; function transition(probe: ProbeResult): void { status = probe; const prev = currentState; if (!probe.index_exists || !probe.vault_exists) { currentState = "unavailable"; } else if (!probe.ollama_up) { currentState = "degraded"; } else { currentState = "healthy"; } if (prev !== currentState) { console.log(`[obsidian-rag] Health: ${prev} → ${currentState}`); } } function get(): HealthStatus { return { state: currentState, ...status, active_job: activeJob }; } function setActiveJob(job: { id: string; mode: string; progress: number } | null): void { activeJob = job; } function startReprobing(fn: () => Promise): void { if (reprobeTimer) clearInterval(reprobeTimer); reprobeTimer = setInterval(async () => { const probe = await fn(); transition(probe); }, REPROBE_INTERVAL_MS); } function stop(): void { if (reprobeTimer) { clearInterval(reprobeTimer); reprobeTimer = null; } } return { get, transition, setActiveJob, startReprobing, stop }; } export async function probeAll(config: ObsidianRagConfig): Promise { const { resolveVectorDbPath } = await import("../utils/lancedb.js"); const vaultPath = resolve(process.cwd(), config.vault_path); const dbPath = resolveVectorDbPath(config); const vaultExists = existsSync(vaultPath); const indexExists = existsSync(String(dbPath)); const ollamaUp = await probeOllama(config.embedding.base_url); let totalDocs = 0; let totalChunks = 0; let lastSync: string | null = null; if (indexExists) { try { const dataDir = resolveDataDir(); const syncPath = resolve(dataDir, "sync-result.json"); if (existsSync(syncPath)) { const data = JSON.parse(readFileSync(syncPath, "utf-8")); lastSync = data.timestamp ?? null; totalDocs = data.indexed_files ?? 0; totalChunks = data.total_chunks ?? 0; } } catch { // ignore } } return { ollama_up: ollamaUp, index_exists: indexExists, vault_exists: vaultExists, total_docs: totalDocs, total_chunks: totalChunks, last_sync: lastSync, }; } function resolveDataDir(): string { const cwd = process.cwd(); const devDataDir = resolve(cwd, ".obsidian-rag"); const devVaultMarker = resolve(cwd, "KnowledgeVault"); if (existsSync(devDataDir) || existsSync(devVaultMarker)) { return devDataDir; } const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; return resolve(home, ".obsidian-rag"); } async function probeOllama(baseUrl: string): Promise { try { const res = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) }); return res.ok; } catch { return false; } }