/** * The two executed install paths: the supervised daemon unit, and the code-graph * timer. * * Both used to end at a printed list the operator retyped — `setup-host.ts` * composed the exact `sudo` lines and showed them, and `graph-setup --write` * staged its files and said it "never runs `systemctl` itself". Retyping is not * a safety property: it is the same commands with a chance of a typo, and it is * why a fleet sits half-installed. These run them, behind one confirm each. * * Why this module exists rather than the code living where its pieces do: * `graph-health.ts` already imports `graph.ts`, so orchestration that verifies * with `probeCodeGraph()` cannot sit in `graph.ts` without a cycle — and `cli.ts` * is argument parsing, not sequencing. Both the CLI verbs and the wizard's tail * call in here; `privileged.ts` stays the primitive underneath. */ import { existsSync, readFileSync } from "node:fs"; import { platform } from "node:os"; import { join } from "node:path"; import { loadConfig, stateDir } from "./config.ts"; import { pauseInstance, pauseSourceToken, setPaused, statusSnapshot } from "./daemon.ts"; import { fleetLayers, DEFAULT_HERDR_SESSION, DEFAULT_HERDR_UNIT } from "./fleet.ts"; import { spawnSync } from "node:child_process"; import { livingDaemon } from "./lifecycle.ts"; import { dbPath, openStore } from "./store.ts"; import { resolveScope, pauseAndDrain, type DrainDeps, type UpgradeScope, } from "./upgrade.ts"; import { graphConflictMessage, graphRepos, formatGraphSetup, legacyReindexFiles, legacyReindexNote, mcpEntry, planGraphSetup, reindexScriptPath, reindexUnitName, resolvePrereqs, stagingConflict, unitPaths, writeGraphSetup, type GraphPrereqs, type GraphRepo, } from "./graph.ts"; import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts"; import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts"; import { agentRenameVerdict, checkEscalation, checkSessionRestartContext, herdrOwnership, DEFAULT_AGENT_RENAME_DEPS, defaultServiceRuntime, planHostRuntime, totalConfiguredWorkers, writeHostRuntime, STAGED_SERVICE_NAME, SYSTEMD_UNIT_DIR, RECOVER_SCRIPT_INSTALL_PATH, type AgentRenameDeps, type EscalationDeps, type HerdrOwnership, type HerdrOwnershipFacts, type ServiceRuntime, } from "./setup-host.ts"; import type { WizardUi } from "./wizard-ui.ts"; import type { Caps, ProjectConfig, Store } from "./types.ts"; /** * Every outcome a caller has to tell apart. `staged` is the non-Linux answer: * the files are real and correct, only the `systemctl` half is impossible. */ export type InstallOutcome = | { kind: "installed"; wrote: readonly string[] } | { kind: "staged"; wrote: readonly string[]; reason: string } | { kind: "declined"; wrote: readonly string[] } | { kind: "refused"; reason: string } | { kind: "failed"; reason: string }; export interface InstallDeps { privileged?: PrivilegedDeps; escalation?: EscalationDeps; /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */ platform?: () => string; unitDir?: string; /** * Where the recovery playbook installs. Injectable so the idempotency gate * is testable hermetically; the real install uses * {@link RECOVER_SCRIPT_INSTALL_PATH}. */ recoverScriptInstallPath?: string; /** * The runtime the plan derives from. Injectable so tests pin herdr * present/absent deterministically; the default ({@link defaultServiceRuntime}) * detects herdr from the host's own PATH. */ runtime?: ServiceRuntime; /** * Whether the host configures more than one project, which scopes the * recovery unit's RECOVER_PROJECT (host-global: never one project's name on * a multi-project host, #510/#530). Injectable so tests pin the shape * hermetically; the default ({@link hostMultiProject}) reads the config. */ multiProject?: boolean; /** * The pause/drain fence around the restart (#512): which projects to drain, * how long to wait, and the store used to attribute a run the restart kills. * Injectable so tests pin the drain hermetically; the defaults read the real * daemon, sentinel and store. */ drain?: { deps?: DrainDeps; /** How long the drain waits for live workers before giving up and staying * paused; absent waits indefinitely, the `upgrade` posture. */ timeoutMs?: number; /** The store consulted for runs still live at restart time, so they are * attributed before the session host restarts under them. */ drainStore?: Pick; }; /** * The live herdr CLI seam for the post-install agent rename (#541). * Injectable so tests pin the verdict without touching the host's herdr; the * default is the same `herdr --session` CLI recover.sh and the tick use. */ agentRename?: AgentRenameDeps; /** * The environment the caller's context is read from (#834): `$HERDR_ENV` says * this shell is a pane inside the unit the install restarts. Injectable so * the refusal is testable without a live Herdr session. */ env?: Record; /** * The live supervision facts and the one remedy for them (#893). Injectable * so the ownership transfer and its readiness gate are testable without * touching real systemd or a real herdr server — production reads * `herdr session list --json` and `systemctl show`. */ supervision?: SupervisionDeps; } /** The reads and the one write the #893 ownership transfer needs. */ export interface SupervisionDeps { /** Who is serving the fleet session right now. */ facts: (session: string) => HerdrOwnershipFacts; /** Bounded readiness polling between reads. */ sleep: (ms: number) => Promise; /** How many times the post-restart gate re-reads before giving up. */ attempts?: number; /** Milliseconds between those reads. */ intervalMs?: number; } /** * `systemctl` exists only on Linux, and staging is still worth doing everywhere: * a macOS operator reading the plan wants the rendered unit on disk to copy to * the box that will run it. So this is checked *after* the files are written. */ function linuxOnly(deps: InstallDeps): string | undefined { return (deps.platform ?? platform)() === "linux" ? undefined : "systemd install is Linux-only; staged files are at"; } /** * Whether a file holds exactly the rendered bytes. The "keep" side of * setup-host.ts's `actionFor`: a unit systemd already reads that matches what * would be written needs no reinstall. Reads can fail (permissions, vanished * mid-flight) — a read error is a mismatch, never silently "matches", because * the whole point is to make the plan only claim a unit install is redundant * when it can see the installed copy. Comparing against the rendered content * rather than the staged file keeps the comparison read-only: staging now * happens only after consent (#720), so nothing may be written to compare. */ function sameContent(path: string, content: string): boolean { try { return readFileSync(path, "utf8") === content; } catch { return false; } } /** * Install the supervised daemon unit: re-render, stage, then run the plan's * steps in order. The steps are the plan's own {@link HostRuntimePlan.steps} — * the same single list its {@link installCommands} display renders from — so * what is printed and what is executed can never diverge. */ export async function runHostInstall( /** * The project whose per-project tail to write, or `undefined` for a * host-global install: the units are host-global (same staged paths, same * systemd destinations, one shared daemon), so no project still installs * and starts them. The per-project tail (tick config, brief link) is the * only part that needs a name — with none, it is skipped with a printed * note (#530). */ project: ProjectConfig | undefined, caps: Caps, telegramStateDir: string, ui: WizardUi, deps: InstallDeps = {}, ): Promise { const verdict = checkEscalation("setup host", deps.escalation); if (verdict.kind === "refuse") { ui.notify(verdict.message, "error"); return { kind: "refused", reason: verdict.message }; } const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR; // The plan is computed eagerly — `planHostRuntime` renders every byte and // compares it against disk, so the prompt can print exactly what would be // written and the currentInstall gate below can decide a re-run restarts // nothing. But rendering is read-only: no file is written here. Staging // happens only after consent (#510), so the prompt's "Nothing has been run // yet" is true of the staged tree when it is printed, and a declined run // leaves every staged file byte-identical. const plan = planHostRuntime( project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps), unitDir, deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH, deps.multiProject ?? hostMultiProject(), ); // No pane-shell key is planned (unusable login shell, unparseable config): // the operator hears why before the consent prompt, not after an install // that quietly had nothing to say about the shell. if (plan.herdrConfigWarning !== undefined) ui.notify(plan.herdrConfigWarning, "warning"); if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning"); // A host-global install (no --project): the per-project tail was // deliberately not written, and the operator must be told which files and // how to get them — never a silent skip. if (plan.noProject !== undefined) { ui.notify( `No project named: ${plan.noProject.skipped.join(", ")} were not written. ${plan.noProject.how}.`, "info", ); } const installed = join(unitDir, STAGED_SERVICE_NAME); const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT); const blocked = linuxOnly(deps); if (blocked !== undefined) { // A non-Linux host cannot run systemd, so staging IS the deliverable: the // rendered files are written for the operator to copy to the box that will // run them, and only the systemctl half is refused. There is no consent // prompt on this path — nothing privileged to approve — so the write lands // here, not in the (never reached) post-consent hook. const { wrote, warnings } = writeHostRuntime(plan); for (const warning of warnings) ui.notify(warning, "warning"); ui.notify(`${blocked} ${plan.service.path}`, "warning"); return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` }; } // A no-op re-run: every unit the steps would install is already current on // disk, so installing them again would only touch mtimes and restart the // units for nothing. Report success without running a single privileged step. // Nothing is staged here either: a re-run with nothing to install has no // reason to touch the staged tree. if (plan.currentInstall) { ui.notify("The supervised daemon and recovery unit are already current — nothing to install, nothing restarted.", "info"); return { kind: "installed", wrote: [] }; } // The caller must survive the transaction it started (#834). Placed here on // purpose: after the plan and the no-op gate — so a host with no herdr unit, // and a re-run with nothing to install, are never refused for a restart that // is not going to happen — and before the drain fence and the post-consent // staging, so a refusal has mutated nothing at all. const context = checkSessionRestartContext("setup host", plan.steps, deps.env); if (context.kind === "refuse") { ui.notify(context.message, "error"); return { kind: "refused", reason: context.message }; } // argv, not shell: `installCommands` renders `sudo …` strings for humans to // read, and re-parsing those into an argv is how a path with a space becomes // two arguments. The plan carries the exact argv once, in // {@link HostRuntimePlan.steps}; we execute that same list, so the printed // plan and the run are the same steps (#509). // // The fence `upgrade` already has (#512): pause new issue claims, drain live // workers to idle, restart the units, then restore the dispatch state this // began in. The consent prompt below names how many workers will be drained // so the operator can choose the moment; it authorises the whole batch, // drain included, in one confirm (the `beforeRun` hook). const drainDeps = hostInstallDrainDeps(ui, deps); // Host-wide by default (#389): the daemon this restarts serves every // configured project, so the drain covers all of them — narrowing to the // named project would restart the shared session host under the other // projects' live workers without ever counting them. const fenceScope = resolveScope(drainDeps, "setup host"); const liveWorkers = fenceScope.selectors.reduce((n, s) => n + drainDeps.snapshot(s).liveWorkers, 0); let fence: { scope: UpgradeScope; initialPaused: boolean } | undefined; // What the post-consent staging actually wrote, surfaced by the outcomes that // report it. Empty on a decline — the hook never ran. let wrote: readonly string[] = []; const beforeRun = async (): Promise => { // Consent has been given; the plan's files may now land. Staging here — // post-confirm, pre-step, under the same confirm that authorised the batch // — is what makes the prompt's "Nothing has been run yet" true of the // staged tree when it is printed: a declined prompt never reaches this // hook, so nothing is staged and a re-run still plans an install (#510). const staged = writeHostRuntime(plan); wrote = staged.wrote; for (const warning of staged.warnings) ui.notify(warning, "warning"); fence = await pauseAndDrain(drainDeps, "setup host", { ...(deps.drain?.timeoutMs === undefined ? {} : { timeoutMs: deps.drain.timeoutMs }), }); // Any run still live when the session host is about to restart is killed by // it; record the actor on its row so the classifier reads an administrative // kill rather than charging a failure for an operator's restart (#512). The // fence drained to idle, so in the healthy path this records nothing. attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore); }; // Who is serving the fleet session, before anything is staged or run (#893). // A `systemctl restart` that returns 0 does not prove supervision moved: an // unmanaged `herdr --session server` holding the session makes every // start of the unit exit 1, and `Type=simple` returns before the child finds // out. So the collision is resolved by stopping the unmanaged session inside // this transaction, or the transaction refuses before it mutates anything. const supervision = deps.supervision ?? defaultSupervisionDeps(); const session = plan.herdrSession ?? DEFAULT_HERDR_SESSION; let transferSteps: PrivilegedStep[] = []; if (plan.herdrUnit !== undefined) { const ownership = herdrOwnership(supervision.facts(session)); if (ownership.kind === "unknown") { // The remedy here stops somebody's live session, so an unreadable fact is // a refusal, never an assumption. Nothing has been staged or installed. const reason = `refused: ${ownership.detail}, so this install cannot tell whether ${DEFAULT_HERDR_UNIT} owns the ` + `fleet session or an unmanaged server does. Stopping the wrong one kills a live fleet, so nothing ` + `was staged or installed. Check \`systemctl status ${DEFAULT_HERDR_UNIT}\` and ` + `\`herdr session list\`, then re-run.`; ui.notify(reason, "error"); return { kind: "refused", reason }; } if (ownership.kind === "unmanaged") { ui.notify( `The fleet session is served outside systemd: ${ownership.detail}. This install stops that server ` + `first so ${DEFAULT_HERDR_UNIT} can take the session over — every pane in it restarts under the unit.`, "warning", ); transferSteps = [ { // The fleet account's own session, so no escalation: `setup host` // already runs as that account (the escalation guard proves it). title: `stop the unmanaged herdr session "${session}" so ${DEFAULT_HERDR_UNIT} can own it`, argv: ["herdr", "session", "stop", session], unprivileged: true, }, ]; } } const outcome = await runPrivileged([...transferSteps, ...plan.steps], ui, { ...(deps.privileged === undefined ? {} : { deps: deps.privileged }), title: "Install and start the supervised session?", answerKey: "install-host", preamble: [ `Installs ${plan.service.path} as ${installed}, then enables and restarts it.`, ...(plan.herdrUnit === undefined ? [] : [ `Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`, "run it instead of dash, then enables and restarts it.", ...(plan.herdrConfigTarget === undefined ? [] : [ `And merges [terminal] default_shell into ${plan.herdrConfigTarget} as you, before that restart,`, "so the session server reads the pinned shell the moment it comes up.", ]), ]), "The unit runs as the account that staged it; nothing here changes that.", ...(transferSteps.length === 0 ? [] : [ `Stops the unmanaged herdr session "${session}" first — systemd cannot adopt a running process, so the ` + "session has to be handed over. Every pane in it comes back under the unit.", ]), // Retirement is named before the confirm that authorises it (#895): an // operator approving this batch is approving an unmount, and a removal // nobody announced is not consented to. ...(plan.retire === undefined ? [] : [ `Retires host state an earlier release installed and this one no longer ships: ` + `${[...plan.retire.units, ...plan.retire.staged].join(", ")}.`, "Worker sessions run as the fleet account again, so the read-only bind that served the separate " + "worker account is unmounted and its unit removed. The account itself is left alone.", ]), "", liveWorkers === 0 ? "No workers are live — nothing to drain before the restart." : `Pauses new claims and drains ${liveWorkers} live worker(s) to idle first — this can take minutes per worker — then restarts the units and resumes dispatch.`, ], beforeRun, }); if (outcome.kind === "declined") return { kind: "declined", wrote }; if (outcome.kind === "failed") { // Restore the preceding dispatch state: a restart that failed is still a // finished operator action whose units may or may not be up, so the fleet // must not sit paused for it. A throw from the drain itself (a timeout or // a superseded fence) never reaches here — it propagates and leaves // dispatch paused, fail-closed, exactly as upgrade does. if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey); return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` }; } // Success is not "the steps exited 0" (#893). `Type=simple` returns before the // session server can discover a collision and die, so the transaction proves // the canonical unit is still up AND owns the session — bounded, because an // auto-restart loop keeps looking momentarily alive. if (plan.herdrUnit !== undefined) { const settled = await proveSupervision(supervision, session); if (settled.kind !== "unit") { // Dispatch stays paused, deliberately and unlike a failed step: the fleet // is being served by something nobody supervises, and admitting workers // into that is the outcome this gate exists to prevent. The operator is // told what was observed rather than a generic failure. const reason = `${DEFAULT_HERDR_UNIT} did not take over the fleet session: ${settled.detail}. ` + `The units are installed, but supervision did not transfer, so dispatch stays paused. ` + `Check \`systemctl status ${DEFAULT_HERDR_UNIT}\` and \`herdr session list\`, then re-run \`setup host\` ` + `from a shell outside the session.`; ui.notify(reason, "error"); return { kind: "failed", reason }; } ui.notify(`${DEFAULT_HERDR_UNIT} owns the fleet session — ${settled.detail}.`, "info"); } if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey); ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info"); // #541: the tick plan may have restamped agentName away from the shared // default — the exact moment the live pane is stuck under the old name and // every tick declines with the rename hint. Perform the rename now that the // herdr session has (re)started, safely: exactly one pane carrying the old // identity is renamed; zero, several, or an unreadable agent list prints the // exact command and says why it was not run. Never guesses a pane. if (plan.agentRename !== undefined) { const renameDeps = deps.agentRename ?? DEFAULT_AGENT_RENAME_DEPS; const verdict = agentRenameVerdict( renameDeps.list(plan.agentRename.session), plan.agentRename.from, plan.agentRename.to, plan.agentRename.session, ); if (verdict.kind === "rename") { const ran = renameDeps.rename(plan.agentRename.session, verdict.pane, plan.agentRename.to); if (ran.ok) { ui.notify(`Renamed the herdr agent in pane ${verdict.pane} to ${plan.agentRename.to}.`, "info"); } else { ui.notify( `The herdr agent rename did not go through (${ran.problem ?? "unknown"}): ${verdict.command} — the pane still carries the old name.`, "warning", ); } } else { ui.notify(`${verdict.reason}; run it by hand: ${verdict.command}`, "warning"); } } return { kind: "installed", wrote }; } /** * Poll until the canonical unit owns the fleet session, or the bound runs out * (#893). Returns the last verdict either way, so the caller reports what was * actually observed rather than "timed out". * * `absent` and `unmanaged` are both failures here, for different reasons: the * first is a unit that started and died (the collision's own signature under * `Type=simple`), the second is a session still held outside it. */ async function proveSupervision(deps: SupervisionDeps, session: string): Promise { const attempts = deps.attempts ?? SUPERVISION_PROBE_ATTEMPTS; const intervalMs = deps.intervalMs ?? SUPERVISION_PROBE_INTERVAL_MS; let verdict = herdrOwnership(deps.facts(session)); for (let attempt = 1; attempt < attempts && verdict.kind !== "unit"; attempt += 1) { await deps.sleep(intervalMs); verdict = herdrOwnership(deps.facts(session)); } return verdict; } /** How many reads the readiness gate takes, and how far apart. Twelve seconds * total: long enough for a session server to bind its socket, short enough * that a restart loop is reported rather than waited out. */ const SUPERVISION_PROBE_ATTEMPTS = 12; const SUPERVISION_PROBE_INTERVAL_MS = 1_000; /** The real reads: herdr's own session list, and systemd's view of the unit. */ function defaultSupervisionDeps(): SupervisionDeps { return { facts: (session) => ({ sessionRunning: herdrSessionRunning(session), unit: herdrUnitState(), }), sleep: Bun.sleep, }; } /** Whether herdr reports a live server for one session, or `undefined` when it * could not be asked — never `false` on a failed read (#893). */ function herdrSessionRunning(session: string): boolean | undefined { const ran = spawnSync("herdr", ["session", "list", "--json"], { encoding: "utf8", timeout: 10_000 }); if (ran.status !== 0 || typeof ran.stdout !== "string") return undefined; try { const parsed: unknown = JSON.parse(ran.stdout); const sessions = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "sessions") : undefined; if (!Array.isArray(sessions)) return undefined; const row = sessions.find( (entry) => entry !== null && typeof entry === "object" && Reflect.get(entry, "name") === session, ); if (row === undefined) return false; return Reflect.get(row as object, "running") === true; } catch { return undefined; } } /** The canonical unit's live state, or `undefined` when systemctl could not * answer. A property systemd omits reads as unreadable rather than as zero. */ function herdrUnitState(): HerdrOwnershipFacts["unit"] { const ran = spawnSync( "systemctl", ["show", DEFAULT_HERDR_UNIT, "-p", "ActiveState", "-p", "SubState", "-p", "MainPID", "-p", "NRestarts"], { encoding: "utf8", timeout: 10_000 }, ); if (ran.status !== 0 || typeof ran.stdout !== "string") return undefined; const values = new Map(); for (const line of ran.stdout.split("\n")) { const eq = line.indexOf("="); if (eq > 0) values.set(line.slice(0, eq), line.slice(eq + 1)); } const activeState = values.get("ActiveState"); const subState = values.get("SubState"); const mainPid = Number(values.get("MainPID")); const nRestarts = Number(values.get("NRestarts")); if (activeState === undefined || subState === undefined || !Number.isFinite(mainPid)) return undefined; return { activeState, subState, mainPid, nRestarts: Number.isFinite(nRestarts) ? nRestarts : 0 }; } /** * The pause/drain fence's default accessors, overridable per test through * {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps} * wiring, except the pause names `setup host` as the actor and the drain * reports progress through the same UI surface. */ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps { if (deps.drain?.deps !== undefined) return deps.drain.deps; return { snapshot: statusSnapshot, layers: fleetLayers, projectNames: () => loadConfig().projects.map((p) => p.name), daemonIdentity: () => { const daemon = livingDaemon(); return daemon === undefined ? { running: false } : { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` }; }, pauseState: (project) => pauseInstance(project), setPaused: (v, project) => // `owner` is this process (#938): the fence lives exactly as long as this // transaction, so a kill leaves it behind with nothing to explain it. setPaused( v, { source: pauseSourceToken("setup host"), reason: "setup host, draining", owner: process.pid }, project, ), sleep: Bun.sleep, log: (message) => ui.notify(message, "info"), }; } /** * Attribute the runs the restart is about to kill: any run still live when the * session host restarts underneath it is that restart's casualty, not a failed * attempt, so its row records the actor. The classifier reads this as an * administrative kill that costs no budget and requeues (#512). The fence * drains to idle first, so in the healthy path this records nothing — it is * the safety net for a claim that raced the drain or a worker the drain could * not bring to idle. */ function attributeRestartKills( scope: UpgradeScope, project: string, store: Pick | undefined, ): void { const attribute = (owner: Pick): void => { for (const selector of scope.selectors) { for (const run of owner.liveRuns(selector ?? project)) { owner.updateRun(run.id, { lastError: "admin restart: setup host (draining)" }); } } }; if (store !== undefined) { attribute(store); return; } const path = dbPath(); // No store yet — a host that has never dispatched — means nothing to attribute // and nothing a fresh open would do but create an empty database. if (!existsSync(path)) return; const owner = openStore(path); try { attribute(owner); } finally { owner.close(); } } /** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */ function hostInstallWorkers(caps: Caps): number { try { return totalConfiguredWorkers(); } catch { return caps.maxConcurrentWorkers; } } /** * Whether this host configures more than one project. The recovery unit is * host-global and must not encode one project's name when several are * configured, so the plan scopes RECOVER_PROJECT by this (#510/#530). A * loadable config is the truth; when it cannot be read (a not-yet-configured * box), there is no evidence of several projects and the named case stays * unambiguous — the same fallback shape as {@link hostInstallWorkers}. */ function hostMultiProject(): boolean { try { return loadConfig().projects.length > 1; } catch { return false; } } export interface GraphInstallOptions extends InstallDeps { /** * Skip the seeding step. The timer is still installed and enabled, and the * graph is plainly unusable until its first scheduled run finishes. Never * skips the prerequisite or clone steps: those are what make the installed * unit runnable at all. */ noSeed?: boolean; /** Print the plan and change nothing — today's `graph-setup` behaviour. */ print?: boolean; /** Injected so the prerequisite gate is testable without touching PATH. */ prereqs?: GraphPrereqs; /** Injected so verification is deterministic without a live indexer. */ probe?: (project: ProjectConfig) => Promise; } /** * The code-graph install, end to end. * * Staging and enabling alone installs a service that fails on every run: the * generated script runs under `set -euo pipefail` and `cd ""` as * its first act per repo, so a missing clone is a `cd` failure at 03:00 rather * than a graph. That is why staging was never allowed to call the install a * finished job. So: prerequisites, then a conflict check, then clones, then * install, then seed and verify — one preview, one confirm, and nothing * staged before the confirm (#720). */ export async function runGraphInstall( project: ProjectConfig, ui: WizardUi, options: GraphInstallOptions = {}, ): Promise { const repos = graphRepos(project); if (repos.length === 0) { const reason = `no repo in ${project.name} has graphProject — nothing to install`; ui.notify(reason, "error"); return { kind: "refused", reason }; } const prereqs = options.prereqs ?? resolvePrereqs(); // `--print` is today's `graph-setup`: read-only, writes nothing, runs nothing. // It comes first — before the escalation guard and before the prerequisite // refusal — because the host that most needs the plan is exactly the fresh one // missing the indexer, and refusing there would withhold the remediation the // operator ran the command to get. `formatGraphSetup` is that renderer, and it // already reports the prerequisites as step 0. if (options.print === true) { ui.notify(formatGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR, prereqs), "info"); return { kind: "staged", wrote: [], reason: "print-only" }; } const verdict = checkEscalation("setup graph", options.escalation); if (verdict.kind === "refuse") { ui.notify(verdict.message, "error"); return { kind: "refused", reason: verdict.message }; } // 1. Prerequisites. With no indexer on PATH every timer run fails, and with no // MCP entry no worker can read what it indexed — so this stops before // installing anything rather than enabling a timer that cannot work. const missing = prerequisiteProblem(prereqs); if (missing !== undefined) { ui.notify([missing, "", mcpEntry(prereqs)].join("\n"), "error"); return { kind: "refused", reason: missing }; } const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR; // The plan is computed eagerly — `planGraphSetup` renders every byte, so the // prompt can print exactly what would be written and the unitsCurrent gate // below can decide a re-run installs nothing. But rendering is read-only: no // file is written here. Staging happens only after consent (#720), so the // prompt's "stages once you confirm" is true of the staged tree when it is // printed, and a declined run leaves every staged file byte-identical. const plan = planGraphSetup(project, unitDir); // Never overwrite another project's staged artefact: the stems derive from // the project name, and two names can fold to one stem ("My Project" vs // "my_project"), so an existing file's generated-for marker is checked // before the consent prompt — a collision is refused before anything is // asked, let alone written (#720). const conflict = stagingConflict(project, [ plan.script.path, plan.service.path, plan.timer.path, ...Object.values(unitPaths(project, unitDir)), ]); if (conflict !== undefined) { const message = graphConflictMessage(conflict); ui.notify(message, "error"); return { kind: "refused", reason: message }; } // A host that ran the pre-#720 project-less version keeps its old files: // their timer refreshes whichever project generated it last, forever. Name // the remediation before the consent prompt — deleting files outside this // command's own names is exactly how the overwrite happened in the first // place, so it is host action, not install action. const legacy = legacyReindexFiles(); if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning"); const absent = repos.filter((r) => !existsSync(r.graphProject)); // 2. Missing clones, as the operator. A root-owned index-only clone under the // fleet user's cache is exactly the failure the escalation guard exists to // prevent — but it belongs in the same plan under the same confirm. const clones: PrivilegedStep[] = absent.map((r) => ({ title: `clone ${r.name} for indexing (as you, not root)`, argv: ["git", "clone", "--single-branch", "--branch", r.defaultBranch, r.cloneUrl, r.graphProject], unprivileged: true, })); const blocked = linuxOnly(options); const stem = reindexUnitName(project); const { service, timer } = unitPaths(project, unitDir); // The units systemd already reads, compared to the rendered content — the // same distinction setup-host.ts draws between `installedAction` and // `service.action` (read the installed file, compare to the fresh content, // "keep" vs "update"). "Installed" is not enough: a green timer serving a // stale unit is the older bug, so when they differ the install runs. Only // when both units already match does the plan omit them — then just the // outstanding clone and seed remain. Nothing is written to compare: staging // is post-consent now, so the comparison is against the rendered bytes. const unitsCurrent = blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content); // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is // one preview and one confirm, and a second confirm here also invented a // third outcome — a declined seed — that neither the caller nor the // verification below could interpret. const seed: PrivilegedStep[] = options.noSeed === true ? [] : [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }]; const install: PrivilegedStep[] = blocked === undefined ? [ ...(unitsCurrent ? [] : [ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] }, { title: "reload systemd", argv: ["systemctl", "daemon-reload"] }, { title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] }, ]), ...seed, ] : []; if (blocked !== undefined) { // A non-Linux host cannot run systemd, so staging IS the deliverable: the // rendered files are written for the operator to copy to the box that will // run them, and only the systemctl half is refused. There is no consent // prompt on this path — nothing privileged to approve — so the write lands // here, not in the (never reached) post-consent hook (#720, as #510). const staged = writeGraphSetup(project, unitDir); ui.notify(`${blocked} ${service} and ${timer}`, "warning"); return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` }; } // What the post-consent staging actually wrote, surfaced by the outcomes // that report it. Empty on a decline — the hook never ran. let wrote: readonly string[] = []; const beforeRun = async (): Promise => { // Consent has been given; this project's files may now land. Staging here — // post-confirm, pre-step, under the same confirm that authorised the batch // — is what makes the prompt's plan true of the staged tree when it is // printed: a declined prompt never reaches this hook, so nothing is staged // and a re-run still plans an install (#720). `writeGraphSetup`'s ownership // backstop throws here with nothing written if a collision raced the // pre-consent check. wrote = writeGraphSetup(project, unitDir).written; }; const outcome = await runPrivileged([...clones, ...install], ui, { ...(options.privileged === undefined ? {} : { deps: options.privileged }), title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?", answerKey: `install-code-graph.${project.name}`, preamble: [ `Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`, ...(clones.length === 0 ? ["Every indexed clone already exists."] : [ `${clones.length} clone(s) run as you; ${ unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root." }`, ]), ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []), `Indexer: ${prereqs.indexer ?? "on PATH"}.`, ], beforeRun, }); if (outcome.kind === "declined") return { kind: "declined", wrote }; if (outcome.kind === "failed") { return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` }; } // Verify. `--no-seed` has nothing to verify yet, and saying so is the honest // answer: an unseeded graph is not usable until the timer first fires. if (options.noSeed === true) { ui.notify( `Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`, "warning", ); return { kind: "installed", wrote }; } const health = await (options.probe ?? probeCodeGraph)(project); const unhealthy = unverifiedRepos(health); if (unhealthy.length > 0) { // Staged but not trusted. Reporting success here is how an operator learns // months later that no worker ever read an index. ui.notify( [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"), "error", ); return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` }; } ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info"); return { kind: "installed", wrote }; } /** The two prerequisites that make an enabled timer meaningful, or `undefined`. */ function prerequisiteProblem(prereqs: GraphPrereqs): string | undefined { if (prereqs.indexer === null) { return "codebase-memory-mcp is not on PATH — every timer run would fail, so nothing was installed."; } if (!prereqs.mounted) { return "no codebase-memory MCP entry — no worker could read the indexes, so nothing was installed."; } return undefined; } /** * Repos whose index the probe could not vouch for: a missing clone, or an * indexed project path that never appeared in `list_projects`. Either one means * staged-but-not-trusted, which must not be reported as success. */ function unverifiedRepos(health: CodeGraphHealth): string[] { if (!health.configured) return []; return health.repos.filter((r) => r.clone !== "present" || r.index !== "present").map((r) => r.name); } /** The repos `setup graph` would act on, for a caller deciding whether to offer it. */ export function graphInstallable(project: ProjectConfig): GraphRepo[] { return graphRepos(project); } /** Where this project's reindex script lands, for the wizard's tail to name. */ export function reindexScriptLocation(project: ProjectConfig): string { return reindexScriptPath(project); }