import { stat } from "node:fs/promises"; import { createRequire } from "node:module"; import { DatabaseSync } from "node:sqlite"; import { join, resolve } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { loadConfig, loadPriceCatalog, type UltraPaths } from "../config/loader.js"; import { discoverInterruptedRuns } from "../context/recovery.js"; import { listExperiments, minimumSampleSummary } from "../experiments/runtime.js"; import { freeDoctor } from "../provider/model-discovery.js"; import { formatPriceCoverage, priceCoverage } from "../models/price-coverage.js"; import { rosterFor } from "../models/roster.js"; import { inspectMcpSettings, inspectProfileMarker } from "../security/profiles.js"; import { EventStore } from "../telemetry/event-store.js"; import { PROJECTED_EXPERIMENT_EVENTS } from "../telemetry/sqlite-projector.js"; import type { BaseEvent } from "../types.js"; const require = createRequire(import.meta.url); /** Events that belong to the installation rather than to any one task. */ const GLOBAL_TELEMETRY_EVENTS = new Set(["config.changed", "budget.updated", "experiment.started", "experiment.evaluated", "experiment.stopped", "recommendation.accepted", "recommendation.rejected", "export.created"]); function packageVersion(name: string): string | undefined { try { let packageFile: string; try { packageFile = require.resolve(`${name}/package.json`); } catch { const dependency = require.resolve("pi-agents"); const marker = `${resolve("/", "node_modules")}/`; const index = dependency.lastIndexOf(marker); if (index < 0) return undefined; packageFile = join(dependency.slice(0, index + marker.length), name, "package.json"); } const packageJson = require(packageFile) as { version?: unknown }; return typeof packageJson.version === "string" ? packageJson.version : undefined; } catch { return undefined; } } async function permission(file: string): Promise<"0600" | "missing" | "wrong" | "error"> { try { const mode = (await stat(file)).mode & 0o777; return mode === 0o600 ? "0600" : "wrong"; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; return "error"; } } async function directoryPermission(directory: string): Promise<"0700" | "missing" | "wrong" | "error"> { try { const entry = await stat(directory); return entry.isDirectory() && (entry.mode & 0o777) === 0o700 ? "0700" : "wrong"; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; return "error"; } } function sameSet(left: Iterable, right: Iterable): boolean { const expected = new Set(left); const actual = new Set(right); return expected.size === actual.size && [...expected].every((value) => actual.has(value)); } export interface DoctorDependencies { paths: UltraPaths; agentDir: string; ready: () => Promise } /** * A read-only report on whether this installation is in a state where a run can be trusted: * profile agreement, version pins, MCP hygiene, price-catalog age, projection integrity against * the event log, file permissions, and experiment sample sufficiency. */ export async function runDoctor(deps: DoctorDependencies, context: ExtensionContext): Promise { const config = await loadConfig(deps.paths); const marker = await inspectProfileMarker(deps.agentDir); const models = context.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`); const catalog = await loadPriceCatalog(deps.paths); const lines = [ `agent-dir=PASS ${deps.agentDir}`, `profile=${config.profile}`, `profile-marker=${marker.valid ? `PASS ${marker.profile}` : `FAIL ${marker.reason}`}`, `mode=${config.mode}`, `policy=${config.policy}`, `models=${models.join(",")}`, ...(config.profile === "free" ? freeDoctor(models) : []), ]; if (marker.valid && marker.profile === config.profile) await deps.ready(); else if (marker.valid) lines.push(`profile-consistency=FAIL marker=${marker.profile} config=${config.profile}`); else if (config.mode === "auto") lines.push(`profile-consistency=FAIL auto mode requires a valid profile marker`); else lines.push("profile-consistency=PASS explicit mode without a marker"); const runtimePi = packageVersion("@earendil-works/pi-coding-agent"); const runtimeAgents = packageVersion("pi-agents"); const nodeMajor = Number(process.versions.node.split(".", 1)[0]); const compatibility = runtimePi === config.compatibility.piVersion && runtimeAgents === config.compatibility.piAgentsVersion && nodeMajor >= 24; lines.push(`compatibility=${compatibility ? "PASS" : "FAIL"} pinnedPi=${config.compatibility.piVersion} runtimePi=${runtimePi ?? "unknown"} pinnedPiAgents=${config.compatibility.piAgentsVersion} runtimePiAgents=${runtimeAgents ?? "unknown"} node=${process.versions.node}`); const mcp = await inspectMcpSettings(deps.agentDir); lines.push(`mcp=${mcp.valid ? `PASS valid servers=${mcp.serverCount}` : mcp.present ? `FAIL ${mcp.reason}` : "WARN missing"} profile=${deps.agentDir}`); const catalogDate = Date.parse(`${catalog.version}T00:00:00.000Z`); const catalogAge = Number.isFinite(catalogDate) ? Math.max(0, Math.floor((Date.now() - catalogDate) / 86_400_000)) : undefined; lines.push(`price-catalog-age=${catalogAge !== undefined && catalogAge <= 30 ? "PASS" : "FAIL"} version=${catalog.version} days=${catalogAge ?? "invalid"}`); // Every declared model has to reach a price, or the budget governor cannot bite. Reported here so // an expensive roster shows up as a number of turns per task before it shows up as a refusal. const registryCost = (model: string) => { const slash = model.indexOf("/"); const entry = slash > 0 ? context.modelRegistry.find(model.slice(0, slash), model.slice(slash + 1)) : undefined; return entry?.cost ? { input: entry.cost.input, cacheRead: entry.cost.cacheRead, output: entry.cost.output } : undefined; }; lines.push(...formatPriceCoverage(priceCoverage(rosterFor(config).tiers, catalog, registryCost, config.policy), catalog, config.policy)); let events: BaseEvent[] = []; let eventSqlite = "FAIL unavailable"; try { events = await new EventStore(deps.paths.events).all(); if ((await permission(deps.paths.database)) !== "0600") throw new Error("database permission"); const database = new DatabaseSync(deps.paths.database, { readOnly: true }); try { const integrity = database.prepare("PRAGMA integrity_check").get() as Record; const expectedTaskRecords = new Map(); for (const event of events) if (!event.eventType.startsWith("session.") && !GLOBAL_TELEMETRY_EVENTS.has(event.eventType)) expectedTaskRecords.set(event.taskId, expectedTaskRecords.get(event.taskId) ?? event); const expectedTaskIds = [...expectedTaskRecords.keys()]; const actualTaskRecords = database.prepare("SELECT task_id, session_id, profile, started_at FROM tasks").all() as Array<{ task_id: string; session_id: string; profile: string; started_at: string }>; const actualTaskIds = actualTaskRecords.map((row) => row.task_id); const expectedCallIds = events.filter((event) => event.eventType === "model.call.completed" || event.eventType === "model.call.failed").map((event) => event.eventId); const actualCallIds = (database.prepare("SELECT call_id FROM model_calls").all() as Array<{ call_id: string }>).map((row) => row.call_id); const expectedVerificationIds = events.filter((event) => event.eventType === "verification.completed").map((event) => event.eventId); const actualVerificationIds = (database.prepare("SELECT verification_id FROM verifications").all() as Array<{ verification_id: string }>).map((row) => row.verification_id); const expectedAgentIds = events.filter((event) => event.eventType === "agent.spawn.proposed" || event.eventType === "agent.spawned").map((event) => `${event.taskId}\u0000${String(event.nodeId ?? event.eventId)}`); const actualAgentIds = (database.prepare("SELECT task_id, node_id FROM agents").all() as Array<{ task_id: string; node_id: string }>).map((row) => `${row.task_id}\u0000${row.node_id}`); const expectedRouteIds = events.filter((event) => event.eventType === "route.validated").map((event) => event.eventId); const actualRouteIds = (database.prepare("SELECT id FROM route_decisions").all() as Array<{ id: string }>).map((row) => row.id); const expectedConfigVersions = events.filter((event) => event.eventType === "config.changed" && typeof event.version === "string" && typeof event.configHash === "string" && event.configJson && typeof event.configJson === "object" && !Array.isArray(event.configJson)).map((event) => event.version as string); const actualConfigVersions = (database.prepare("SELECT version FROM config_versions").all() as Array<{ version: string }>).map((row) => row.version); const expectedExperimentIds = events.filter((event) => PROJECTED_EXPERIMENT_EVENTS.has(event.eventType) && typeof event.experimentId === "string").map((event) => event.experimentId as string); const actualExperimentIds = (database.prepare("SELECT experiment_id FROM experiments").all() as Array<{ experiment_id: string }>).map((row) => row.experiment_id); const identitiesMatch = new Set(events.map((event) => event.eventId)).size === events.length && sameSet(expectedTaskIds, actualTaskIds) && actualTaskRecords.every((row) => { const expected = expectedTaskRecords.get(row.task_id); return expected?.sessionId === row.session_id && expected.profile === row.profile && expected.timestamp === row.started_at; }) && sameSet(expectedCallIds, actualCallIds) && sameSet(expectedVerificationIds, actualVerificationIds) && sameSet(expectedAgentIds, actualAgentIds) && sameSet(expectedRouteIds, actualRouteIds) && sameSet(expectedConfigVersions, actualConfigVersions) && sameSet(expectedExperimentIds, actualExperimentIds); eventSqlite = integrity.integrity_check === "ok" && identitiesMatch ? `PASS events=${events.length} tasks=${actualTaskIds.length}` : `FAIL events=${events.length} tasks=${actualTaskIds.length} expectedTasks=${new Set(expectedTaskIds).size} identity-mismatch`; } finally { database.close(); } } catch (error) { eventSqlite = `FAIL ${error instanceof Error ? error.message : "unavailable"}`; } lines.push(`event-sqlite=${eventSqlite}`); const incomplete = await discoverInterruptedRuns(deps.paths.events, deps.paths.ledgers); lines.push(`incomplete-runs=${incomplete.length ? `FAIL ${incomplete.length}` : "PASS 0"}`); const permissions = await Promise.all([deps.paths.hmacKey, deps.paths.vaultKey, deps.paths.database].map(async (file) => [file, await permission(file)] as const)); const claimsPermission = await directoryPermission(deps.paths.autoAdmissionClaims); lines.push(`permissions=${permissions.every(([, mode]) => mode === "0600") && claimsPermission === "0700" ? "PASS" : "FAIL"} ${permissions.map(([file, mode]) => `${file.split("/").at(-1)}=${mode}`).join(" ")} auto-admission-claims=${claimsPermission}`); let experimentData = "PASS no running experiment minimum=8/8/5"; try { const records = await listExperiments(deps.paths); const running = records.filter((record) => record.status === "running"); if (running.length) { const grouped = new Map(); for (const event of events) { if (!event.experimentId || event.taskId.startsWith("experiment:")) continue; const item = grouped.get(event.taskId) ?? { completed: false }; if (event.eventType === "experiment.assigned" && (event.cohort === "champion" || event.cohort === "challenger")) item.cohort = event.cohort; if (event.eventType === "result.completed") item.completed = true; grouped.set(event.taskId, item); } const counts = { championTasks: 0, challengerTasks: 0, verifiedComparablePairs: 0 }; for (const item of grouped.values()) if (item.completed && item.cohort) counts[`${item.cohort}Tasks` as "championTasks" | "challengerTasks"] += 1; counts.verifiedComparablePairs = Math.min(counts.championTasks, counts.challengerTasks); const summary = minimumSampleSummary(counts); experimentData = `${summary.sampleSufficient ? "PASS" : "WARN"} ${running[0]!.experimentId} champion=${counts.championTasks} challenger=${counts.challengerTasks} comparable=${counts.verifiedComparablePairs} required=8/8/5`; } } catch (error) { experimentData = `FAIL ${error instanceof Error ? error.message : "invalid experiment data"}`; } lines.push(`experiment-data=${experimentData}`); lines.push(`budget=${config.budgets.acknowledged ? "configured" : "required"}`); return lines; }