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
package/src/upgrade.ts CHANGED
@@ -12,13 +12,28 @@ import {
12
12
  type FleetLayers,
13
13
  } from "./fleet.ts";
14
14
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
15
- import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, writeConfigRaw } from "./config.ts";
15
+ import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
16
16
  import { renderBriefForProject } from "./setup.ts";
17
17
  import {
18
18
  STAGED_SERVICE_NAME,
19
19
  planHostRuntime,
20
20
  writeHostRuntime,
21
21
  } from "./setup-host.ts";
22
+ import {
23
+ appendJournal,
24
+ readUpgradeJournal,
25
+ upgradeJournalPath,
26
+ type UpgradeJournalEntry,
27
+ } from "./upgrade-journal.ts";
28
+ import {
29
+ enqueueUpgradeReport,
30
+ pendingUpgradeRequest,
31
+ reportProject,
32
+ requestProgression,
33
+ runCommand,
34
+ type PendingUpgradeRequest,
35
+ type UpgradeCommandResult,
36
+ } from "./upgrade-verify.ts";
22
37
 
23
38
  const PACKAGE = "omp-conductor";
24
39
  const HERDR_PLUGIN = "herdr-conductor";
@@ -28,15 +43,17 @@ const DRAIN_POLL_MS = 5_000;
28
43
  const RECOVERY_POLL_MS = 2_000;
29
44
  const RECOVERY_ATTEMPTS = 30;
30
45
 
31
- export interface UpgradeCommandResult {
32
- code: number;
33
- stdout: string;
34
- stderr: string;
35
- }
36
-
37
46
  export interface UpgradeOptions {
38
47
  version?: string;
39
48
  project?: string;
49
+ /**
50
+ * Run as the detached fleet installer (#486): journal every surface the
51
+ * transaction touches, skip the in-process verification and leave dispatch
52
+ * paused — the first tick of the post-restart daemon verifies version,
53
+ * /healthz, ticks, pane and doctor, and closes or rolls back on that
54
+ * evidence. Only the transient unit's `upgrade-install` entry sets this.
55
+ */
56
+ detached?: boolean;
40
57
  }
41
58
 
42
59
  export interface UpgradeResult {
@@ -79,25 +96,18 @@ export interface UpgradeDeps {
79
96
  sleep(ms: number): Promise<void>;
80
97
  env: NodeJS.ProcessEnv;
81
98
  log(message: string): void;
99
+ /**
100
+ * The durable journal sink for the detached fleet installer (#486). Wired
101
+ * to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
102
+ * on the human `upgrade` path, whose own process is its witness. The engine
103
+ * journals each surface boundary so the post-restart tick can verify against
104
+ * a record it did not write.
105
+ */
106
+ journal?(entry: UpgradeJournalEntry): void;
82
107
  }
83
108
 
84
- async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
85
- try {
86
- const child = Bun.spawn([command, ...args], {
87
- stdin: "ignore",
88
- stdout: "pipe",
89
- stderr: "pipe",
90
- env: process.env,
91
- });
92
- const stdout = new Response(child.stdout).text();
93
- const stderr = new Response(child.stderr).text();
94
- const code = await child.exited;
95
- return { code, stdout: await stdout, stderr: await stderr };
96
- } catch (err) {
97
- return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
98
- }
99
- }
100
-
109
+ /** One command run to completion with captured output. Exported so the
110
+ * post-restart verifier and the rollback unit run processes the same way. */
101
111
  export const DEFAULT_DEPS: UpgradeDeps = {
102
112
  run: runCommand,
103
113
  snapshot: statusSnapshot,
@@ -301,13 +311,39 @@ function previousHerdrInstall(source: string): readonly [string, readonly string
301
311
  * exactly this transaction's pause while leaving any per-project operator hold
302
312
  * standing.
303
313
  */
304
- interface UpgradeScope {
314
+ export interface UpgradeScope {
305
315
  /** Project selectors to address the project-aware deps with, never empty. */
306
316
  selectors: readonly (string | undefined)[];
307
317
  /** The sentinel this transaction pauses and resumes. */
308
318
  pauseKey: string | undefined;
309
319
  }
310
320
 
321
+ /**
322
+ * The subset of {@link UpgradeDeps} the pause/drain fence needs. Factored out
323
+ * so a caller that only wants the fence — `setup host` — can provide just
324
+ * these members instead of a full install-and-verify deps object. Every
325
+ * {@link UpgradeDeps} is a `DrainDeps`.
326
+ */
327
+ export interface DrainDeps {
328
+ /** Live worker count per project selector; `undefined` = the one configured project. */
329
+ snapshot(project?: string): { liveWorkers: number };
330
+ /** The fleet's current dispatch/daemon layers for one project selector. */
331
+ layers(project?: string): FleetLayers;
332
+ /** Every configured project name, in config order (#389). */
333
+ projectNames(): readonly string[];
334
+ /**
335
+ * The daemon the transaction targets: running or not, its project when it
336
+ * recorded one, and a `generation` identifying the exact instance (#377).
337
+ */
338
+ daemonIdentity(): { running: boolean; project?: string; generation?: string };
339
+ /** The active pause sentinel as an instance, when one is readable (#377). */
340
+ pauseState(project?: string): { source: string; reason?: string; since: number } | undefined;
341
+ /** Write or clear the durable pause sentinel for the scope's project. */
342
+ setPaused(value: boolean, project?: string): void;
343
+ sleep(ms: number): Promise<void>;
344
+ log(message: string): void;
345
+ }
346
+
311
347
  /**
312
348
  * Decides which projects a transaction covers, and refuses the requests that
313
349
  * cannot be honoured truthfully.
@@ -326,7 +362,11 @@ interface UpgradeScope {
326
362
  * A single-configured-project host keeps the historical bare selector, so its
327
363
  * behaviour is unchanged whether or not the daemon recorded its name.
328
364
  */
329
- function resolveScope(deps: UpgradeDeps, verb: "upgrade" | "restart", project?: string): UpgradeScope {
365
+ export function resolveScope(
366
+ deps: DrainDeps,
367
+ verb: "upgrade" | "restart" | "setup host",
368
+ project?: string,
369
+ ): UpgradeScope {
330
370
  let configured: readonly string[] = [];
331
371
  try {
332
372
  configured = deps.projectNames();
@@ -362,8 +402,8 @@ function resolveScope(deps: UpgradeDeps, verb: "upgrade" | "restart", project?:
362
402
  return { selectors: [undefined], pauseKey: undefined };
363
403
  }
364
404
 
365
- async function waitForDrain(
366
- deps: UpgradeDeps,
405
+ export async function waitForDrain(
406
+ deps: DrainDeps,
367
407
  scope: UpgradeScope,
368
408
  deadlineAt?: number,
369
409
  stale?: () => string | undefined,
@@ -417,7 +457,7 @@ interface RestartBegun {
417
457
  * stopped by it.
418
458
  */
419
459
  function restartFenceProblem(
420
- deps: UpgradeDeps,
460
+ deps: DrainDeps,
421
461
  scope: UpgradeScope,
422
462
  begun: RestartBegun,
423
463
  ): string | undefined {
@@ -440,6 +480,59 @@ function restartFenceProblem(
440
480
  return undefined;
441
481
  }
442
482
 
483
+ /**
484
+ * Pause claims and drain live workers to idle — the destructive half of the
485
+ * trusted restart transaction, minus the restart. Exported so `setup host`
486
+ * runs the same fence `upgrade` and `restart` do, then performs its own unit
487
+ * installs instead of {@link drainAndRestart}'s daemon restart.
488
+ *
489
+ * Generation-scoped (#377) exactly as the full transaction is: the caller
490
+ * captures the restart-owned pause and the daemon generation, and the drain
491
+ * aborts if a `resume` lifts the pause, replaces the sentinel, or a newer
492
+ * daemon generation appears. On ANY throw after the pause the fleet stays
493
+ * paused (no resume in a catch), so a drain that outlives `timeoutMs` cannot
494
+ * silently resume dispatch over a wedged daemon. `timeoutMs` absent waits
495
+ * indefinitely, which is the `upgrade` posture.
496
+ */
497
+ export async function pauseAndDrain(
498
+ deps: DrainDeps,
499
+ verb: "upgrade" | "restart" | "setup host",
500
+ o: { project?: string; timeoutMs?: number },
501
+ ): Promise<{ scope: UpgradeScope; initialPaused: boolean }> {
502
+ // Host-wide by default (#389): the daemon this restarts serves every
503
+ // configured project, so the drain counts every project's workers and the
504
+ // pause covers all of them.
505
+ const scope = resolveScope(deps, verb, o.project);
506
+ const initial = deps.layers(scope.pauseKey);
507
+ if (!initial.paused) deps.setPaused(true, scope.pauseKey);
508
+ // The lock must be provable NOW. A pause that cannot be read as an instance
509
+ // — sentinel malformed or unreadable — would make the whole fence fail open
510
+ // (nothing to compare against), so the transaction refuses before waiting
511
+ // on anything it cannot act upon either way.
512
+ const pauseToken = deps.pauseState(scope.pauseKey);
513
+ if (pauseToken === undefined) {
514
+ throw new Error(
515
+ `${verb} cancelled: cannot prove the ${verb}-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted`,
516
+ );
517
+ }
518
+ const begun: RestartBegun = {
519
+ pauseToken,
520
+ daemon: deps.daemonIdentity(),
521
+ };
522
+ const stale = () => restartFenceProblem(deps, scope, begun);
523
+ await waitForDrain(
524
+ deps,
525
+ scope,
526
+ o.timeoutMs === undefined ? undefined : Date.now() + o.timeoutMs,
527
+ stale,
528
+ );
529
+ // The drain completed; the world may have moved on while it did. Re-prove
530
+ // the pause and the generation an instant before the destructive call.
531
+ const cancelled = stale();
532
+ if (cancelled !== undefined) throw new Error(cancelled);
533
+ return { scope, initialPaused: initial.paused };
534
+ }
535
+
443
536
  /**
444
537
  * Pause, drain, restart, restore — the trusted restart transaction, minus the
445
538
  * install/verify steps of {@link upgradeConductor}.
@@ -467,34 +560,9 @@ export async function drainAndRestart(
467
560
  deps: UpgradeDeps,
468
561
  o: { project?: string; timeoutMs: number },
469
562
  ): Promise<void> {
470
- // Host-wide by default (#389): the daemon this restarts serves every
471
- // configured project, so the drain counts every project's workers and the
472
- // pause covers all of them.
473
- const scope = resolveScope(deps, "restart", o.project);
474
- const initial = deps.layers(scope.pauseKey);
475
- if (!initial.paused) deps.setPaused(true, scope.pauseKey);
476
- // The lock must be provable NOW. A pause that cannot be read as an instance
477
- // — sentinel malformed or unreadable — would make the whole fence fail open
478
- // (nothing to compare against), so the transaction refuses before waiting
479
- // on anything it cannot act upon either way.
480
- const pauseToken = deps.pauseState(scope.pauseKey);
481
- if (pauseToken === undefined) {
482
- throw new Error(
483
- "restart cancelled: cannot prove the restart-owned pause — the active pause sentinel is unreadable or malformed; nothing was restarted",
484
- );
485
- }
486
- const begun: RestartBegun = {
487
- pauseToken,
488
- daemon: deps.daemonIdentity(),
489
- };
490
- const stale = () => restartFenceProblem(deps, scope, begun);
491
- await waitForDrain(deps, scope, Date.now() + o.timeoutMs, stale);
492
- // The drain completed; the world may have moved on while it did. Re-prove
493
- // the pause and the generation an instant before the destructive call.
494
- const cancelled = stale();
495
- if (cancelled !== undefined) throw new Error(cancelled);
563
+ const { scope, initialPaused } = await pauseAndDrain(deps, "restart", o);
496
564
  await deps.restartDaemon();
497
- if (!initial.paused) deps.setPaused(false, scope.pauseKey);
565
+ if (!initialPaused) deps.setPaused(false, scope.pauseKey);
498
566
  }
499
567
 
500
568
  function recoveryProblem(
@@ -576,7 +644,10 @@ async function upgradeBriefs(deps: UpgradeDeps, briefs: readonly ScopedBrief[]):
576
644
  }
577
645
  }
578
646
 
579
- async function rollbackUpgrade(
647
+ /** Restore every surface the upgrade touched, in reverse, from the identities
648
+ * captured before the first install. Exported so the detached rollback unit
649
+ * can restore what the detached installer recorded (#486). */
650
+ export async function rollbackUpgrade(
580
651
  deps: UpgradeDeps,
581
652
  previous: InstalledSurfaces,
582
653
  scope: UpgradeScope,
@@ -736,6 +807,10 @@ export async function upgradeConductor(
736
807
  overrides: Partial<UpgradeDeps> = {},
737
808
  ): Promise<UpgradeResult> {
738
809
  const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
810
+ const detached = options.detached === true;
811
+ const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = new Date()): void => {
812
+ deps.journal?.({ ...entry, at: at.toISOString() });
813
+ };
739
814
  if (deps.env["HERDR_ENV"] !== undefined) {
740
815
  throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
741
816
  }
@@ -748,6 +823,18 @@ export async function upgradeConductor(
748
823
  const release = parseRegistry(
749
824
  (await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
750
825
  );
826
+ if (detached) {
827
+ // The durable request: written before anything can pause, so a unit that
828
+ // dies after this line leaves a journal the returning process can act on
829
+ // (roll back, resume, or report) instead of a paused fleet with no record.
830
+ journal({
831
+ kind: "request",
832
+ ok: true,
833
+ version: release.version,
834
+ gitHead: release.gitHead,
835
+ unit: deps.env["OMP_CONDUCTOR_UNIT"],
836
+ });
837
+ }
751
838
  const initial = deps.layers(scope.pauseKey);
752
839
  const surfaces = await inspectSurfaces(deps);
753
840
  const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
@@ -756,6 +843,15 @@ export async function upgradeConductor(
756
843
  }));
757
844
  const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
758
845
  if (!installNeeded && briefs.every((b) => b.current)) {
846
+ if (detached) {
847
+ journal({
848
+ kind: "outcome",
849
+ phase: "already-current",
850
+ ok: true,
851
+ version: release.version,
852
+ detail: "all three surfaces and every brief were already pinned to the target release",
853
+ });
854
+ }
759
855
  return {
760
856
  previousVersion: surfaces.cliVersion,
761
857
  ...release,
@@ -796,20 +892,51 @@ export async function upgradeConductor(
796
892
  }
797
893
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
798
894
 
895
+ if (detached) {
896
+ // The durable record the returning process rolls back or verifies against:
897
+ // what was installed, what dispatch state the fleet began in, which pause
898
+ // sentinel this transaction owns, and the config snapshot for the restore.
899
+ journal({
900
+ kind: "snapshot",
901
+ ok: true,
902
+ version: release.version,
903
+ gitHead: release.gitHead,
904
+ unit: deps.env["OMP_CONDUCTOR_UNIT"],
905
+ previous: {
906
+ cliVersion: surfaces.cliVersion,
907
+ ompVersion: surfaces.ompVersion,
908
+ herdrSource: surfaces.herdrSource,
909
+ },
910
+ ...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
911
+ pauseKey: scope.pauseKey,
912
+ initialPaused: initial.paused,
913
+ initial: {
914
+ dispatch: initial.dispatch,
915
+ ticks: initial.ticks,
916
+ pane: initial.pane,
917
+ herdr: initial.herdr,
918
+ },
919
+ selectors: scope.selectors.map((s) => s ?? null),
920
+ });
921
+ }
922
+
799
923
  deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
800
924
  if (!initial.paused) {
801
925
  deps.log("safety: pausing new issue claims");
802
926
  deps.setPaused(true, scope.pauseKey);
803
927
  }
928
+ journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
804
929
  deps.log("drain: waiting for live omp worker sessions");
805
930
  try {
806
931
  await waitForDrain(deps, scope);
807
932
  } catch (err) {
933
+ journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
808
934
  const failure = err instanceof Error ? err.message : String(err);
809
935
  throw new Error(
810
936
  `upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
811
937
  );
812
938
  }
939
+ journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
813
940
  deps.log("drain: complete");
814
941
 
815
942
  let installTouched = false;
@@ -821,8 +948,10 @@ export async function upgradeConductor(
821
948
  installTouched = true;
822
949
  deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
823
950
  await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
951
+ journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
824
952
  deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
825
953
  await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
954
+ journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
826
955
  if (surfaces.herdrSource?.startsWith("local:")) {
827
956
  await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
828
957
  }
@@ -835,6 +964,7 @@ export async function upgradeConductor(
835
964
  release.gitHead,
836
965
  "--yes",
837
966
  ]);
967
+ journal({ kind: "phase", phase: "install", surface: "herdr", ok: true, version: release.version, gitHead: release.gitHead });
838
968
  } else {
839
969
  deps.log("install: CLI, omp plugin, and Herdr plugin already pinned to the target release");
840
970
  }
@@ -842,16 +972,40 @@ export async function upgradeConductor(
842
972
  deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
843
973
  briefChanged = true;
844
974
  await upgradeBriefs(deps, briefs);
975
+ journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
845
976
 
846
977
  if (initial.herdr === "active") {
847
978
  herdrReloadStarted = true;
848
979
  deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
849
980
  await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
981
+ journal({ kind: "phase", phase: "restart", surface: "herdr", ok: true, version: release.version });
850
982
  }
851
983
  if (initial.dispatch !== "stopped") {
852
984
  daemonReloadStarted = true;
853
985
  deps.log("reload: restarting the omp-conductor dispatch daemon");
854
986
  await deps.restartDaemon();
987
+ journal({ kind: "phase", phase: "restart", surface: "daemon", ok: true, version: release.version });
988
+ }
989
+
990
+ if (detached) {
991
+ // The installing process does not declare its own success: it journals
992
+ // that the transaction reached the reloads and stops, leaving dispatch
993
+ // paused exactly where it was. The first tick of the post-restart daemon
994
+ // verifies against this journal and is the only witness that closes the
995
+ // transaction (#486).
996
+ journal({
997
+ kind: "phase",
998
+ phase: "awaiting-verification",
999
+ ok: true,
1000
+ version: release.version,
1001
+ detail: "installed; the first tick after the restart verifies and restores dispatch",
1002
+ });
1003
+ return {
1004
+ previousVersion: surfaces.cliVersion,
1005
+ ...release,
1006
+ alreadyCurrent: false,
1007
+ dispatch: initial.dispatch,
1008
+ };
855
1009
  }
856
1010
 
857
1011
  deps.log("verify 1/2: package identities, services, pane, ticks, and paused dispatch");
@@ -885,10 +1039,21 @@ export async function upgradeConductor(
885
1039
  deps.setPaused(true, scope.pauseKey);
886
1040
  }
887
1041
  const failure = err instanceof Error ? err.message : String(err);
1042
+ journal({
1043
+ kind: "phase",
1044
+ phase: "failed",
1045
+ ok: false,
1046
+ version: release.version,
1047
+ detail: failure,
1048
+ });
888
1049
  deps.log(`upgrade failed: ${failure}`);
1050
+ const journalLog = (message: string): void => {
1051
+ journal({ kind: "phase", phase: "rollback", ok: true, version: release.version, detail: message });
1052
+ deps.log(message);
1053
+ };
889
1054
  try {
890
1055
  await rollbackUpgrade(
891
- deps,
1056
+ { ...deps, log: journalLog },
892
1057
  surfaces,
893
1058
  scope,
894
1059
  installTouched,
@@ -898,8 +1063,35 @@ export async function upgradeConductor(
898
1063
  configBefore,
899
1064
  preUpgradeBackup,
900
1065
  );
1066
+ journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
1067
+ enqueueUpgradeReport(
1068
+ "tier2",
1069
+ reportProject(scope.selectors),
1070
+ `fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
1071
+ [
1072
+ `Reason: ${failure}`,
1073
+ `Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
1074
+ `herdr=${surfaces.herdrSource ?? "missing"}`,
1075
+ ...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
1076
+ "Dispatch stays paused until the first tick after the restart records the rollback verified.",
1077
+ ],
1078
+ `upgrade:${release.version}:rolled-back`,
1079
+ );
901
1080
  } catch (rollbackErr) {
902
1081
  const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1082
+ journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
1083
+ enqueueUpgradeReport(
1084
+ "tier2",
1085
+ reportProject(scope.selectors),
1086
+ `upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
1087
+ [
1088
+ `Failure: ${failure}`,
1089
+ `Rollback failure: ${rollback}`,
1090
+ "The fleet may be at mixed versions; do not resume dispatch.",
1091
+ "Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
1092
+ ],
1093
+ `upgrade:${release.version}:rollback-failed`,
1094
+ );
903
1095
  throw new Error(
904
1096
  `upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
905
1097
  );
@@ -914,3 +1106,138 @@ export async function upgradeConductor(
914
1106
  dispatch: initial.dispatch,
915
1107
  };
916
1108
  }
1109
+
1110
+ // ---------------------------------------------------------------------------
1111
+ // The detached fleet installer (#486)
1112
+ // ---------------------------------------------------------------------------
1113
+
1114
+ /**
1115
+ * Run the upgrade transaction as the detached fleet installer: `upgrade`'s
1116
+ * engine with a journal wired to the state-dir journal and the in-process
1117
+ * verification handed to the first tick after the restart. This is the
1118
+ * `upgrade-install` command the transient unit runs — the one process a
1119
+ * `systemctl restart herdr-fleet.service` and a daemon restart both pass by.
1120
+ */
1121
+ export async function runDetachedUpgrade(options: UpgradeOptions = {}): Promise<UpgradeResult> {
1122
+ return upgradeConductor(
1123
+ { ...options, detached: true },
1124
+ {
1125
+ journal: (entry) => appendJournal(stateDir(), entry),
1126
+ },
1127
+ );
1128
+ }
1129
+
1130
+ // ---------------------------------------------------------------------------
1131
+ // The detached rollback unit (#486)
1132
+ // ---------------------------------------------------------------------------
1133
+
1134
+ export interface RollbackFromJournalResult {
1135
+ /** The version the rollback restored the fleet to. */
1136
+ restoredVersion: string;
1137
+ /** The version the failed upgrade had targeted. */
1138
+ failedVersion: string;
1139
+ }
1140
+
1141
+ /**
1142
+ * Restore every surface the failed upgrade touched, from the journal the
1143
+ * detached installer wrote. This is the `upgrade-rollback` command the
1144
+ * verifier's transient unit runs — the same trust boundary as the install:
1145
+ * the process that rolls back is not the process that failed, and it acts
1146
+ * only on identities the journal recorded before the first install touched
1147
+ * anything.
1148
+ *
1149
+ * Scoped to the newest request's progression: an older, already-closed
1150
+ * upgrade in the same append-only journal neither supplies the snapshot to
1151
+ * restore nor the phases that say which surfaces were touched, and its
1152
+ * terminal outcome cannot refuse this rollback.
1153
+ */
1154
+ export async function rollbackFromJournal(
1155
+ overrides: Partial<UpgradeDeps> = {},
1156
+ root = stateDir(),
1157
+ ): Promise<RollbackFromJournalResult> {
1158
+ const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
1159
+ const entries = readUpgradeJournal(root);
1160
+ const progression = requestProgression(entries);
1161
+ const request = pendingUpgradeRequest(entries);
1162
+ if (progression === undefined || request === undefined) {
1163
+ throw new Error("no upgrade request is journaled; nothing to roll back");
1164
+ }
1165
+ const snapshot = progression.find((entry) => entry.kind === "snapshot");
1166
+ if (snapshot === undefined || snapshot.previous === undefined) {
1167
+ throw new Error(
1168
+ `upgrade ${request.version} has no durable pre-install snapshot; refusing to guess what to restore`,
1169
+ );
1170
+ }
1171
+ const newestOutcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
1172
+ if (newestOutcome !== undefined && newestOutcome.phase !== "rollback-requested") {
1173
+ // Already terminal (verified, rolled back in-process, aborted). A second
1174
+ // rollback would fight the newer outcome.
1175
+ throw new Error(
1176
+ `upgrade ${request.version} already closed as "${newestOutcome.phase}"; nothing to roll back`,
1177
+ );
1178
+ }
1179
+
1180
+ const phases = progression.filter((entry) => entry.kind === "phase");
1181
+ const scope: UpgradeScope = {
1182
+ selectors: request.selectors,
1183
+ pauseKey: request.pauseKey,
1184
+ };
1185
+ const previous: InstalledSurfaces = {
1186
+ cliVersion: snapshot.previous.cliVersion,
1187
+ ompVersion: snapshot.previous.ompVersion,
1188
+ herdrSource: snapshot.previous.herdrSource,
1189
+ };
1190
+ let configBefore: string | undefined;
1191
+ if (request.configBackup !== undefined && existsSync(request.configBackup)) {
1192
+ try {
1193
+ configBefore = readFileSync(request.configBackup, "utf8");
1194
+ } catch {
1195
+ configBefore = undefined;
1196
+ }
1197
+ }
1198
+ const journalLog = (message: string): void => {
1199
+ appendJournal(root, {
1200
+ at: new Date().toISOString(),
1201
+ kind: "phase",
1202
+ phase: "rollback",
1203
+ ok: true,
1204
+ version: request.version,
1205
+ detail: message,
1206
+ });
1207
+ process.stdout.write(`${message}\n`);
1208
+ };
1209
+ await rollbackUpgrade(
1210
+ { ...deps, log: journalLog },
1211
+ previous,
1212
+ scope,
1213
+ phases.some((entry) => entry.phase === "install"),
1214
+ phases.some((entry) => entry.phase === "brief"),
1215
+ phases.some((entry) => entry.phase === "restart" && entry.surface === "herdr"),
1216
+ phases.some((entry) => entry.phase === "restart" && entry.surface === "daemon"),
1217
+ configBefore,
1218
+ request.configBackup,
1219
+ );
1220
+
1221
+ const restoredVersion = previous.cliVersion;
1222
+ appendJournal(root, {
1223
+ at: new Date().toISOString(),
1224
+ kind: "outcome",
1225
+ phase: "rolled-back",
1226
+ ok: false,
1227
+ version: request.version,
1228
+ detail: `restored ${previous.cliVersion} from the pre-install snapshot`,
1229
+ });
1230
+ enqueueUpgradeReport(
1231
+ "tier2",
1232
+ reportProject(request.selectors),
1233
+ `fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
1234
+ [
1235
+ "The transient rollback unit restored every surface from the durable snapshot;",
1236
+ "dispatch stays paused until the first tick after these restarts verifies the old version.",
1237
+ `Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
1238
+ ],
1239
+ `upgrade:${request.version}:rolled-back`,
1240
+ );
1241
+ return { restoredVersion, failedVersion: request.version };
1242
+ }
1243
+