omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -15,10 +15,20 @@
15
15
  * call in here; `privileged.ts` stays the primitive underneath.
16
16
  */
17
17
 
18
- import { existsSync } from "node:fs";
18
+ import { existsSync, readFileSync } from "node:fs";
19
19
  import { platform } from "node:os";
20
20
  import { join } from "node:path";
21
- import { stateDir } from "./config.ts";
21
+ import { loadConfig, stateDir } from "./config.ts";
22
+ import { pauseInstance, setPaused, statusSnapshot } from "./daemon.ts";
23
+ import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
24
+ import { livingDaemon } from "./lifecycle.ts";
25
+ import { dbPath, openStore } from "./store.ts";
26
+ import {
27
+ resolveScope,
28
+ pauseAndDrain,
29
+ type DrainDeps,
30
+ type UpgradeScope,
31
+ } from "./upgrade.ts";
22
32
  import {
23
33
  graphRepos,
24
34
  formatGraphSetup,
@@ -40,10 +50,12 @@ import {
40
50
  writeHostRuntime,
41
51
  STAGED_SERVICE_NAME,
42
52
  SYSTEMD_UNIT_DIR,
53
+ RECOVER_SCRIPT_INSTALL_PATH,
43
54
  type EscalationDeps,
55
+ type ServiceRuntime,
44
56
  } from "./setup-host.ts";
45
57
  import type { WizardUi } from "./wizard-ui.ts";
46
- import type { Caps, ProjectConfig } from "./types.ts";
58
+ import type { Caps, ProjectConfig, Store } from "./types.ts";
47
59
 
48
60
  /**
49
61
  * Every outcome a caller has to tell apart. `staged` is the non-Linux answer:
@@ -62,6 +74,40 @@ export interface InstallDeps {
62
74
  /** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
63
75
  platform?: () => string;
64
76
  unitDir?: string;
77
+ /**
78
+ * Where the recovery playbook installs. Injectable so the idempotency gate
79
+ * is testable hermetically; the real install uses
80
+ * {@link RECOVER_SCRIPT_INSTALL_PATH}.
81
+ */
82
+ recoverScriptInstallPath?: string;
83
+ /**
84
+ * The runtime the plan derives from. Injectable so tests pin herdr
85
+ * present/absent deterministically; the default ({@link defaultServiceRuntime})
86
+ * detects herdr from the host's own PATH.
87
+ */
88
+ runtime?: ServiceRuntime;
89
+ /**
90
+ * Whether the host configures more than one project, which scopes the
91
+ * recovery unit's RECOVER_PROJECT (host-global: never one project's name on
92
+ * a multi-project host, #510/#530). Injectable so tests pin the shape
93
+ * hermetically; the default ({@link hostMultiProject}) reads the config.
94
+ */
95
+ multiProject?: boolean;
96
+ /**
97
+ * The pause/drain fence around the restart (#512): which projects to drain,
98
+ * how long to wait, and the store used to attribute a run the restart kills.
99
+ * Injectable so tests pin the drain hermetically; the defaults read the real
100
+ * daemon, sentinel and store.
101
+ */
102
+ drain?: {
103
+ deps?: DrainDeps;
104
+ /** How long the drain waits for live workers before giving up and staying
105
+ * paused; absent waits indefinitely, the `upgrade` posture. */
106
+ timeoutMs?: number;
107
+ /** The store consulted for runs still live at restart time, so they are
108
+ * attributed before the session host restarts under them. */
109
+ drainStore?: Pick<Store, "liveRuns" | "updateRun">;
110
+ };
65
111
  }
66
112
 
67
113
  /**
@@ -76,11 +122,37 @@ function linuxOnly(deps: InstallDeps): string | undefined {
76
122
  }
77
123
 
78
124
  /**
79
- * Install the supervised daemon unit: re-render, stage, then run the four steps
80
- * `planHostRuntime` used to only print.
125
+ * Whether two files hold identical bytes. The "keep" side of setup-host.ts's
126
+ * `actionFor`: a unit systemd already reads that matches what would be written
127
+ * needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
128
+ * read error is a mismatch, never silently "matches", because the whole point
129
+ * is to make the plan only claim a unit install is redundant when it can see
130
+ * the installed copy.
131
+ */
132
+ function sameFile(a: string, b: string): boolean {
133
+ try {
134
+ return readFileSync(a, "utf8") === readFileSync(b, "utf8");
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Install the supervised daemon unit: re-render, stage, then run the plan's
142
+ * steps in order. The steps are the plan's own {@link HostRuntimePlan.steps} —
143
+ * the same single list its {@link installCommands} display renders from — so
144
+ * what is printed and what is executed can never diverge.
81
145
  */
82
146
  export async function runHostInstall(
83
- project: ProjectConfig,
147
+ /**
148
+ * The project whose per-project tail to write, or `undefined` for a
149
+ * host-global install: the units are host-global (same staged paths, same
150
+ * systemd destinations, one shared daemon), so no project still installs
151
+ * and starts them. The per-project tail (tick config, brief link) is the
152
+ * only part that needs a name — with none, it is skipped with a printed
153
+ * note (#530).
154
+ */
155
+ project: ProjectConfig | undefined,
84
156
  caps: Caps,
85
157
  telegramStateDir: string,
86
158
  ui: WizardUi,
@@ -92,43 +164,206 @@ export async function runHostInstall(
92
164
  return { kind: "refused", reason: verdict.message };
93
165
  }
94
166
 
95
- const plan = planHostRuntime(project, caps, telegramStateDir, undefined, hostInstallWorkers(caps));
96
- const wrote = writeHostRuntime(plan);
97
167
  const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
168
+ // The plan is computed eagerly — `planHostRuntime` renders every byte and
169
+ // compares it against disk, so the prompt can print exactly what would be
170
+ // written and the currentInstall gate below can decide a re-run restarts
171
+ // nothing. But rendering is read-only: no file is written here. Staging
172
+ // happens only after consent (#510), so the prompt's "Nothing has been run
173
+ // yet" is true of the staged tree when it is printed, and a declined run
174
+ // leaves every staged file byte-identical.
175
+ const plan = planHostRuntime(
176
+ project,
177
+ caps,
178
+ telegramStateDir,
179
+ deps.runtime,
180
+ hostInstallWorkers(caps),
181
+ unitDir,
182
+ deps.recoverScriptInstallPath ?? RECOVER_SCRIPT_INSTALL_PATH,
183
+ deps.multiProject ?? hostMultiProject(),
184
+ );
185
+ // No pane-shell key is planned (unusable login shell, unparseable config):
186
+ // the operator hears why before the consent prompt, not after an install
187
+ // that quietly had nothing to say about the shell.
188
+ if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
189
+ // A host-global install (no --project): the per-project tail was
190
+ // deliberately not written, and the operator must be told which files and
191
+ // how to get them — never a silent skip.
192
+ if (plan.noProject !== undefined) {
193
+ ui.notify(
194
+ `No project named: ${plan.noProject.skipped.join(", ")} were not written. ${plan.noProject.how}.`,
195
+ "info",
196
+ );
197
+ }
98
198
  const installed = join(unitDir, STAGED_SERVICE_NAME);
199
+ const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
99
200
 
100
201
  const blocked = linuxOnly(deps);
101
202
  if (blocked !== undefined) {
203
+ // A non-Linux host cannot run systemd, so staging IS the deliverable: the
204
+ // rendered files are written for the operator to copy to the box that will
205
+ // run them, and only the systemctl half is refused. There is no consent
206
+ // prompt on this path — nothing privileged to approve — so the write lands
207
+ // here, not in the (never reached) post-consent hook.
208
+ const { wrote, warnings } = writeHostRuntime(plan);
209
+ for (const warning of warnings) ui.notify(warning, "warning");
102
210
  ui.notify(`${blocked} ${plan.service.path}`, "warning");
103
211
  return { kind: "staged", wrote, reason: `${blocked} ${plan.service.path}` };
104
212
  }
105
213
 
214
+ // A no-op re-run: every unit the steps would install is already current on
215
+ // disk, so installing them again would only touch mtimes and restart the
216
+ // units for nothing. Report success without running a single privileged step.
217
+ // Nothing is staged here either: a re-run with nothing to install has no
218
+ // reason to touch the staged tree.
219
+ if (plan.currentInstall) {
220
+ ui.notify("The supervised daemon and recovery unit are already current — nothing to install, nothing restarted.", "info");
221
+ return { kind: "installed", wrote: [] };
222
+ }
223
+
106
224
  // argv, not shell: `installCommands` renders `sudo …` strings for humans to
107
225
  // 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, {
226
+ // two arguments. The plan carries the exact argv once, in
227
+ // {@link HostRuntimePlan.steps}; we execute that same list, so the printed
228
+ // plan and the run are the same steps (#509).
229
+ //
230
+ // The fence `upgrade` already has (#512): pause new issue claims, drain live
231
+ // workers to idle, restart the units, then restore the dispatch state this
232
+ // began in. The consent prompt below names how many workers will be drained
233
+ // so the operator can choose the moment; it authorises the whole batch,
234
+ // drain included, in one confirm (the `beforeRun` hook).
235
+ const drainDeps = hostInstallDrainDeps(ui, deps);
236
+ // Host-wide by default (#389): the daemon this restarts serves every
237
+ // configured project, so the drain covers all of them — narrowing to the
238
+ // named project would restart the shared session host under the other
239
+ // projects' live workers without ever counting them.
240
+ const fenceScope = resolveScope(drainDeps, "setup host");
241
+ const liveWorkers = fenceScope.selectors.reduce((n, s) => n + drainDeps.snapshot(s).liveWorkers, 0);
242
+ let fence: { scope: UpgradeScope; initialPaused: boolean } | undefined;
243
+ // What the post-consent staging actually wrote, surfaced by the outcomes that
244
+ // report it. Empty on a decline — the hook never ran.
245
+ let wrote: readonly string[] = [];
246
+ const beforeRun = async (): Promise<void> => {
247
+ // Consent has been given; the plan's files may now land. Staging here —
248
+ // post-confirm, pre-step, under the same confirm that authorised the batch
249
+ // — is what makes the prompt's "Nothing has been run yet" true of the
250
+ // staged tree when it is printed: a declined prompt never reaches this
251
+ // hook, so nothing is staged and a re-run still plans an install (#510).
252
+ const staged = writeHostRuntime(plan);
253
+ wrote = staged.wrote;
254
+ for (const warning of staged.warnings) ui.notify(warning, "warning");
255
+ fence = await pauseAndDrain(drainDeps, "setup host", {
256
+ ...(deps.drain?.timeoutMs === undefined ? {} : { timeoutMs: deps.drain.timeoutMs }),
257
+ });
258
+ // Any run still live when the session host is about to restart is killed by
259
+ // it; record the actor on its row so the classifier reads an administrative
260
+ // kill rather than charging a failure for an operator's restart (#512). The
261
+ // fence drained to idle, so in the healthy path this records nothing.
262
+ attributeRestartKills(fence.scope, project?.name ?? "", deps.drain?.drainStore);
263
+ };
264
+
265
+ const outcome = await runPrivileged(plan.steps, ui, {
117
266
  ...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
118
- title: "Install and start the supervised daemon?",
267
+ title: "Install and start the supervised session?",
119
268
  preamble: [
120
269
  `Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
270
+ ...(plan.herdrUnit === undefined
271
+ ? []
272
+ : [
273
+ `Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
274
+ "run it instead of dash, then enables and restarts it.",
275
+ ...(plan.herdrConfigTarget === undefined
276
+ ? []
277
+ : [
278
+ `And merges [terminal] default_shell into ${plan.herdrConfigTarget} as you, before that restart,`,
279
+ "so the session server reads the pinned shell the moment it comes up.",
280
+ ]),
281
+ ]),
121
282
  "The unit runs as the account that staged it; nothing here changes that.",
283
+ "",
284
+ liveWorkers === 0
285
+ ? "No workers are live — nothing to drain before the restart."
286
+ : `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.`,
122
287
  ],
288
+ beforeRun,
123
289
  });
124
290
  if (outcome.kind === "declined") return { kind: "declined", wrote };
125
291
  if (outcome.kind === "failed") {
292
+ // Restore the preceding dispatch state: a restart that failed is still a
293
+ // finished operator action whose units may or may not be up, so the fleet
294
+ // must not sit paused for it. A throw from the drain itself (a timeout or
295
+ // a superseded fence) never reaches here — it propagates and leaves
296
+ // dispatch paused, fail-closed, exactly as upgrade does.
297
+ if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
126
298
  return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
127
299
  }
300
+ if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
128
301
  ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
129
302
  return { kind: "installed", wrote };
130
303
  }
131
304
 
305
+ /**
306
+ * The pause/drain fence's default accessors, overridable per test through
307
+ * {@link InstallDeps.drain.deps}. Mirrors `upgrade.ts`'s {@link DrainDeps}
308
+ * wiring, except the pause names `setup host` as the actor and the drain
309
+ * reports progress through the same UI surface.
310
+ */
311
+ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
312
+ if (deps.drain?.deps !== undefined) return deps.drain.deps;
313
+ return {
314
+ snapshot: statusSnapshot,
315
+ layers: fleetLayers,
316
+ projectNames: () => loadConfig().projects.map((p) => p.name),
317
+ daemonIdentity: () => {
318
+ const daemon = livingDaemon();
319
+ return daemon === undefined
320
+ ? { running: false }
321
+ : { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
322
+ },
323
+ pauseState: (project) => pauseInstance(project),
324
+ setPaused: (v, project) => setPaused(v, { source: "setup host", reason: "setup host, draining" }, project),
325
+ sleep: Bun.sleep,
326
+ log: (message) => ui.notify(message, "info"),
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Attribute the runs the restart is about to kill: any run still live when the
332
+ * session host restarts underneath it is that restart's casualty, not a failed
333
+ * attempt, so its row records the actor. The classifier reads this as an
334
+ * administrative kill that costs no budget and requeues (#512). The fence
335
+ * drains to idle first, so in the healthy path this records nothing — it is
336
+ * the safety net for a claim that raced the drain or a worker the drain could
337
+ * not bring to idle.
338
+ */
339
+ function attributeRestartKills(
340
+ scope: UpgradeScope,
341
+ project: string,
342
+ store: Pick<Store, "liveRuns" | "updateRun"> | undefined,
343
+ ): void {
344
+ const attribute = (owner: Pick<Store, "liveRuns" | "updateRun">): void => {
345
+ for (const selector of scope.selectors) {
346
+ for (const run of owner.liveRuns(selector ?? project)) {
347
+ owner.updateRun(run.id, { lastError: "admin restart: setup host (draining)" });
348
+ }
349
+ }
350
+ };
351
+ if (store !== undefined) {
352
+ attribute(store);
353
+ return;
354
+ }
355
+ const path = dbPath();
356
+ // No store yet — a host that has never dispatched — means nothing to attribute
357
+ // and nothing a fresh open would do but create an empty database.
358
+ if (!existsSync(path)) return;
359
+ const owner = openStore(path);
360
+ try {
361
+ attribute(owner);
362
+ } finally {
363
+ owner.close();
364
+ }
365
+ }
366
+
132
367
  /** Fleet-wide worker sum when config is loadable; otherwise this project's caps. */
133
368
  function hostInstallWorkers(caps: Caps): number {
134
369
  try {
@@ -138,6 +373,22 @@ function hostInstallWorkers(caps: Caps): number {
138
373
  }
139
374
  }
140
375
 
376
+ /**
377
+ * Whether this host configures more than one project. The recovery unit is
378
+ * host-global and must not encode one project's name when several are
379
+ * configured, so the plan scopes RECOVER_PROJECT by this (#510/#530). A
380
+ * loadable config is the truth; when it cannot be read (a not-yet-configured
381
+ * box), there is no evidence of several projects and the named case stays
382
+ * unambiguous — the same fallback shape as {@link hostInstallWorkers}.
383
+ */
384
+ function hostMultiProject(): boolean {
385
+ try {
386
+ return loadConfig().projects.length > 1;
387
+ } catch {
388
+ return false;
389
+ }
390
+ }
391
+
141
392
  export interface GraphInstallOptions extends InstallDeps {
142
393
  /**
143
394
  * Skip the seeding step. The timer is still installed and enabled, and the
@@ -219,6 +470,15 @@ export async function runGraphInstall(
219
470
  const blocked = linuxOnly(options);
220
471
  const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
221
472
  const from = unitPaths(stateDir());
473
+ // The units systemd already reads, compared to what staging just wrote — the
474
+ // same distinction setup-host.ts draws between `installedAction` and
475
+ // `service.action` (read the installed file, compare to the fresh content,
476
+ // "keep" vs "update"). "Installed" is not enough: a green timer serving a
477
+ // stale unit is the older bug, so when they differ the install runs. Only
478
+ // when both units already match does the plan omit them — then just the
479
+ // outstanding clone and seed remain.
480
+ const unitsCurrent =
481
+ blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
222
482
  // 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
223
483
  // one preview and one confirm, and a second confirm here also invented a
224
484
  // third outcome — a declined seed — that neither the caller nor the
@@ -230,9 +490,13 @@ export async function runGraphInstall(
230
490
  const install: PrivilegedStep[] =
231
491
  blocked === undefined
232
492
  ? [
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`] },
493
+ ...(unitsCurrent
494
+ ? []
495
+ : [
496
+ { title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
497
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
498
+ { title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
499
+ ]),
236
500
  ...seed,
237
501
  ]
238
502
  : [];
@@ -242,19 +506,19 @@ export async function runGraphInstall(
242
506
  return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
243
507
  }
244
508
 
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
509
  const outcome = await runPrivileged([...clones, ...install], ui, {
251
510
  ...(options.privileged === undefined ? {} : { deps: options.privileged }),
252
- title: "Clone, install and enable the code-graph timer?",
511
+ title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
253
512
  preamble: [
254
513
  `Staged: ${staged.written.join(", ")}.`,
255
514
  ...(clones.length === 0
256
515
  ? ["Every indexed clone already exists."]
257
- : [`${clones.length} clone(s) run as you; the unit install needs root.`]),
516
+ : [
517
+ `${clones.length} clone(s) run as you; ${
518
+ unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root."
519
+ }`,
520
+ ]),
521
+ ...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
258
522
  `Indexer: ${prereqs.indexer ?? "on PATH"}.`,
259
523
  ],
260
524
  });