omp-conductor 0.15.9 → 0.15.11
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.
- package/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- 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
|
-
|
|
85
|
-
|
|
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,
|
|
@@ -576,7 +586,10 @@ async function upgradeBriefs(deps: UpgradeDeps, briefs: readonly ScopedBrief[]):
|
|
|
576
586
|
}
|
|
577
587
|
}
|
|
578
588
|
|
|
579
|
-
|
|
589
|
+
/** Restore every surface the upgrade touched, in reverse, from the identities
|
|
590
|
+
* captured before the first install. Exported so the detached rollback unit
|
|
591
|
+
* can restore what the detached installer recorded (#486). */
|
|
592
|
+
export async function rollbackUpgrade(
|
|
580
593
|
deps: UpgradeDeps,
|
|
581
594
|
previous: InstalledSurfaces,
|
|
582
595
|
scope: UpgradeScope,
|
|
@@ -736,6 +749,10 @@ export async function upgradeConductor(
|
|
|
736
749
|
overrides: Partial<UpgradeDeps> = {},
|
|
737
750
|
): Promise<UpgradeResult> {
|
|
738
751
|
const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
|
|
752
|
+
const detached = options.detached === true;
|
|
753
|
+
const journal = (entry: Omit<UpgradeJournalEntry, "at">, at = new Date()): void => {
|
|
754
|
+
deps.journal?.({ ...entry, at: at.toISOString() });
|
|
755
|
+
};
|
|
739
756
|
if (deps.env["HERDR_ENV"] !== undefined) {
|
|
740
757
|
throw new Error("run omp-conductor upgrade from a shell outside the target Herdr session");
|
|
741
758
|
}
|
|
@@ -748,6 +765,18 @@ export async function upgradeConductor(
|
|
|
748
765
|
const release = parseRegistry(
|
|
749
766
|
(await mustRun(deps, "npm", ["view", requested, "version", "gitHead", "--json"])).stdout,
|
|
750
767
|
);
|
|
768
|
+
if (detached) {
|
|
769
|
+
// The durable request: written before anything can pause, so a unit that
|
|
770
|
+
// dies after this line leaves a journal the returning process can act on
|
|
771
|
+
// (roll back, resume, or report) instead of a paused fleet with no record.
|
|
772
|
+
journal({
|
|
773
|
+
kind: "request",
|
|
774
|
+
ok: true,
|
|
775
|
+
version: release.version,
|
|
776
|
+
gitHead: release.gitHead,
|
|
777
|
+
unit: deps.env["OMP_CONDUCTOR_UNIT"],
|
|
778
|
+
});
|
|
779
|
+
}
|
|
751
780
|
const initial = deps.layers(scope.pauseKey);
|
|
752
781
|
const surfaces = await inspectSurfaces(deps);
|
|
753
782
|
const briefs: ScopedBrief[] = scope.selectors.map((selector) => ({
|
|
@@ -756,6 +785,15 @@ export async function upgradeConductor(
|
|
|
756
785
|
}));
|
|
757
786
|
const installNeeded = !surfacesCurrent(surfaces, release.version, release.gitHead);
|
|
758
787
|
if (!installNeeded && briefs.every((b) => b.current)) {
|
|
788
|
+
if (detached) {
|
|
789
|
+
journal({
|
|
790
|
+
kind: "outcome",
|
|
791
|
+
phase: "already-current",
|
|
792
|
+
ok: true,
|
|
793
|
+
version: release.version,
|
|
794
|
+
detail: "all three surfaces and every brief were already pinned to the target release",
|
|
795
|
+
});
|
|
796
|
+
}
|
|
759
797
|
return {
|
|
760
798
|
previousVersion: surfaces.cliVersion,
|
|
761
799
|
...release,
|
|
@@ -796,20 +834,51 @@ export async function upgradeConductor(
|
|
|
796
834
|
}
|
|
797
835
|
if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
|
|
798
836
|
|
|
837
|
+
if (detached) {
|
|
838
|
+
// The durable record the returning process rolls back or verifies against:
|
|
839
|
+
// what was installed, what dispatch state the fleet began in, which pause
|
|
840
|
+
// sentinel this transaction owns, and the config snapshot for the restore.
|
|
841
|
+
journal({
|
|
842
|
+
kind: "snapshot",
|
|
843
|
+
ok: true,
|
|
844
|
+
version: release.version,
|
|
845
|
+
gitHead: release.gitHead,
|
|
846
|
+
unit: deps.env["OMP_CONDUCTOR_UNIT"],
|
|
847
|
+
previous: {
|
|
848
|
+
cliVersion: surfaces.cliVersion,
|
|
849
|
+
ompVersion: surfaces.ompVersion,
|
|
850
|
+
herdrSource: surfaces.herdrSource,
|
|
851
|
+
},
|
|
852
|
+
...(preUpgradeBackup === undefined ? {} : { configBackup: preUpgradeBackup }),
|
|
853
|
+
pauseKey: scope.pauseKey,
|
|
854
|
+
initialPaused: initial.paused,
|
|
855
|
+
initial: {
|
|
856
|
+
dispatch: initial.dispatch,
|
|
857
|
+
ticks: initial.ticks,
|
|
858
|
+
pane: initial.pane,
|
|
859
|
+
herdr: initial.herdr,
|
|
860
|
+
},
|
|
861
|
+
selectors: scope.selectors.map((s) => s ?? null),
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
799
865
|
deps.log(`target release: omp-conductor@${release.version} (${release.gitHead})`);
|
|
800
866
|
if (!initial.paused) {
|
|
801
867
|
deps.log("safety: pausing new issue claims");
|
|
802
868
|
deps.setPaused(true, scope.pauseKey);
|
|
803
869
|
}
|
|
870
|
+
journal({ kind: "phase", phase: "paused", surface: "dispatch", ok: true, version: release.version });
|
|
804
871
|
deps.log("drain: waiting for live omp worker sessions");
|
|
805
872
|
try {
|
|
806
873
|
await waitForDrain(deps, scope);
|
|
807
874
|
} catch (err) {
|
|
875
|
+
journal({ kind: "phase", phase: "drain", ok: false, version: release.version, detail: err instanceof Error ? err.message : String(err) });
|
|
808
876
|
const failure = err instanceof Error ? err.message : String(err);
|
|
809
877
|
throw new Error(
|
|
810
878
|
`upgrade failed while draining workers: ${failure}; no installation started; dispatch remains paused`,
|
|
811
879
|
);
|
|
812
880
|
}
|
|
881
|
+
journal({ kind: "phase", phase: "drain", ok: true, version: release.version });
|
|
813
882
|
deps.log("drain: complete");
|
|
814
883
|
|
|
815
884
|
let installTouched = false;
|
|
@@ -821,8 +890,10 @@ export async function upgradeConductor(
|
|
|
821
890
|
installTouched = true;
|
|
822
891
|
deps.log(`install 1/3: Bun-global omp-conductor CLI → ${release.version}`);
|
|
823
892
|
await mustRun(deps, "bun", ["add", "-g", `${PACKAGE}@${release.version}`]);
|
|
893
|
+
journal({ kind: "phase", phase: "install", surface: "cli", ok: true, version: release.version, gitHead: release.gitHead });
|
|
824
894
|
deps.log(`install 2/3: omp plugin omp-conductor → ${release.version}`);
|
|
825
895
|
await mustRun(deps, "omp", ["plugin", "install", `${PACKAGE}@${release.version}`]);
|
|
896
|
+
journal({ kind: "phase", phase: "install", surface: "omp", ok: true, version: release.version, gitHead: release.gitHead });
|
|
826
897
|
if (surfaces.herdrSource?.startsWith("local:")) {
|
|
827
898
|
await mustRun(deps, "herdr", ["plugin", "unlink", HERDR_PLUGIN]);
|
|
828
899
|
}
|
|
@@ -835,6 +906,7 @@ export async function upgradeConductor(
|
|
|
835
906
|
release.gitHead,
|
|
836
907
|
"--yes",
|
|
837
908
|
]);
|
|
909
|
+
journal({ kind: "phase", phase: "install", surface: "herdr", ok: true, version: release.version, gitHead: release.gitHead });
|
|
838
910
|
} else {
|
|
839
911
|
deps.log("install: CLI, omp plugin, and Herdr plugin already pinned to the target release");
|
|
840
912
|
}
|
|
@@ -842,16 +914,40 @@ export async function upgradeConductor(
|
|
|
842
914
|
deps.log("brief: refreshing managed ORCHESTRATOR.md floor while preserving POLICY.md");
|
|
843
915
|
briefChanged = true;
|
|
844
916
|
await upgradeBriefs(deps, briefs);
|
|
917
|
+
journal({ kind: "phase", phase: "brief", surface: "brief", ok: true, version: release.version });
|
|
845
918
|
|
|
846
919
|
if (initial.herdr === "active") {
|
|
847
920
|
herdrReloadStarted = true;
|
|
848
921
|
deps.log("reload: restarting herdr-fleet.service and recovering the orchestrator pane");
|
|
849
922
|
await mustRun(deps, "systemctl", ["restart", HERDR_UNIT]);
|
|
923
|
+
journal({ kind: "phase", phase: "restart", surface: "herdr", ok: true, version: release.version });
|
|
850
924
|
}
|
|
851
925
|
if (initial.dispatch !== "stopped") {
|
|
852
926
|
daemonReloadStarted = true;
|
|
853
927
|
deps.log("reload: restarting the omp-conductor dispatch daemon");
|
|
854
928
|
await deps.restartDaemon();
|
|
929
|
+
journal({ kind: "phase", phase: "restart", surface: "daemon", ok: true, version: release.version });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (detached) {
|
|
933
|
+
// The installing process does not declare its own success: it journals
|
|
934
|
+
// that the transaction reached the reloads and stops, leaving dispatch
|
|
935
|
+
// paused exactly where it was. The first tick of the post-restart daemon
|
|
936
|
+
// verifies against this journal and is the only witness that closes the
|
|
937
|
+
// transaction (#486).
|
|
938
|
+
journal({
|
|
939
|
+
kind: "phase",
|
|
940
|
+
phase: "awaiting-verification",
|
|
941
|
+
ok: true,
|
|
942
|
+
version: release.version,
|
|
943
|
+
detail: "installed; the first tick after the restart verifies and restores dispatch",
|
|
944
|
+
});
|
|
945
|
+
return {
|
|
946
|
+
previousVersion: surfaces.cliVersion,
|
|
947
|
+
...release,
|
|
948
|
+
alreadyCurrent: false,
|
|
949
|
+
dispatch: initial.dispatch,
|
|
950
|
+
};
|
|
855
951
|
}
|
|
856
952
|
|
|
857
953
|
deps.log("verify 1/2: package identities, services, pane, ticks, and paused dispatch");
|
|
@@ -885,10 +981,21 @@ export async function upgradeConductor(
|
|
|
885
981
|
deps.setPaused(true, scope.pauseKey);
|
|
886
982
|
}
|
|
887
983
|
const failure = err instanceof Error ? err.message : String(err);
|
|
984
|
+
journal({
|
|
985
|
+
kind: "phase",
|
|
986
|
+
phase: "failed",
|
|
987
|
+
ok: false,
|
|
988
|
+
version: release.version,
|
|
989
|
+
detail: failure,
|
|
990
|
+
});
|
|
888
991
|
deps.log(`upgrade failed: ${failure}`);
|
|
992
|
+
const journalLog = (message: string): void => {
|
|
993
|
+
journal({ kind: "phase", phase: "rollback", ok: true, version: release.version, detail: message });
|
|
994
|
+
deps.log(message);
|
|
995
|
+
};
|
|
889
996
|
try {
|
|
890
997
|
await rollbackUpgrade(
|
|
891
|
-
deps,
|
|
998
|
+
{ ...deps, log: journalLog },
|
|
892
999
|
surfaces,
|
|
893
1000
|
scope,
|
|
894
1001
|
installTouched,
|
|
@@ -898,8 +1005,35 @@ export async function upgradeConductor(
|
|
|
898
1005
|
configBefore,
|
|
899
1006
|
preUpgradeBackup,
|
|
900
1007
|
);
|
|
1008
|
+
journal({ kind: "outcome", phase: "rolled-back", ok: false, version: release.version, detail: failure });
|
|
1009
|
+
enqueueUpgradeReport(
|
|
1010
|
+
"tier2",
|
|
1011
|
+
reportProject(scope.selectors),
|
|
1012
|
+
`fleet upgrade to omp-conductor@${release.version} failed and was rolled back`,
|
|
1013
|
+
[
|
|
1014
|
+
`Reason: ${failure}`,
|
|
1015
|
+
`Restored: cli=${surfaces.cliVersion}, omp=${surfaces.ompVersion ?? "missing"}, ` +
|
|
1016
|
+
`herdr=${surfaces.herdrSource ?? "missing"}`,
|
|
1017
|
+
...(preUpgradeBackup === undefined ? [] : [`Pre-upgrade config snapshot: ${preUpgradeBackup}`]),
|
|
1018
|
+
"Dispatch stays paused until the first tick after the restart records the rollback verified.",
|
|
1019
|
+
],
|
|
1020
|
+
`upgrade:${release.version}:rolled-back`,
|
|
1021
|
+
);
|
|
901
1022
|
} catch (rollbackErr) {
|
|
902
1023
|
const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
1024
|
+
journal({ kind: "outcome", phase: "rollback-failed", ok: false, version: release.version, detail: rollback });
|
|
1025
|
+
enqueueUpgradeReport(
|
|
1026
|
+
"tier2",
|
|
1027
|
+
reportProject(scope.selectors),
|
|
1028
|
+
`upgrade to omp-conductor@${release.version} failed and ITS ROLLBACK ALSO FAILED`,
|
|
1029
|
+
[
|
|
1030
|
+
`Failure: ${failure}`,
|
|
1031
|
+
`Rollback failure: ${rollback}`,
|
|
1032
|
+
"The fleet may be at mixed versions; do not resume dispatch.",
|
|
1033
|
+
"Restore manually from the journal at " + upgradeJournalPath(stateDir()) + ".",
|
|
1034
|
+
],
|
|
1035
|
+
`upgrade:${release.version}:rollback-failed`,
|
|
1036
|
+
);
|
|
903
1037
|
throw new Error(
|
|
904
1038
|
`upgrade failed: ${failure}; rollback incomplete: ${rollback}; dispatch remains paused`,
|
|
905
1039
|
);
|
|
@@ -914,3 +1048,138 @@ export async function upgradeConductor(
|
|
|
914
1048
|
dispatch: initial.dispatch,
|
|
915
1049
|
};
|
|
916
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
// ---------------------------------------------------------------------------
|
|
1053
|
+
// The detached fleet installer (#486)
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Run the upgrade transaction as the detached fleet installer: `upgrade`'s
|
|
1058
|
+
* engine with a journal wired to the state-dir journal and the in-process
|
|
1059
|
+
* verification handed to the first tick after the restart. This is the
|
|
1060
|
+
* `upgrade-install` command the transient unit runs — the one process a
|
|
1061
|
+
* `systemctl restart herdr-fleet.service` and a daemon restart both pass by.
|
|
1062
|
+
*/
|
|
1063
|
+
export async function runDetachedUpgrade(options: UpgradeOptions = {}): Promise<UpgradeResult> {
|
|
1064
|
+
return upgradeConductor(
|
|
1065
|
+
{ ...options, detached: true },
|
|
1066
|
+
{
|
|
1067
|
+
journal: (entry) => appendJournal(stateDir(), entry),
|
|
1068
|
+
},
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
// The detached rollback unit (#486)
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
export interface RollbackFromJournalResult {
|
|
1077
|
+
/** The version the rollback restored the fleet to. */
|
|
1078
|
+
restoredVersion: string;
|
|
1079
|
+
/** The version the failed upgrade had targeted. */
|
|
1080
|
+
failedVersion: string;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Restore every surface the failed upgrade touched, from the journal the
|
|
1085
|
+
* detached installer wrote. This is the `upgrade-rollback` command the
|
|
1086
|
+
* verifier's transient unit runs — the same trust boundary as the install:
|
|
1087
|
+
* the process that rolls back is not the process that failed, and it acts
|
|
1088
|
+
* only on identities the journal recorded before the first install touched
|
|
1089
|
+
* anything.
|
|
1090
|
+
*
|
|
1091
|
+
* Scoped to the newest request's progression: an older, already-closed
|
|
1092
|
+
* upgrade in the same append-only journal neither supplies the snapshot to
|
|
1093
|
+
* restore nor the phases that say which surfaces were touched, and its
|
|
1094
|
+
* terminal outcome cannot refuse this rollback.
|
|
1095
|
+
*/
|
|
1096
|
+
export async function rollbackFromJournal(
|
|
1097
|
+
overrides: Partial<UpgradeDeps> = {},
|
|
1098
|
+
root = stateDir(),
|
|
1099
|
+
): Promise<RollbackFromJournalResult> {
|
|
1100
|
+
const deps: UpgradeDeps = { ...DEFAULT_DEPS, ...overrides };
|
|
1101
|
+
const entries = readUpgradeJournal(root);
|
|
1102
|
+
const progression = requestProgression(entries);
|
|
1103
|
+
const request = pendingUpgradeRequest(entries);
|
|
1104
|
+
if (progression === undefined || request === undefined) {
|
|
1105
|
+
throw new Error("no upgrade request is journaled; nothing to roll back");
|
|
1106
|
+
}
|
|
1107
|
+
const snapshot = progression.find((entry) => entry.kind === "snapshot");
|
|
1108
|
+
if (snapshot === undefined || snapshot.previous === undefined) {
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
`upgrade ${request.version} has no durable pre-install snapshot; refusing to guess what to restore`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
const newestOutcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
|
|
1114
|
+
if (newestOutcome !== undefined && newestOutcome.phase !== "rollback-requested") {
|
|
1115
|
+
// Already terminal (verified, rolled back in-process, aborted). A second
|
|
1116
|
+
// rollback would fight the newer outcome.
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
`upgrade ${request.version} already closed as "${newestOutcome.phase}"; nothing to roll back`,
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const phases = progression.filter((entry) => entry.kind === "phase");
|
|
1123
|
+
const scope: UpgradeScope = {
|
|
1124
|
+
selectors: request.selectors,
|
|
1125
|
+
pauseKey: request.pauseKey,
|
|
1126
|
+
};
|
|
1127
|
+
const previous: InstalledSurfaces = {
|
|
1128
|
+
cliVersion: snapshot.previous.cliVersion,
|
|
1129
|
+
ompVersion: snapshot.previous.ompVersion,
|
|
1130
|
+
herdrSource: snapshot.previous.herdrSource,
|
|
1131
|
+
};
|
|
1132
|
+
let configBefore: string | undefined;
|
|
1133
|
+
if (request.configBackup !== undefined && existsSync(request.configBackup)) {
|
|
1134
|
+
try {
|
|
1135
|
+
configBefore = readFileSync(request.configBackup, "utf8");
|
|
1136
|
+
} catch {
|
|
1137
|
+
configBefore = undefined;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const journalLog = (message: string): void => {
|
|
1141
|
+
appendJournal(root, {
|
|
1142
|
+
at: new Date().toISOString(),
|
|
1143
|
+
kind: "phase",
|
|
1144
|
+
phase: "rollback",
|
|
1145
|
+
ok: true,
|
|
1146
|
+
version: request.version,
|
|
1147
|
+
detail: message,
|
|
1148
|
+
});
|
|
1149
|
+
process.stdout.write(`${message}\n`);
|
|
1150
|
+
};
|
|
1151
|
+
await rollbackUpgrade(
|
|
1152
|
+
{ ...deps, log: journalLog },
|
|
1153
|
+
previous,
|
|
1154
|
+
scope,
|
|
1155
|
+
phases.some((entry) => entry.phase === "install"),
|
|
1156
|
+
phases.some((entry) => entry.phase === "brief"),
|
|
1157
|
+
phases.some((entry) => entry.phase === "restart" && entry.surface === "herdr"),
|
|
1158
|
+
phases.some((entry) => entry.phase === "restart" && entry.surface === "daemon"),
|
|
1159
|
+
configBefore,
|
|
1160
|
+
request.configBackup,
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1163
|
+
const restoredVersion = previous.cliVersion;
|
|
1164
|
+
appendJournal(root, {
|
|
1165
|
+
at: new Date().toISOString(),
|
|
1166
|
+
kind: "outcome",
|
|
1167
|
+
phase: "rolled-back",
|
|
1168
|
+
ok: false,
|
|
1169
|
+
version: request.version,
|
|
1170
|
+
detail: `restored ${previous.cliVersion} from the pre-install snapshot`,
|
|
1171
|
+
});
|
|
1172
|
+
enqueueUpgradeReport(
|
|
1173
|
+
"tier2",
|
|
1174
|
+
reportProject(request.selectors),
|
|
1175
|
+
`fleet restored to omp-conductor@${restoredVersion} after the failed upgrade to ${request.version}`,
|
|
1176
|
+
[
|
|
1177
|
+
"The transient rollback unit restored every surface from the durable snapshot;",
|
|
1178
|
+
"dispatch stays paused until the first tick after these restarts verifies the old version.",
|
|
1179
|
+
`Pre-upgrade config snapshot: ${request.configBackup ?? "none recorded"}`,
|
|
1180
|
+
],
|
|
1181
|
+
`upgrade:${request.version}:rolled-back`,
|
|
1182
|
+
);
|
|
1183
|
+
return { restoredVersion, failedVersion: request.version };
|
|
1184
|
+
}
|
|
1185
|
+
|
package/src/verbs/actions.ts
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts";
|
|
18
|
+
import { livingDaemon } from "../lifecycle.ts";
|
|
19
|
+
import { stateDir } from "../config.ts";
|
|
18
20
|
import { ensureMirror } from "../worktree.ts";
|
|
21
|
+
import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts";
|
|
22
|
+
import { classifyUpgrade } from "../upgrade-verify.ts";
|
|
19
23
|
import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts";
|
|
20
|
-
import type { ActionOutcome, ReleaseExecution, VerbActions } from "./server.ts";
|
|
24
|
+
import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* What this daemon can actually cut.
|
|
@@ -779,5 +783,73 @@ export function githubVerbActions(
|
|
|
779
783
|
}
|
|
780
784
|
: failed(pushed.result, pushed.argv);
|
|
781
785
|
},
|
|
786
|
+
|
|
787
|
+
// The fleet-installs-itself request (#486). Two gates, then a spawn:
|
|
788
|
+
//
|
|
789
|
+
// 1. `npm view` must actually expose the version with a full gitHead —
|
|
790
|
+
// "install it" may only name what npm can serve. Refused with npm's
|
|
791
|
+
// own detail so the orchestrator learns why, not that something was
|
|
792
|
+
// wrong.
|
|
793
|
+
// 2. A live daemon must exist to verify the install on its first tick
|
|
794
|
+
// after the restart. A stopped daemon can never come back to do that,
|
|
795
|
+
// so the request is refused and the operator is pointed at the plain
|
|
796
|
+
// `upgrade` command, whose own process is its witness.
|
|
797
|
+
install: async (execution: InstallExecution): Promise<ActionOutcome> => {
|
|
798
|
+
if (livingDaemon() === undefined) {
|
|
799
|
+
return {
|
|
800
|
+
ok: false,
|
|
801
|
+
stderr:
|
|
802
|
+
"the dispatch daemon is not running, so nobody can verify this install after the restart " +
|
|
803
|
+
"it performs. Run `omp-conductor upgrade` from a shell instead.",
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
// One install at a time: the journal's newest request is either closed or
|
|
807
|
+
// in flight, and a second request while one is in flight would race it
|
|
808
|
+
// for the same restarts. The refusal names the state so the caller knows
|
|
809
|
+
// to wait for the verifier's outcome instead of retrying blind.
|
|
810
|
+
const pending = classifyUpgrade(readUpgradeJournal(stateDir()));
|
|
811
|
+
if (pending.kind !== "none" && pending.kind !== "closed") {
|
|
812
|
+
return {
|
|
813
|
+
ok: false,
|
|
814
|
+
stderr:
|
|
815
|
+
`an install is already in flight (journal verdict: ${pending.kind}); ` +
|
|
816
|
+
"the fleet verifies or rolls it back before accepting another request.",
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const looked = await run(
|
|
820
|
+
["npm", "view", `omp-conductor@${execution.version}`, "version", "gitHead", "--json"],
|
|
821
|
+
{ env: env() },
|
|
822
|
+
);
|
|
823
|
+
if (!looked.ok) {
|
|
824
|
+
const detail = looked.stderr.trim() || looked.stdout.trim() || "the npm view command failed";
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
stderr:
|
|
828
|
+
`npm does not expose omp-conductor@${execution.version}: ${detail}. ` +
|
|
829
|
+
"Only a published release with a full gitHead can be installed; the request was not started.",
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const metadata = objectFromJson(looked.stdout);
|
|
833
|
+
const published = metadata === undefined ? undefined : String(metadata["version"] ?? "");
|
|
834
|
+
const gitHead = metadata === undefined ? undefined : String(metadata["gitHead"] ?? "");
|
|
835
|
+
if (published !== execution.version || !/^[0-9a-f]{40}$/i.test(gitHead ?? "")) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
stderr:
|
|
839
|
+
`npm does not expose omp-conductor@${execution.version} with a full gitHead ` +
|
|
840
|
+
`(${published === undefined ? "unreadable metadata" : `saw ${published}`}); the request was not started.`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const launched = await launchTransientUnit(
|
|
844
|
+
async (argv) => {
|
|
845
|
+
const ran = await run([...argv] as string[], { env: env() });
|
|
846
|
+
return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr };
|
|
847
|
+
},
|
|
848
|
+
env(),
|
|
849
|
+
{ kind: "install", version: execution.version },
|
|
850
|
+
);
|
|
851
|
+
if (!launched.ok) return { ok: false, stderr: launched.stderr };
|
|
852
|
+
return { ok: true, detail: launched.unit };
|
|
853
|
+
},
|
|
782
854
|
};
|
|
783
855
|
}
|
package/src/verbs/protocol.ts
CHANGED
|
@@ -288,15 +288,40 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
|
|
|
288
288
|
`release authority is the orchestrator's, and this is a ${role} session. ` +
|
|
289
289
|
"A worker session holds no release grant whatever the config says.",
|
|
290
290
|
},
|
|
291
|
+
conductor_install: {
|
|
292
|
+
name: "conductor_install",
|
|
293
|
+
mutating: true,
|
|
294
|
+
allowedRoles: ["orchestrator"],
|
|
295
|
+
description:
|
|
296
|
+
"Request the fleet upgrade itself to a published omp-conductor release (#486). The install " +
|
|
297
|
+
"shape gates it like a release shape and stays with the human by default; with the grant, " +
|
|
298
|
+
"the daemon validates the version against npm and starts a detached transient unit that " +
|
|
299
|
+
"pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session " +
|
|
300
|
+
"and the daemon. The unit never declares its own success: the first tick after the restart " +
|
|
301
|
+
"verifies version, /healthz, ticks, pane and doctor, and reports through the durable outbox.",
|
|
302
|
+
args: {
|
|
303
|
+
version: {
|
|
304
|
+
type: "string",
|
|
305
|
+
required: true,
|
|
306
|
+
description:
|
|
307
|
+
"The exact published semver to install (e.g. 0.15.11). The request is refused when npm " +
|
|
308
|
+
"does not expose it with a full gitHead.",
|
|
309
|
+
},
|
|
310
|
+
rationale: RATIONALE_ARG,
|
|
311
|
+
},
|
|
312
|
+
roleRefusalText: (role) =>
|
|
313
|
+
`install authority is the orchestrator's, and this is a ${role} session. ` +
|
|
314
|
+
"A worker session holds no install grant whatever the config says.",
|
|
315
|
+
},
|
|
291
316
|
conductor_pr_status: {
|
|
292
317
|
name: "conductor_pr_status",
|
|
293
318
|
mutating: false,
|
|
294
319
|
allowedRoles: ["worker", "orchestrator"],
|
|
295
320
|
description:
|
|
296
|
-
"Read one pull request's live state and current head
|
|
297
|
-
"
|
|
298
|
-
"
|
|
299
|
-
"A worker may omit prUrl and gets its own run's.",
|
|
321
|
+
"Read one pull request's live state and current head — open, merged, or closed — and the checks " +
|
|
322
|
+
"available for it (checks are unavailable for a merged or closed PR). Optionally compare that head " +
|
|
323
|
+
"with one the caller already observed. This is the daemon's own merge-gate verdict, not a summary of it. " +
|
|
324
|
+
"A poll, not a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.",
|
|
300
325
|
args: {
|
|
301
326
|
prUrl: {
|
|
302
327
|
type: "string",
|