omp-conductor 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
package/src/setup-host.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { homedir, userInfo } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
- import { configPath, stateDir } from "./config.ts";
5
+ import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
5
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
6
7
  import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
7
8
  import {
@@ -14,11 +15,13 @@ import {
14
15
  type StopResult,
15
16
  } from "./lifecycle.ts";
16
17
  import {
18
+ legacyArmedMarkerPath,
17
19
  readTickConfig,
18
20
  TICK_CONFIG_FILE,
21
+ tickConfigMatchesProject,
19
22
  type TickConfig,
20
23
  } from "./orchestrator-tick.ts";
21
- import type { Caps, ProjectConfig } from "./types.ts";
24
+ import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
22
25
 
23
26
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
24
27
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
@@ -36,6 +39,21 @@ export interface HostRuntimePlan {
36
39
  tick?: PlannedWrite<TickConfig>;
37
40
  installCommands: readonly string[];
38
41
  cliSource: "global" | "plugin";
42
+ /** Absolute path of the unit systemd actually reads. */
43
+ installedPath: string;
44
+ /**
45
+ * What installing would do to the unit **systemd reads**, which is a different
46
+ * question from {@link HostRuntimePlan.service}'s action — that one compares the
47
+ * staged copy under the state directory.
48
+ *
49
+ * The distinction is load-bearing: staging has always happened during setup,
50
+ * so on any fleet configured before the install was executed the staged file is
51
+ * already current (`service.action === "keep"`) while `/etc/systemd/system`
52
+ * holds nothing at all. Gating the install offer on the staged action therefore
53
+ * skipped exactly the fleets that had never installed the unit, and told them
54
+ * the installed unit matched.
55
+ */
56
+ installedAction: PlannedWrite<string>["action"];
39
57
  }
40
58
 
41
59
  export interface ServiceRuntime {
@@ -66,6 +84,143 @@ function actionFor(path: string, content: string): PlannedWrite<string>["action"
66
84
  return "update";
67
85
  }
68
86
  }
87
+ /**
88
+ * Which account this host's fleet belongs to, and how we know.
89
+ *
90
+ * Two sources, in order of authority: the installed unit's own `User=` is what
91
+ * systemd will actually run as, and the owner of `$OMP_CONDUCTOR_HOME` is what
92
+ * owns the config, the store and the state directory. Either one disagreeing
93
+ * with the invoking account means staging would write the wrong identity.
94
+ */
95
+ export interface FleetAccount {
96
+ name: string | undefined;
97
+ source: "installed unit" | "$OMP_CONDUCTOR_HOME owner" | "nothing on this host";
98
+ }
99
+
100
+ export interface EscalationDeps {
101
+ invokingUser(): string;
102
+ /** `$SUDO_USER`, which `sudo` sets and `sudo -i` / `su -` clear. */
103
+ sudoUser(): string | undefined;
104
+ /** `User=` from the installed unit, or `undefined` when there is no unit. */
105
+ unitUser(): string | undefined;
106
+ /** Owner of `$OMP_CONDUCTOR_HOME`, or `undefined` when it does not exist. */
107
+ homeOwner(): string | undefined;
108
+ conductorHome(): string;
109
+ }
110
+
111
+ /** uid → account name. `id -un` because no Node builtin maps a foreign uid. */
112
+ function accountName(uid: number): string {
113
+ const ran = spawnSync("id", ["-un", String(uid)], { encoding: "utf8" });
114
+ const name = ran.status === 0 ? (ran.stdout ?? "").trim() : "";
115
+ return name === "" ? `uid ${uid}` : name;
116
+ }
117
+
118
+ function installedUnitUser(): string | undefined {
119
+ try {
120
+ const unit = readFileSync(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME), "utf8");
121
+ const match = /^User=(.*)$/m.exec(unit);
122
+ if (match === null) return undefined;
123
+ // Unquoted in what we generate, but systemd accepts quotes and a
124
+ // hand-edited unit is exactly the case this guard has to read correctly.
125
+ const value = match[1]?.trim().replace(/^"(.*)"$/, "$1") ?? "";
126
+ return value === "" ? undefined : value;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ export const DEFAULT_ESCALATION_DEPS: EscalationDeps = {
133
+ invokingUser: () => userInfo().username,
134
+ sudoUser: () => process.env["SUDO_USER"],
135
+ unitUser: installedUnitUser,
136
+ homeOwner: () => {
137
+ try {
138
+ return accountName(statSync(dirname(configPath())).uid);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ conductorHome: () => dirname(configPath()),
144
+ };
145
+
146
+ /** The account this host's fleet runs as, from whichever source knows. */
147
+ export function resolveFleetAccount(deps: EscalationDeps = DEFAULT_ESCALATION_DEPS): FleetAccount {
148
+ const fromUnit = deps.unitUser();
149
+ if (fromUnit !== undefined) return { name: fromUnit, source: "installed unit" };
150
+ const fromHome = deps.homeOwner();
151
+ if (fromHome !== undefined) return { name: fromHome, source: "$OMP_CONDUCTOR_HOME owner" };
152
+ return { name: undefined, source: "nothing on this host" };
153
+ }
154
+
155
+ export type EscalationVerdict = { kind: "ok" } | { kind: "refuse"; message: string };
156
+
157
+ /**
158
+ * Refuses an *escalated* invocation of any setup path — and only that.
159
+ *
160
+ * The distinction is the whole point. Staging derives the unit's `User=` and
161
+ * `HOME=` from the invoking account ({@link defaultServiceRuntime}), so a
162
+ * `sudo omp-conductor setup host` writes `User=root` + `HOME=/root` into a unit
163
+ * for a fleet that runs as somebody else, and the config it loads, the state
164
+ * directory it writes and the indexes it points at all resolve as root too.
165
+ * Nothing about that announces itself: the unit starts, the timer goes green,
166
+ * and the fleet reads none of it. So it is refused rather than accommodated.
167
+ *
168
+ * `uid 0` is *not* the test. A fleet that legitimately runs as root — root owns
169
+ * `$OMP_CONDUCTOR_HOME` and the unit says `User=root` — is the case this verb
170
+ * exists for on single-tenant boxes, and refusing there would break exactly the
171
+ * hosts it targets. Two checks instead:
172
+ *
173
+ * - `$SUDO_USER` is set, which is `sudo` announcing the escalation itself;
174
+ * - the invoking account disagrees with the fleet account, which is what
175
+ * catches `sudo -i` and `su -` — both clear `$SUDO_USER`, so the
176
+ * environment check alone is not sufficient.
177
+ */
178
+ export function checkEscalation(
179
+ verb: string,
180
+ deps: EscalationDeps = DEFAULT_ESCALATION_DEPS,
181
+ ): EscalationVerdict {
182
+ const invoking = deps.invokingUser();
183
+ const sudoUser = deps.sudoUser();
184
+ const fleet = resolveFleetAccount(deps);
185
+
186
+ if (sudoUser !== undefined && sudoUser !== "") {
187
+ return {
188
+ kind: "refuse",
189
+ message: refusal(verb, invoking, sudoUser, fleet, deps.conductorHome()),
190
+ };
191
+ }
192
+ if (fleet.name !== undefined && fleet.name !== invoking) {
193
+ return {
194
+ kind: "refuse",
195
+ message: refusal(verb, invoking, undefined, fleet, deps.conductorHome()),
196
+ };
197
+ }
198
+ return { kind: "ok" };
199
+ }
200
+
201
+ function refusal(
202
+ verb: string,
203
+ invoking: string,
204
+ sudoUser: string | undefined,
205
+ fleet: FleetAccount,
206
+ home: string,
207
+ ): string {
208
+ const fleetName = fleet.name ?? sudoUser ?? "the fleet's own account";
209
+ return [
210
+ `omp-conductor: run ${verb} as the account the fleet runs as, not escalated.`,
211
+ sudoUser === undefined
212
+ ? `This is running as "${invoking}", but the fleet runs as "${fleetName}" (${fleet.source}).`
213
+ : `This is running as "${invoking}" under sudo from "${sudoUser}"` +
214
+ (fleet.name === undefined ? "." : `, and the fleet runs as "${fleet.name}" (${fleet.source}).`),
215
+ `As "${invoking}" the config, ${home}, ~/.cache and the unit's own User= all resolve`,
216
+ `as "${invoking}" instead, and the result is a unit that starts and a fleet that reads`,
217
+ "none of it. Nothing has been written.",
218
+ "",
219
+ `Run it as "${fleetName}". Only the individual install steps need root, and`,
220
+ `${verb} runs those for you with sudo after showing you each one.`,
221
+ ].join("\n");
222
+ }
223
+
69
224
 
70
225
  function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
71
226
  const home = homedir();
@@ -86,16 +241,19 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
86
241
  };
87
242
  }
88
243
 
89
- export function renderDaemonService(
90
- project: ProjectConfig,
91
- caps: Caps,
92
- runtime: ServiceRuntime,
93
- ): string {
244
+ export function totalConfiguredWorkers(cfg: ConductorConfig = loadConfig()): number {
245
+ return cfg.projects.reduce(
246
+ (total, project) => total + resolveCaps(project, cfg.defaults).maxConcurrentWorkers,
247
+ 0,
248
+ );
249
+ }
250
+
251
+ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: number): string {
94
252
  const command =
95
253
  runtime.cli === undefined
96
- ? [runtime.bun, runtime.packageCli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)]
97
- : [runtime.cli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)];
98
- const memoryMax = caps.maxConcurrentWorkers <= 1 ? "3G" : "5G";
254
+ ? [runtime.bun, runtime.packageCli, "daemon", "--port", String(DEFAULT_PORT)]
255
+ : [runtime.cli, "daemon", "--port", String(DEFAULT_PORT)];
256
+ const memoryMax = totalWorkers <= 1 ? "3G" : "5G";
99
257
  return [
100
258
  "[Unit]",
101
259
  "Description=omp-conductor dispatch daemon",
@@ -128,6 +286,19 @@ function tickSearchRoots(project: ProjectConfig): string[] {
128
286
  return roots.filter((root, index) => roots.indexOf(root) === index);
129
287
  }
130
288
 
289
+ /**
290
+ * The tick config for one project's fleet cwd.
291
+ *
292
+ * Everything per-project here used to be shared, and that was two bugs: one
293
+ * `<stateDir>/armed` marker meant arming project A armed B as well, and one
294
+ * `agentName` meant every pane claimed the identity `fleet`, so herdr recovery
295
+ * and tick ownership could not tell two fleets apart.
296
+ *
297
+ * An existing config keeps an explicit `armedFile`/`agentName` only when it
298
+ * differs from those shared defaults. A value equal to a shared default cannot
299
+ * have been a deliberate per-project choice — it *is* the collision — so it is
300
+ * rewritten; anything else is operator intent and survives untouched.
301
+ */
131
302
  function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
132
303
  let existing: { path: string; config: TickConfig } | undefined;
133
304
  for (const root of tickSearchRoots(project)) {
@@ -135,24 +306,37 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
135
306
  if (result.kind === "invalid") {
136
307
  throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
137
308
  }
138
- if (result.kind === "ok") {
309
+ // A config stamped for another project is that project's file: the search
310
+ // roots overlap, and restamping it here would hand this project's identity
311
+ // to the other fleet's cwd.
312
+ if (result.kind === "ok" && tickConfigMatchesProject(result.config, project.name)) {
139
313
  existing = { path: result.path, config: result.config };
140
314
  break;
141
315
  }
142
316
  }
143
317
 
318
+ const armedFile = join(stateDir(), `armed-${project.name}`);
144
319
  const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
145
320
  const config: TickConfig = existing === undefined
146
321
  ? {
147
322
  intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
148
- armedFile: join(stateDir(), "armed"),
323
+ project: project.name,
324
+ armedFile,
149
325
  accessFile: join(telegramStateDir, "access.json"),
150
- agentName: DEFAULT_FLEET_AGENT_NAME,
326
+ agentName: project.name,
151
327
  }
152
328
  : {
153
329
  ...existing.config,
154
- armedFile: existing.config.armedFile ?? join(stateDir(), "armed"),
330
+ project: project.name,
331
+ armedFile:
332
+ existing.config.armedFile === undefined || existing.config.armedFile === legacyArmedMarkerPath()
333
+ ? armedFile
334
+ : existing.config.armedFile,
155
335
  accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
336
+ agentName:
337
+ existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME
338
+ ? project.name
339
+ : existing.config.agentName,
156
340
  };
157
341
  const content = `${JSON.stringify(config, null, 2)}\n`;
158
342
  return { path, action: actionFor(path, content), content, value: config };
@@ -160,12 +344,16 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
160
344
 
161
345
  export function planHostRuntime(
162
346
  project: ProjectConfig,
163
- caps: Caps,
347
+ _caps: Caps,
164
348
  telegramStateDir: string,
165
349
  runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
350
+ // Callers that know the fleet pass the sum of resolved maxConcurrentWorkers.
351
+ // Defaulting through loadConfig() would throw in install tests that stage
352
+ // files before a config exists, and would hide a missing total at the call site.
353
+ totalWorkers: number = 1,
166
354
  ): HostRuntimePlan {
167
355
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
168
- const serviceContent = renderDaemonService(project, caps, runtime);
356
+ const serviceContent = renderDaemonService(runtime, totalWorkers);
169
357
  const service: PlannedWrite<string> = {
170
358
  path: servicePath,
171
359
  action: actionFor(servicePath, serviceContent),
@@ -185,6 +373,8 @@ export function planHostRuntime(
185
373
  `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
186
374
  ],
187
375
  cliSource: runtime.cli === undefined ? "plugin" : "global",
376
+ installedPath,
377
+ installedAction: actionFor(installedPath, serviceContent),
188
378
  };
189
379
  }
190
380
 
@@ -242,7 +432,7 @@ export interface SetupSmokeResult {
242
432
  }
243
433
 
244
434
  export interface SetupSmokeDeps {
245
- paused(): boolean;
435
+ paused(project: string): boolean;
246
436
  runOnce(project: string): Promise<void>;
247
437
  living(): DaemonRecord | undefined;
248
438
  health(port: number): Promise<{ ok: boolean; body?: string }>;
@@ -265,7 +455,7 @@ export async function runSetupSmoke(
265
455
  project: string,
266
456
  deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
267
457
  ): Promise<SetupSmokeResult> {
268
- if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
458
+ if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
269
459
  await deps.runOnce(project);
270
460
  const existing = deps.living();
271
461
  if (existing !== undefined) {
@@ -283,8 +473,3 @@ export async function runSetupSmoke(
283
473
  await deps.stop();
284
474
  }
285
475
  }
286
-
287
- /** Absolute path of the unit systemd actually reads. */
288
- export function installedUnitPath(): string {
289
- return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
290
- }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The two executed install paths: the supervised daemon unit, and the code-graph
3
+ * timer.
4
+ *
5
+ * Both used to end at a printed list the operator retyped — `setup-host.ts`
6
+ * composed the exact `sudo` lines and showed them, and `graph-setup --write`
7
+ * staged its files and said it "never runs `systemctl` itself". Retyping is not
8
+ * a safety property: it is the same commands with a chance of a typo, and it is
9
+ * why a fleet sits half-installed. These run them, behind one confirm each.
10
+ *
11
+ * Why this module exists rather than the code living where its pieces do:
12
+ * `graph-health.ts` already imports `graph.ts`, so orchestration that verifies
13
+ * with `probeCodeGraph()` cannot sit in `graph.ts` without a cycle — and `cli.ts`
14
+ * is argument parsing, not sequencing. Both the CLI verbs and the wizard's tail
15
+ * call in here; `privileged.ts` stays the primitive underneath.
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { platform } from "node:os";
20
+ import { join } from "node:path";
21
+ import { stateDir } from "./config.ts";
22
+ import {
23
+ graphRepos,
24
+ formatGraphSetup,
25
+ mcpEntry,
26
+ reindexScriptPath,
27
+ resolvePrereqs,
28
+ REINDEX_UNIT,
29
+ unitPaths,
30
+ writeGraphSetup,
31
+ type GraphPrereqs,
32
+ type GraphRepo,
33
+ } from "./graph.ts";
34
+ import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
35
+ import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
36
+ import {
37
+ checkEscalation,
38
+ planHostRuntime,
39
+ totalConfiguredWorkers,
40
+ writeHostRuntime,
41
+ STAGED_SERVICE_NAME,
42
+ SYSTEMD_UNIT_DIR,
43
+ type EscalationDeps,
44
+ } from "./setup-host.ts";
45
+ import type { WizardUi } from "./wizard-ui.ts";
46
+ import type { Caps, ProjectConfig } from "./types.ts";
47
+
48
+ /**
49
+ * Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
50
+ * the files are real and correct, only the `systemctl` half is impossible.
51
+ */
52
+ export type InstallOutcome =
53
+ | { kind: "installed"; wrote: readonly string[] }
54
+ | { kind: "staged"; wrote: readonly string[]; reason: string }
55
+ | { kind: "declined"; wrote: readonly string[] }
56
+ | { kind: "refused"; reason: string }
57
+ | { kind: "failed"; reason: string };
58
+
59
+ export interface InstallDeps {
60
+ privileged?: PrivilegedDeps;
61
+ escalation?: EscalationDeps;
62
+ /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
63
+ platform?: () => string;
64
+ unitDir?: string;
65
+ }
66
+
67
+ /**
68
+ * `systemctl` exists only on Linux, and staging is still worth doing everywhere:
69
+ * a macOS operator reading the plan wants the rendered unit on disk to copy to
70
+ * the box that will run it. So this is checked *after* the files are written.
71
+ */
72
+ function linuxOnly(deps: InstallDeps): string | undefined {
73
+ return (deps.platform ?? platform)() === "linux"
74
+ ? undefined
75
+ : "systemd install is Linux-only; staged files are at";
76
+ }
77
+
78
+ /**
79
+ * Install the supervised daemon unit: re-render, stage, then run the four steps
80
+ * `planHostRuntime` used to only print.
81
+ */
82
+ export async function runHostInstall(
83
+ project: ProjectConfig,
84
+ caps: Caps,
85
+ telegramStateDir: string,
86
+ ui: WizardUi,
87
+ deps: InstallDeps = {},
88
+ ): Promise<InstallOutcome> {
89
+ const verdict = checkEscalation("setup host", deps.escalation);
90
+ if (verdict.kind === "refuse") {
91
+ ui.notify(verdict.message, "error");
92
+ return { kind: "refused", reason: verdict.message };
93
+ }
94
+
95
+ const plan = planHostRuntime(project, caps, telegramStateDir, undefined, hostInstallWorkers(caps));
96
+ const wrote = writeHostRuntime(plan);
97
+ const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
98
+ const installed = join(unitDir, STAGED_SERVICE_NAME);
99
+
100
+ const blocked = linuxOnly(deps);
101
+ if (blocked !== undefined) {
102
+ ui.notify(`${blocked} ${plan.service.path}`, "warning");
103
+ return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
104
+ }
105
+
106
+ // argv, not shell: `installCommands` renders `sudo …` strings for humans to
107
+ // read, and re-parsing those into an argv is how a path with a space becomes
108
+ // two arguments. The steps are built from the same values instead.
109
+ const steps: PrivilegedStep[] = [
110
+ { title: `install ${STAGED_SERVICE_NAME}`, argv: ["install", "-m", "0644", plan.service.path, installed] },
111
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
112
+ { title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
113
+ { title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
114
+ ];
115
+
116
+ const outcome = await runPrivileged(steps, ui, {
117
+ ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
118
+ title: "Install and start the supervised daemon?",
119
+ preamble: [
120
+ `Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
121
+ "The unit runs as the account that staged it; nothing here changes that.",
122
+ ],
123
+ });
124
+ if (outcome.kind === "declined") return { kind: "declined", wrote };
125
+ if (outcome.kind === "failed") {
126
+ return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
127
+ }
128
+ ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
129
+ return { kind: "installed", wrote };
130
+ }
131
+
132
+ /** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
133
+ function hostInstallWorkers(caps: Caps): number {
134
+ try {
135
+ return totalConfiguredWorkers();
136
+ } catch {
137
+ return caps.maxConcurrentWorkers;
138
+ }
139
+ }
140
+
141
+ export interface GraphInstallOptions extends InstallDeps {
142
+ /**
143
+ * Skip the seeding step. The timer is still installed and enabled, and the
144
+ * graph is plainly unusable until its first scheduled run finishes. Never
145
+ * skips the prerequisite or clone steps: those are what make the installed
146
+ * unit runnable at all.
147
+ */
148
+ noSeed?: boolean;
149
+ /** Print the plan and change nothing — today's `graph-setup` behaviour. */
150
+ print?: boolean;
151
+ /** Injected so the prerequisite gate is testable without touching PATH. */
152
+ prereqs?: GraphPrereqs;
153
+ /** Injected so verification is deterministic without a live indexer. */
154
+ probe?: (project: ProjectConfig) => Promise<CodeGraphHealth>;
155
+ }
156
+
157
+ /**
158
+ * The code-graph install, end to end.
159
+ *
160
+ * Staging and enabling alone installs a service that fails on every run: the
161
+ * generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
162
+ * its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
163
+ * than a graph. That is why `writeGraphSetup` already refused to call the old
164
+ * install a finished job. So: prerequisites, then clones, then install, then
165
+ * seed and verify — one preview, one confirm.
166
+ */
167
+ export async function runGraphInstall(
168
+ project: ProjectConfig,
169
+ ui: WizardUi,
170
+ options: GraphInstallOptions = {},
171
+ ): Promise<InstallOutcome> {
172
+ const repos = graphRepos(project);
173
+ if (repos.length === 0) {
174
+ const reason = `no repo in ${project.name} has graphProject — nothing to install`;
175
+ ui.notify(reason, "error");
176
+ return { kind: "refused", reason };
177
+ }
178
+
179
+ const prereqs = options.prereqs ?? resolvePrereqs();
180
+
181
+ // `--print` is today's `graph-setup`: read-only, writes nothing, runs nothing.
182
+ // It comes first — before the escalation guard and before the prerequisite
183
+ // refusal — because the host that most needs the plan is exactly the fresh one
184
+ // missing the indexer, and refusing there would withhold the remediation the
185
+ // operator ran the command to get. `formatGraphSetup` is that renderer, and it
186
+ // already reports the prerequisites as step 0.
187
+ if (options.print === true) {
188
+ ui.notify(formatGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR, prereqs), "info");
189
+ return { kind: "staged", wrote: [], reason: "print-only" };
190
+ }
191
+
192
+ const verdict = checkEscalation("setup graph", options.escalation);
193
+ if (verdict.kind === "refuse") {
194
+ ui.notify(verdict.message, "error");
195
+ return { kind: "refused", reason: verdict.message };
196
+ }
197
+
198
+ // 1. Prerequisites. With no indexer on PATH every timer run fails, and with no
199
+ // MCP entry no worker can read what it indexed — so this stops before
200
+ // installing anything rather than enabling a timer that cannot work.
201
+ const missing = prerequisiteProblem(prereqs);
202
+ if (missing !== undefined) {
203
+ ui.notify([missing, "", mcpEntry(prereqs)].join("\n"), "error");
204
+ return { kind: "refused", reason: missing };
205
+ }
206
+
207
+ const staged = writeGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR);
208
+ const absent = repos.filter((r) => !existsSync(r.graphProject));
209
+
210
+ // 2. Missing clones, as the operator. A root-owned index-only clone under the
211
+ // fleet user's cache is exactly the failure the escalation guard exists to
212
+ // prevent — but it belongs in the same plan under the same confirm.
213
+ const clones: PrivilegedStep[] = absent.map((r) => ({
214
+ title: `clone ${r.name} for indexing (as you, not root)`,
215
+ argv: ["git", "clone", "--single-branch", "--branch", r.defaultBranch, r.cloneUrl, r.graphProject],
216
+ unprivileged: true,
217
+ }));
218
+
219
+ const blocked = linuxOnly(options);
220
+ const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
221
+ const from = unitPaths(stateDir());
222
+ // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
223
+ // one preview and one confirm, and a second confirm here also invented a
224
+ // third outcome — a declined seed — that neither the caller nor the
225
+ // verification below could interpret.
226
+ const seed: PrivilegedStep[] =
227
+ options.noSeed === true
228
+ ? []
229
+ : [{ title: `seed the indexes (runs ${REINDEX_UNIT}.service once, minutes per repo)`, argv: ["systemctl", "start", `${REINDEX_UNIT}.service`] }];
230
+ const install: PrivilegedStep[] =
231
+ blocked === undefined
232
+ ? [
233
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
234
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
235
+ { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
236
+ ...seed,
237
+ ]
238
+ : [];
239
+
240
+ if (blocked !== undefined) {
241
+ ui.notify(`${blocked} ${service} and ${timer}`, "warning");
242
+ return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
243
+ }
244
+
245
+ if (blocked !== undefined) {
246
+ ui.notify(`${blocked} ${service} and ${timer}`, "warning");
247
+ return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
248
+ }
249
+
250
+ const outcome = await runPrivileged([...clones, ...install], ui, {
251
+ ...(options.privileged === undefined ? {} : { deps: options.privileged }),
252
+ title: "Clone, install and enable the code-graph timer?",
253
+ preamble: [
254
+ `Staged: ${staged.written.join(", ")}.`,
255
+ ...(clones.length === 0
256
+ ? ["Every indexed clone already exists."]
257
+ : [`${clones.length} clone(s) run as you; the unit install needs root.`]),
258
+ `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
259
+ ],
260
+ });
261
+ if (outcome.kind === "declined") return { kind: "declined", wrote: staged.written };
262
+ if (outcome.kind === "failed") {
263
+ return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
264
+ }
265
+
266
+ // Verify. `--no-seed` has nothing to verify yet, and saying so is the honest
267
+ // answer: an unseeded graph is not usable until the timer first fires.
268
+ if (options.noSeed === true) {
269
+ ui.notify(
270
+ `Timer enabled; skipped the seeding run. The graph is NOT usable until ${REINDEX_UNIT}.timer first fires.`,
271
+ "warning",
272
+ );
273
+ return { kind: "installed", wrote: staged.written };
274
+ }
275
+
276
+ const health = await (options.probe ?? probeCodeGraph)(project);
277
+ const unhealthy = unverifiedRepos(health);
278
+ if (unhealthy.length > 0) {
279
+ // Staged but not trusted. Reporting success here is how an operator learns
280
+ // months later that no worker ever read an index.
281
+ ui.notify(
282
+ [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", staged.next].join("\n"),
283
+ "error",
284
+ );
285
+ return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
286
+ }
287
+ ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
288
+ return { kind: "installed", wrote: staged.written };
289
+ }
290
+
291
+ /** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
292
+ function prerequisiteProblem(prereqs: GraphPrereqs): string | undefined {
293
+ if (prereqs.indexer === null) {
294
+ return "codebase-memory-mcp is not on PATH — every timer run would fail, so nothing was installed.";
295
+ }
296
+ if (!prereqs.mounted) {
297
+ return "no codebase-memory MCP entry — no worker could read the indexes, so nothing was installed.";
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Repos whose index the probe could not vouch for: a missing clone, or an
304
+ * indexed project path that never appeared in `list_projects`. Either one means
305
+ * staged-but-not-trusted, which must not be reported as success.
306
+ */
307
+ function unverifiedRepos(health: CodeGraphHealth): string[] {
308
+ if (!health.configured) return [];
309
+ return health.repos.filter((r) => r.clone !== "present" || r.index !== "present").map((r) => r.name);
310
+ }
311
+
312
+ /** The repos `setup graph` would act on, for a caller deciding whether to offer it. */
313
+ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
314
+ return graphRepos(project);
315
+ }
316
+
317
+ /** Where the reindex script lands, for the wizard's tail to name. */
318
+ export function reindexScriptLocation(): string {
319
+ return reindexScriptPath();
320
+ }