omp-conductor 0.17.0 → 0.18.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 (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/src/setup-host.ts CHANGED
@@ -15,10 +15,13 @@ import {
15
15
  DEFAULT_PORT,
16
16
  healthCheck,
17
17
  livingDaemon,
18
+ probeUnit,
18
19
  startDaemon,
19
20
  stopDaemon,
21
+ SYSTEMD_UNIT,
20
22
  type DaemonRecord,
21
23
  type StopResult,
24
+ type UnitOwnership,
22
25
  } from "./lifecycle.ts";
23
26
  import {
24
27
  legacyArmedMarkerPath,
@@ -48,7 +51,7 @@ export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
48
51
  export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
49
52
 
50
53
  /** The playbook file shipped in the package's `systemd/` directory. */
51
- const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
54
+ export const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
52
55
 
53
56
  /**
54
57
  * Where `setup host` installs the recovery playbook so the unit's ExecStart is
@@ -1512,8 +1515,125 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
1512
1515
  return { wrote, warnings };
1513
1516
  }
1514
1517
 
1518
+ // ---------------------------------------------------------- rollback (#652) --
1519
+ //
1520
+ // The inverse of the writes above, for the setup apply's mutation inventory:
1521
+ // every path the apply can write is captured byte-for-byte (or as absence, or
1522
+ // as unreadable — never conflated) before the first mutation, and restored on
1523
+ // any later failure. `capturePathState` is the read half, `restorePathState`
1524
+ // the write half; the transaction that owns them lives in `setup-wizard.ts`.
1525
+
1526
+ /** One path's pre-entry state, three-valued so absence is never confused with
1527
+ * unreadability:
1528
+ * - `absent` — nothing at the path; rollback removes what the apply created.
1529
+ * - `bytes` — readable on entry (a regular file with its mode, or a symlink
1530
+ * with its target); rollback writes those exact bytes/mode or re-links it.
1531
+ * - `unreadable` — something exists but cannot be read. The transaction must
1532
+ * fail closed before changing anything: collapsing this to absence and
1533
+ * later deleting the path would remove state the apply never saw. */
1534
+ export type CapturedPathState =
1535
+ | { path: string; kind: "absent" }
1536
+ | { path: string; kind: "bytes"; bytes: string; mode: number; symlink: boolean }
1537
+ | { path: string; kind: "unreadable" };
1538
+
1539
+ /** Reads {@link CapturedPathState} for one path. Symlinks are captured as
1540
+ * their target (readlink), never followed — the brief link is a symlink by
1541
+ * contract, and a dangling one is still state worth restoring. */
1542
+ export function capturePathState(path: string): CapturedPathState {
1543
+ let st: Stats;
1544
+ try {
1545
+ st = lstatSync(path);
1546
+ } catch {
1547
+ return { path, kind: "absent" };
1548
+ }
1549
+ try {
1550
+ if (st.isSymbolicLink()) {
1551
+ return { path, kind: "bytes", bytes: readlinkSync(path, "utf8"), mode: st.mode & 0o7777, symlink: true };
1552
+ }
1553
+ return { path, kind: "bytes", bytes: readFileSync(path, "utf8"), mode: st.mode & 0o7777, symlink: false };
1554
+ } catch {
1555
+ return { path, kind: "unreadable" };
1556
+ }
1557
+ }
1558
+
1559
+ /** Options for {@link restorePathState}. */
1560
+ export interface RestorePathOptions {
1561
+ /**
1562
+ * Never remove or replace a regular file: only a symlink (or absence) is
1563
+ * touched, so an operator's file that appeared mid-transaction survives a
1564
+ * rollback that cannot clobber it — the same guard the brief-link write
1565
+ * itself applies (#652). A regular file where the entry state said
1566
+ * "absent"/"symlink" therefore reports a restoration failure: the prior
1567
+ * state is not coherent and the operator must be told.
1568
+ */
1569
+ preserveRegularFile?: boolean;
1570
+ }
1571
+
1572
+ /** Restores {@link CapturedPathState} captured before the first mutation:
1573
+ * writes the entry bytes back (with their mode), re-links a symlink's entry
1574
+ * target, or removes what the apply created when nothing was there. Returns
1575
+ * `undefined` on a complete restore, else the first failure's message — a
1576
+ * rollback that could not restore a path must be reported, never implied
1577
+ * complete. */
1578
+ export function restorePathState(state: CapturedPathState, opts: RestorePathOptions = {}): string | undefined {
1579
+ const { path } = state;
1580
+ try {
1581
+ if (state.kind === "bytes" && state.symlink) {
1582
+ // Re-link the entry target. Under `preserveRegularFile` a regular file
1583
+ // that replaced the link mid-flight is left alone — and reported — so a
1584
+ // rollback never clobbers an operator's file it did not create.
1585
+ let current: Stats | undefined;
1586
+ try {
1587
+ current = lstatSync(path);
1588
+ } catch {
1589
+ current = undefined;
1590
+ }
1591
+ if (current !== undefined && !current.isSymbolicLink() && opts.preserveRegularFile === true) {
1592
+ return `could not restore the symlink at ${path}: a regular file now occupies it`;
1593
+ }
1594
+ rmSync(path, { force: true });
1595
+ mkdirSync(dirname(path), { recursive: true });
1596
+ symlinkSync(state.bytes, path);
1597
+ return undefined;
1598
+ }
1599
+ if (state.kind === "bytes") {
1600
+ mkdirSync(dirname(path), { recursive: true });
1601
+ writeFileSync(path, state.bytes);
1602
+ // Mode is part of the pre-entry state (a staged unit 0644, the tick
1603
+ // config 0600, the recovery playbook 0755): writeFileSync preserves an
1604
+ // existing file's mode, so chmod explicitly restores the captured one.
1605
+ chmodSync(path, state.mode);
1606
+ return undefined;
1607
+ }
1608
+ // Absent at entry: remove what the apply created. A regular file that
1609
+ // appeared where the entry was absent is the operator's own unless the
1610
+ // caller says otherwise.
1611
+ let current: Stats | undefined;
1612
+ try {
1613
+ current = lstatSync(path);
1614
+ } catch {
1615
+ return undefined; // already gone — a complete restore.
1616
+ }
1617
+ if (current.isSymbolicLink() || opts.preserveRegularFile !== true) {
1618
+ rmSync(path, { force: true });
1619
+ return undefined;
1620
+ }
1621
+ return `could not restore ${path}: a regular file appeared where nothing was at entry`;
1622
+ } catch (err) {
1623
+ return `could not restore ${path}: ${err instanceof Error ? err.message : String(err)}`;
1624
+ }
1625
+ }
1626
+
1515
1627
  export interface SetupSmokeResult {
1516
1628
  mode: "temporary" | "existing";
1629
+ /**
1630
+ * Whether the smoke itself proved the daemon answers `/healthz` on
1631
+ * {@link daemon}.port. A record-less active unit has no port to probe, so
1632
+ * its "existing" smoke is unproven: it cannot count as passed until the
1633
+ * caller restarts the daemon through the lifecycle seam, whose
1634
+ * `waitForOwnedDaemon` proves MainPID + `/healthz` (#651, review #2).
1635
+ */
1636
+ healthProven: boolean;
1517
1637
  status: StatusSnapshot;
1518
1638
  daemon: DaemonRecord;
1519
1639
  }
@@ -1522,6 +1642,15 @@ export interface SetupSmokeDeps {
1522
1642
  paused(project: string): boolean;
1523
1643
  runOnce(project: string): Promise<void>;
1524
1644
  living(): DaemonRecord | undefined;
1645
+ /**
1646
+ * systemd ownership of the `omp-conductor.service` supervisor unit, probed
1647
+ * independently of the pidfile/runtime record. An active unit is a live
1648
+ * supervised daemon even when its record is missing or unreadable (#618):
1649
+ * it must never be run beside a `--once` tick, and `start()` must never be
1650
+ * aimed at it. `unknown` is preserved — never collapsed into `inactive` —
1651
+ * so a possibly-live supervised daemon is not run beside (#651, review #2).
1652
+ */
1653
+ unitOwnership(): UnitOwnership;
1525
1654
  health(port: number): Promise<{ ok: boolean; body?: string }>;
1526
1655
  start(project: string): Promise<DaemonRecord>;
1527
1656
  stop(): Promise<StopResult>;
@@ -1532,30 +1661,90 @@ const DEFAULT_SMOKE_DEPS: SetupSmokeDeps = {
1532
1661
  paused: isPaused,
1533
1662
  runOnce: async (project) => await runDaemon({ once: true, project }),
1534
1663
  living: livingDaemon,
1664
+ unitOwnership: () => probeUnit(SYSTEMD_UNIT),
1535
1665
  health: healthCheck,
1536
1666
  start: async (project) => await startDaemon({ project }),
1537
1667
  stop: stopDaemon,
1538
1668
  status: statusSnapshot,
1539
1669
  };
1540
1670
 
1671
+ /** The daemon record a smoke attributes to a live but record-less supervisor.
1672
+ * The port is unknown (the record would have named it); the "existing" mode
1673
+ * caller restarts the daemon through the lifecycle seam and recomputes the
1674
+ * real /healthz line from that restart, so this satisfies the result shape
1675
+ * without pretending to know a port. `healthProven: false` marks that this
1676
+ * is a placeholder until that restart proves the unit's MainPID answers. */
1677
+ function unrecordedDaemon(mainPid: number): DaemonRecord {
1678
+ return {
1679
+ pid: 0,
1680
+ port: 0,
1681
+ startedAt: 0,
1682
+ logFile: `<record missing — supervised daemon MainPID ${mainPid} via systemd>`,
1683
+ };
1684
+ }
1685
+
1541
1686
  export async function runSetupSmoke(
1542
1687
  project: string,
1543
1688
  deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
1544
1689
  ): Promise<SetupSmokeResult> {
1545
1690
  if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
1546
- await deps.runOnce(project);
1691
+ // Detect a live daemon BEFORE the mutating `--once` tick (#618). A paused
1692
+ // `--once` run is itself a dispatcher: it settles rows, projects labels,
1693
+ // admits and salvages workers. Running one beside a live daemon orphans and
1694
+ // salvages the runs the live process owns — exactly what the incident did to
1695
+ // three workers before failing its restart check. An already-active daemon
1696
+ // needs only its health proving (the caller restarts it through the lifecycle
1697
+ // seam so the new config takes effect) — no `--once` process ever runs beside
1698
+ // it, and no `systemctl start` is aimed at a unit that is already active.
1547
1699
  const existing = deps.living();
1548
1700
  if (existing !== undefined) {
1549
1701
  const health = await deps.health(existing.port);
1550
1702
  if (!health.ok) throw new Error(`existing daemon on :${existing.port} did not answer /healthz`);
1551
- return { mode: "existing", status: deps.status(project), daemon: existing };
1703
+ return { mode: "existing", healthProven: true, status: deps.status(project), daemon: existing };
1552
1704
  }
1553
1705
 
1706
+ // The pidfile record is absent, but that is not "no daemon": an active
1707
+ // systemd-owned unit is a live supervised dispatcher even when its record
1708
+ // went missing (a crash raced a re-record, or an unmanaged start never wrote
1709
+ // one). Without this independent probe the smoke runs a second `--once`
1710
+ // beside it, and `start()` targets the already-active unit — two dispatchers
1711
+ // owning the same state, the #618 incident shape. Treat it as an existing
1712
+ // daemon: no competing tick, no activation; the caller restarts the unit so
1713
+ // the new config takes effect (#651, review #1).
1714
+ const ownership = deps.unitOwnership();
1715
+ if (ownership.kind === "unknown") {
1716
+ // A probe failure is not the confirmed negative `inactive` claims to be:
1717
+ // collapsing `unknown` here is how a unit-owned daemon gets a second one
1718
+ // started beside it (#651, review #2). The mutating `--once` tick refuses
1719
+ // instead.
1720
+ throw new Error(
1721
+ `cannot prove whether a daemon is running: systemd ownership probe failed (${ownership.reason}) — ` +
1722
+ "refusing to run the setup smoke beside a possibly-live daemon",
1723
+ );
1724
+ }
1725
+ if (ownership.kind === "active") {
1726
+ // No record to read a port from, so no /healthz proof is possible here:
1727
+ // the smoke reports `existing` honestly as *unproven*, and the caller
1728
+ // must restart the unit through the lifecycle seam — whose
1729
+ // `waitForOwnedDaemon` proves MainPID + `/healthz` — before the smoke
1730
+ // counts as passed (#651, review #2).
1731
+ return {
1732
+ mode: "existing",
1733
+ healthProven: false,
1734
+ status: deps.status(project),
1735
+ daemon: unrecordedDaemon(ownership.pid),
1736
+ };
1737
+ }
1738
+
1739
+ // Confirmed inactive (or failed, which owns no process): a paused `--once`
1740
+ // smoke can run by itself, then a temporary daemon proves the new runtime,
1741
+ // then it is stopped again.
1742
+ await deps.runOnce(project);
1554
1743
  const daemon = await deps.start(project);
1555
1744
  try {
1556
1745
  const health = await deps.health(daemon.port);
1557
1746
  if (!health.ok) throw new Error(`temporary daemon on :${daemon.port} did not answer /healthz`);
1558
- return { mode: "temporary", status: deps.status(project), daemon };
1747
+ return { mode: "temporary", healthProven: true, status: deps.status(project), daemon };
1559
1748
  } finally {
1560
1749
  await deps.stop();
1561
1750
  }
@@ -30,12 +30,17 @@ import {
30
30
  type UpgradeScope,
31
31
  } from "./upgrade.ts";
32
32
  import {
33
+ graphConflictMessage,
33
34
  graphRepos,
34
35
  formatGraphSetup,
36
+ legacyReindexFiles,
37
+ legacyReindexNote,
35
38
  mcpEntry,
39
+ planGraphSetup,
36
40
  reindexScriptPath,
41
+ reindexUnitName,
37
42
  resolvePrereqs,
38
- REINDEX_UNIT,
43
+ stagingConflict,
39
44
  unitPaths,
40
45
  writeGraphSetup,
41
46
  type GraphPrereqs,
@@ -131,16 +136,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
131
136
  }
132
137
 
133
138
  /**
134
- * Whether two files hold identical bytes. The "keep" side of setup-host.ts's
135
- * `actionFor`: a unit systemd already reads that matches what would be written
136
- * needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
137
- * read error is a mismatch, never silently "matches", because the whole point
138
- * is to make the plan only claim a unit install is redundant when it can see
139
- * the installed copy.
139
+ * Whether a file holds exactly the rendered bytes. The "keep" side of
140
+ * setup-host.ts's `actionFor`: a unit systemd already reads that matches what
141
+ * would be written needs no reinstall. Reads can fail (permissions, vanished
142
+ * mid-flight) — a read error is a mismatch, never silently "matches", because
143
+ * the whole point is to make the plan only claim a unit install is redundant
144
+ * when it can see the installed copy. Comparing against the rendered content
145
+ * rather than the staged file keeps the comparison read-only: staging now
146
+ * happens only after consent (#720), so nothing may be written to compare.
140
147
  */
141
- function sameFile(a: string, b: string): boolean {
148
+ function sameContent(path: string, content: string): boolean {
142
149
  try {
143
- return readFileSync(a, "utf8") === readFileSync(b, "utf8");
150
+ return readFileSync(path, "utf8") === content;
144
151
  } catch {
145
152
  return false;
146
153
  }
@@ -450,9 +457,10 @@ export interface GraphInstallOptions extends InstallDeps {
450
457
  * Staging and enabling alone installs a service that fails on every run: the
451
458
  * generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
452
459
  * its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
453
- * than a graph. That is why `writeGraphSetup` already refused to call the old
454
- * install a finished job. So: prerequisites, then clones, then install, then
455
- * seed and verify — one preview, one confirm.
460
+ * than a graph. That is why staging was never allowed to call the install a
461
+ * finished job. So: prerequisites, then a conflict check, then clones, then
462
+ * install, then seed and verify — one preview, one confirm, and nothing
463
+ * staged before the confirm (#720).
456
464
  */
457
465
  export async function runGraphInstall(
458
466
  project: ProjectConfig,
@@ -494,7 +502,38 @@ export async function runGraphInstall(
494
502
  return { kind: "refused", reason: missing };
495
503
  }
496
504
 
497
- const staged = writeGraphSetup(project, options.unitDir ?? SYSTEMD_UNIT_DIR);
505
+ const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
506
+ // The plan is computed eagerly — `planGraphSetup` renders every byte, so the
507
+ // prompt can print exactly what would be written and the unitsCurrent gate
508
+ // below can decide a re-run installs nothing. But rendering is read-only: no
509
+ // file is written here. Staging happens only after consent (#720), so the
510
+ // prompt's "stages once you confirm" is true of the staged tree when it is
511
+ // printed, and a declined run leaves every staged file byte-identical.
512
+ const plan = planGraphSetup(project, unitDir);
513
+ // Never overwrite another project's staged artefact: the stems derive from
514
+ // the project name, and two names can fold to one stem ("My Project" vs
515
+ // "my_project"), so an existing file's generated-for marker is checked
516
+ // before the consent prompt — a collision is refused before anything is
517
+ // asked, let alone written (#720).
518
+ const conflict = stagingConflict(project, [
519
+ plan.script.path,
520
+ plan.service.path,
521
+ plan.timer.path,
522
+ ...Object.values(unitPaths(project, unitDir)),
523
+ ]);
524
+ if (conflict !== undefined) {
525
+ const message = graphConflictMessage(conflict);
526
+ ui.notify(message, "error");
527
+ return { kind: "refused", reason: message };
528
+ }
529
+ // A host that ran the pre-#720 project-less version keeps its old files:
530
+ // their timer refreshes whichever project generated it last, forever. Name
531
+ // the remediation before the consent prompt — deleting files outside this
532
+ // command's own names is exactly how the overwrite happened in the first
533
+ // place, so it is host action, not install action.
534
+ const legacy = legacyReindexFiles();
535
+ if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
536
+
498
537
  const absent = repos.filter((r) => !existsSync(r.graphProject));
499
538
 
500
539
  // 2. Missing clones, as the operator. A root-owned index-only clone under the
@@ -507,17 +546,18 @@ export async function runGraphInstall(
507
546
  }));
508
547
 
509
548
  const blocked = linuxOnly(options);
510
- const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
511
- const from = unitPaths(stateDir());
512
- // The units systemd already reads, compared to what staging just wrote — the
549
+ const stem = reindexUnitName(project);
550
+ const { service, timer } = unitPaths(project, unitDir);
551
+ // The units systemd already reads, compared to the rendered content — the
513
552
  // same distinction setup-host.ts draws between `installedAction` and
514
553
  // `service.action` (read the installed file, compare to the fresh content,
515
554
  // "keep" vs "update"). "Installed" is not enough: a green timer serving a
516
555
  // stale unit is the older bug, so when they differ the install runs. Only
517
556
  // when both units already match does the plan omit them — then just the
518
- // outstanding clone and seed remain.
557
+ // outstanding clone and seed remain. Nothing is written to compare: staging
558
+ // is post-consent now, so the comparison is against the rendered bytes.
519
559
  const unitsCurrent =
520
- blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
560
+ blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
521
561
  // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
522
562
  // one preview and one confirm, and a second confirm here also invented a
523
563
  // third outcome — a declined seed — that neither the caller nor the
@@ -525,32 +565,52 @@ export async function runGraphInstall(
525
565
  const seed: PrivilegedStep[] =
526
566
  options.noSeed === true
527
567
  ? []
528
- : [{ title: `seed the indexes (runs ${REINDEX_UNIT}.service once, minutes per repo)`, argv: ["systemctl", "start", `${REINDEX_UNIT}.service`] }];
568
+ : [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
529
569
  const install: PrivilegedStep[] =
530
570
  blocked === undefined
531
571
  ? [
532
572
  ...(unitsCurrent
533
573
  ? []
534
574
  : [
535
- { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
575
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
536
576
  { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
537
- { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
577
+ { title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
538
578
  ]),
539
579
  ...seed,
540
580
  ]
541
581
  : [];
542
582
 
543
583
  if (blocked !== undefined) {
584
+ // A non-Linux host cannot run systemd, so staging IS the deliverable: the
585
+ // rendered files are written for the operator to copy to the box that will
586
+ // run them, and only the systemctl half is refused. There is no consent
587
+ // prompt on this path — nothing privileged to approve — so the write lands
588
+ // here, not in the (never reached) post-consent hook (#720, as #510).
589
+ const staged = writeGraphSetup(project, unitDir);
544
590
  ui.notify(`${blocked} ${service} and ${timer}`, "warning");
545
591
  return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
546
592
  }
547
593
 
594
+ // What the post-consent staging actually wrote, surfaced by the outcomes
595
+ // that report it. Empty on a decline — the hook never ran.
596
+ let wrote: readonly string[] = [];
597
+ const beforeRun = async (): Promise<void> => {
598
+ // Consent has been given; this project's files may now land. Staging here —
599
+ // post-confirm, pre-step, under the same confirm that authorised the batch
600
+ // — is what makes the prompt's plan true of the staged tree when it is
601
+ // printed: a declined prompt never reaches this hook, so nothing is staged
602
+ // and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
603
+ // backstop throws here with nothing written if a collision raced the
604
+ // pre-consent check.
605
+ wrote = writeGraphSetup(project, unitDir).written;
606
+ };
607
+
548
608
  const outcome = await runPrivileged([...clones, ...install], ui, {
549
609
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
550
610
  title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
551
611
  answerKey: `install-code-graph.${project.name}`,
552
612
  preamble: [
553
- `Staged: ${staged.written.join(", ")}.`,
613
+ `Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
554
614
  ...(clones.length === 0
555
615
  ? ["Every indexed clone already exists."]
556
616
  : [
@@ -561,8 +621,9 @@ export async function runGraphInstall(
561
621
  ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
562
622
  `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
563
623
  ],
624
+ beforeRun,
564
625
  });
565
- if (outcome.kind === "declined") return { kind: "declined", wrote: staged.written };
626
+ if (outcome.kind === "declined") return { kind: "declined", wrote };
566
627
  if (outcome.kind === "failed") {
567
628
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
568
629
  }
@@ -571,10 +632,10 @@ export async function runGraphInstall(
571
632
  // answer: an unseeded graph is not usable until the timer first fires.
572
633
  if (options.noSeed === true) {
573
634
  ui.notify(
574
- `Timer enabled; skipped the seeding run. The graph is NOT usable until ${REINDEX_UNIT}.timer first fires.`,
635
+ `Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
575
636
  "warning",
576
637
  );
577
- return { kind: "installed", wrote: staged.written };
638
+ return { kind: "installed", wrote };
578
639
  }
579
640
 
580
641
  const health = await (options.probe ?? probeCodeGraph)(project);
@@ -583,13 +644,13 @@ export async function runGraphInstall(
583
644
  // Staged but not trusted. Reporting success here is how an operator learns
584
645
  // months later that no worker ever read an index.
585
646
  ui.notify(
586
- [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", staged.next].join("\n"),
647
+ [`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
587
648
  "error",
588
649
  );
589
650
  return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
590
651
  }
591
652
  ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
592
- return { kind: "installed", wrote: staged.written };
653
+ return { kind: "installed", wrote };
593
654
  }
594
655
 
595
656
  /** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
@@ -618,7 +679,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
618
679
  return graphRepos(project);
619
680
  }
620
681
 
621
- /** Where the reindex script lands, for the wizard's tail to name. */
622
- export function reindexScriptLocation(): string {
623
- return reindexScriptPath();
682
+ /** Where this project's reindex script lands, for the wizard's tail to name. */
683
+ export function reindexScriptLocation(project: ProjectConfig): string {
684
+ return reindexScriptPath(project);
624
685
  }