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
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable seam between the detached fleet installer and the daemon that
|
|
3
|
+
* verifies it (#486).
|
|
4
|
+
*
|
|
5
|
+
* `omp-conductor upgrade` has always been a transaction *inside* one process:
|
|
6
|
+
* it paused, drained, installed, reloaded and verified, and the process that
|
|
7
|
+
* verified was the process that restarted the fleet. The fleet-installs-itself
|
|
8
|
+
* path replaces that wrapper, not the transaction: a detached transient unit
|
|
9
|
+
* performs the install while `upgrade-journal.ts` records every surface it
|
|
10
|
+
* touches, and the first tick of the post-restart daemon verifies against the
|
|
11
|
+
* journal — a process that did not run the install, which is the only witness
|
|
12
|
+
* worth trusting after the restarts.
|
|
13
|
+
*
|
|
14
|
+
* This module is a leaf on purpose. The daemon's privileged tick and the
|
|
15
|
+
* upgrade engine both need the verifier, the request classification and the
|
|
16
|
+
* transient-unit naming, and they already import each other — `upgrade.ts`
|
|
17
|
+
* takes its pause helpers from `daemon.ts`, and the daemon runs the engine's
|
|
18
|
+
* rollback. Putting the shared seams here instead of importing `upgrade.ts`
|
|
19
|
+
* from the daemon keeps that graph acyclic; everything the verifier touches
|
|
20
|
+
* that belongs to a host state is injected through `UpgradeVerifyDeps`, so the
|
|
21
|
+
* only runtime imports are leaves (journal, config, store).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { loadConfig, stateDir } from "./config.ts";
|
|
25
|
+
import { DEFAULT_PORT } from "./lifecycle.ts";
|
|
26
|
+
import { dbPath, openStore } from "./store.ts";
|
|
27
|
+
import { appendJournal, readUpgradeJournal, upgradeJournalPath, type UpgradeCheck, type UpgradeJournalEntry } from "./upgrade-journal.ts";
|
|
28
|
+
import type { DoctorReport } from "./doctor.ts";
|
|
29
|
+
import type { FleetLayers } from "./fleet.ts";
|
|
30
|
+
import type { ReportDraft, ReportKind } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
/** One command run to completion with captured output. The transient unit, the
|
|
33
|
+
* post-restart verifier and the rollback unit all run processes this way —
|
|
34
|
+
* one named construction site, so the seam tests can inject it everywhere. */
|
|
35
|
+
export interface UpgradeCommandResult {
|
|
36
|
+
code: number;
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
|
|
42
|
+
try {
|
|
43
|
+
const child = Bun.spawn([command, ...args], {
|
|
44
|
+
stdin: "ignore",
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
stderr: "pipe",
|
|
47
|
+
env: process.env,
|
|
48
|
+
});
|
|
49
|
+
const stdout = new Response(child.stdout).text();
|
|
50
|
+
const stderr = new Response(child.stderr).text();
|
|
51
|
+
const code = await child.exited;
|
|
52
|
+
return { code, stdout: await stdout, stderr: await stderr };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The project a host-wide outcome report is filed under: the first in-scope
|
|
60
|
+
* project, falling back to the configured first project, then the constant.
|
|
61
|
+
*/
|
|
62
|
+
export function reportProject(selectors: readonly (string | undefined)[]): string {
|
|
63
|
+
for (const selector of selectors) {
|
|
64
|
+
if (selector !== undefined) return selector;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const projects = loadConfig().projects;
|
|
68
|
+
if (projects.length > 0) return projects[0]!.name;
|
|
69
|
+
} catch {
|
|
70
|
+
// no config: the fallback below stands
|
|
71
|
+
}
|
|
72
|
+
return "conductor";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hand a durable outbox row over in the detached context, where no tick is
|
|
77
|
+
* alive to do it. The daemon's report outbox owns delivery from here (bounded
|
|
78
|
+
* retries via the same store rows `omp-conductor report` writes), so a report
|
|
79
|
+
* survives the process that enqueued it. Refusal to queue is logged and lost
|
|
80
|
+
* — the journal on disk is the harder of the two records and is always kept.
|
|
81
|
+
*/
|
|
82
|
+
export function enqueueUpgradeReport(
|
|
83
|
+
kind: ReportKind,
|
|
84
|
+
project: string,
|
|
85
|
+
summary: string,
|
|
86
|
+
lines: string[],
|
|
87
|
+
dedupeKey: string,
|
|
88
|
+
): void {
|
|
89
|
+
try {
|
|
90
|
+
const store = openStore(dbPath());
|
|
91
|
+
try {
|
|
92
|
+
store.enqueueReport({
|
|
93
|
+
project,
|
|
94
|
+
kind,
|
|
95
|
+
body: [summary, "", ...lines].join("\n"),
|
|
96
|
+
at: Date.now(),
|
|
97
|
+
dedupeKey,
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
store.close();
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// logged by nothing: the journal carries the same outcome, and `status`
|
|
104
|
+
// surfaces the reports block. A full disk must not loop here.
|
|
105
|
+
process.stderr.write(`upgrade report could not be queued: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Classifying the journal
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** The state a pending request has reached, read off the journal. */
|
|
114
|
+
export type PendingUpgradeState =
|
|
115
|
+
| { kind: "none" }
|
|
116
|
+
| { kind: "closed"; phase: string }
|
|
117
|
+
| { kind: "no-start" }
|
|
118
|
+
| { kind: "awaiting-verification" }
|
|
119
|
+
| { kind: "incomplete" }
|
|
120
|
+
| { kind: "failed" };
|
|
121
|
+
|
|
122
|
+
/** Index of the newest request line, or -1 when none exists. */
|
|
123
|
+
function newestRequestIndex(entries: readonly UpgradeJournalEntry[]): number {
|
|
124
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
125
|
+
if (entries[i]!.kind === "request") return i;
|
|
126
|
+
}
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Classify one journal: no request, a request whose outcome is already
|
|
132
|
+
* terminal, or a request still in flight — with the phase evidence that says
|
|
133
|
+
* how far it got. An outcome is terminal: the verifier writes it last, after
|
|
134
|
+
* the durable report is already queued, so a later tick reads it and does
|
|
135
|
+
* nothing rather than re-reporting.
|
|
136
|
+
*
|
|
137
|
+
* The journal is append-only across upgrades, so the newest request is the one
|
|
138
|
+
* under test: an older request's terminal outcome must not close or shadow a
|
|
139
|
+
* newer request, and only the newest request's progression decides its state.
|
|
140
|
+
*/
|
|
141
|
+
export function classifyUpgrade(
|
|
142
|
+
entries: readonly UpgradeJournalEntry[],
|
|
143
|
+
): PendingUpgradeState {
|
|
144
|
+
const start = newestRequestIndex(entries);
|
|
145
|
+
if (start === -1) return { kind: "none" };
|
|
146
|
+
const progression = entries.slice(start);
|
|
147
|
+
const outcome = [...progression].reverse().find((entry) => entry.kind === "outcome");
|
|
148
|
+
if (outcome !== undefined) {
|
|
149
|
+
return { kind: "closed", phase: outcome.phase ?? "outcome" };
|
|
150
|
+
}
|
|
151
|
+
const phases = progression.filter((entry) => entry.kind === "phase");
|
|
152
|
+
if (phases.length === 0) return { kind: "no-start" };
|
|
153
|
+
if (phases.some((entry) => entry.phase === "awaiting-verification" && entry.ok)) {
|
|
154
|
+
return { kind: "awaiting-verification" };
|
|
155
|
+
}
|
|
156
|
+
if (phases.some((entry) => entry.ok === false)) {
|
|
157
|
+
return { kind: "failed" };
|
|
158
|
+
}
|
|
159
|
+
return { kind: "incomplete" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One pending request plus the snapshot its verifier needs. */
|
|
163
|
+
export interface PendingUpgradeRequest {
|
|
164
|
+
version: string;
|
|
165
|
+
gitHead?: string;
|
|
166
|
+
initialPaused?: boolean;
|
|
167
|
+
pauseKey?: string;
|
|
168
|
+
selectors: readonly (string | undefined)[];
|
|
169
|
+
initial?: UpgradeJournalEntry["initial"];
|
|
170
|
+
configBackup?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The newest request plus everything journaled after it: the view one
|
|
174
|
+
* transaction owns. Older closed upgrades stay in the journal but do not
|
|
175
|
+
* contribute identity or phases to the one in flight. */
|
|
176
|
+
export function requestProgression(
|
|
177
|
+
entries: readonly UpgradeJournalEntry[],
|
|
178
|
+
): readonly UpgradeJournalEntry[] | undefined {
|
|
179
|
+
const start = newestRequestIndex(entries);
|
|
180
|
+
if (start === -1) return undefined;
|
|
181
|
+
return entries.slice(start);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function pendingUpgradeRequest(
|
|
185
|
+
entries: readonly UpgradeJournalEntry[],
|
|
186
|
+
): PendingUpgradeRequest | undefined {
|
|
187
|
+
const progression = requestProgression(entries);
|
|
188
|
+
if (progression === undefined) return undefined;
|
|
189
|
+
const request = progression[0]!;
|
|
190
|
+
if (request.ok === false) return undefined;
|
|
191
|
+
const snapshot = progression.find((entry) => entry.kind === "snapshot");
|
|
192
|
+
return {
|
|
193
|
+
version: request.version ?? "unknown",
|
|
194
|
+
gitHead: request.gitHead,
|
|
195
|
+
initialPaused: snapshot?.initialPaused,
|
|
196
|
+
pauseKey: snapshot?.pauseKey,
|
|
197
|
+
selectors: (snapshot?.selectors ?? []).map((selector) => selector ?? undefined),
|
|
198
|
+
initial: snapshot?.initial,
|
|
199
|
+
configBackup: snapshot?.configBackup,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// The post-restart verifier
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Every injectable process/state seam the post-restart verifier touches.
|
|
209
|
+
*
|
|
210
|
+
* The verifier runs in the daemon, so everything that belongs to the daemon's
|
|
211
|
+
* live state (the pause sentinel, the layers, the outbox, the health probe)
|
|
212
|
+
* is injected rather than imported — the daemon wires its own functions, and
|
|
213
|
+
* a test drives the whole pass with fakes.
|
|
214
|
+
*/
|
|
215
|
+
export interface UpgradeVerifyDeps {
|
|
216
|
+
projectName: string;
|
|
217
|
+
/** The journal directory; defaults to the conductor state dir. */
|
|
218
|
+
journalRoot?: string;
|
|
219
|
+
/** Sink for journal lines; defaults to the state-dir journal. */
|
|
220
|
+
journal?: (entry: UpgradeJournalEntry) => void;
|
|
221
|
+
run: (command: string, args: readonly string[]) => Promise<UpgradeCommandResult>;
|
|
222
|
+
layers(project?: string): FleetLayers;
|
|
223
|
+
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
224
|
+
doctor(projectName: string): Promise<DoctorReport>;
|
|
225
|
+
/** The durable outbox enqueue — one row, daemon-owned delivery. */
|
|
226
|
+
enqueue(draft: ReportDraft): void;
|
|
227
|
+
/** Read one pause sentinel (who set it, when) — daemon-owned state. */
|
|
228
|
+
pause(project?: string): { source: string; reason?: string; since: number } | undefined;
|
|
229
|
+
/** Lift the pause sentinel for a project, clearing the legacy global too. */
|
|
230
|
+
resume(project?: string): void;
|
|
231
|
+
/** Start the detached rollback unit for a version. */
|
|
232
|
+
launchRollback(
|
|
233
|
+
version: string,
|
|
234
|
+
): Promise<{ ok: true; unit: string } | { ok: false; stderr: string }>;
|
|
235
|
+
log(message: string): void;
|
|
236
|
+
now?(): number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What one verify pass did with the pending request. */
|
|
240
|
+
export interface UpgradeVerifyResult {
|
|
241
|
+
handled:
|
|
242
|
+
| "none"
|
|
243
|
+
| "already-closed"
|
|
244
|
+
| "verified"
|
|
245
|
+
| "aborted"
|
|
246
|
+
| "rollback-requested"
|
|
247
|
+
| "rollback-unavailable";
|
|
248
|
+
version?: string;
|
|
249
|
+
detail?: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The first-tick verification for a fleet-initiated install (#486).
|
|
254
|
+
*
|
|
255
|
+
* The installing process journals progress and stops; whichever process comes
|
|
256
|
+
* back — the restarted daemon's first tick — reads that journal and confirms
|
|
257
|
+
* independently, then closes the transaction on the evidence: the installed
|
|
258
|
+
* version matches the request, `/healthz` answers, ticks and pane match the
|
|
259
|
+
* layers the install began under, and `doctor` has no failing finding.
|
|
260
|
+
*
|
|
261
|
+
* Every action is ordered for a crash: the durable outbox row goes in before
|
|
262
|
+
* the terminal journal outcome, and the outcome's dedupe key makes the
|
|
263
|
+
* at-least-once re-enqueue a no-op — so a tick that died mid-close re-runs
|
|
264
|
+
* the whole pass on the next tick without doubling the report or the rollback
|
|
265
|
+
* unit. The rollback unit is spawned at most once: a `rollback` phase marker
|
|
266
|
+
* journaled with its outcome is the crash guard, and only a marker that
|
|
267
|
+
* records a failed spawn (nothing was started) may retry.
|
|
268
|
+
*/
|
|
269
|
+
export async function verifyPendingUpgrade(deps: UpgradeVerifyDeps): Promise<UpgradeVerifyResult> {
|
|
270
|
+
const root = deps.journalRoot ?? stateDir();
|
|
271
|
+
const now = deps.now ?? Date.now;
|
|
272
|
+
const entries = readUpgradeJournal(root);
|
|
273
|
+
const progression = requestProgression(entries);
|
|
274
|
+
if (progression === undefined) return { handled: "none" };
|
|
275
|
+
const request = pendingUpgradeRequest(entries);
|
|
276
|
+
if (request === undefined) return { handled: "none" };
|
|
277
|
+
const state = classifyUpgrade(entries);
|
|
278
|
+
|
|
279
|
+
const journal = (entry: Omit<UpgradeJournalEntry, "at">): void => {
|
|
280
|
+
const write = deps.journal ?? ((line: UpgradeJournalEntry) => appendJournal(root, line));
|
|
281
|
+
write({ ...entry, version: entry.version ?? request.version, at: new Date(now()).toISOString() });
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------- terminal
|
|
285
|
+
if (state.kind === "closed") {
|
|
286
|
+
// The transaction is already terminal (verified, rolled back, aborted,
|
|
287
|
+
// rollback requested). One thing may still be owed: the install paused
|
|
288
|
+
// dispatch, and a crash took the process that should have resumed it. The
|
|
289
|
+
// resume is safe to redo — it only acts while the sentinel is still the
|
|
290
|
+
// install's own — so it runs on every visit to a closed request.
|
|
291
|
+
resumeUpgradePause(deps, request);
|
|
292
|
+
return { handled: "already-closed", version: request.version, detail: state.phase };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ------------------------------------------------------------ never started
|
|
296
|
+
if (state.kind === "no-start") {
|
|
297
|
+
// The unit died before journaling a single phase. Nothing was installed;
|
|
298
|
+
// the sentinel can still exist if it crashed between pausing and writing
|
|
299
|
+
// the pause line — clear it, close the request, and page tier 2 that the
|
|
300
|
+
// fleet could not install its own fix.
|
|
301
|
+
resumeUpgradePause(deps, request);
|
|
302
|
+
deps.enqueue({
|
|
303
|
+
project: deps.projectName,
|
|
304
|
+
kind: "tier2",
|
|
305
|
+
body: [
|
|
306
|
+
`Fleet upgrade to omp-conductor@${request.version} never started (journal: ${upgradeJournalPath(root)}).`,
|
|
307
|
+
"The detached install unit died before touching any surface; dispatch was not paused.",
|
|
308
|
+
`Re-run the request, or install by hand with \`omp-conductor upgrade --to ${request.version}\`.`,
|
|
309
|
+
].join("\n"),
|
|
310
|
+
at: now(),
|
|
311
|
+
dedupeKey: `upgrade:${request.version}:aborted`,
|
|
312
|
+
});
|
|
313
|
+
journal({ kind: "outcome", phase: "aborted", ok: false, detail: "requested upgrade never started a phase" });
|
|
314
|
+
return { handled: "aborted", version: request.version, detail: "no phase was ever journaled" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ----------------------------------------------- incomplete or failed mid-run
|
|
318
|
+
if (state.kind === "incomplete" || state.kind === "failed") {
|
|
319
|
+
// The installer died mid-transaction or journaled a failure it could not
|
|
320
|
+
// roll back itself. Surfaces may be mixed; the fixed answer is the
|
|
321
|
+
// snapshot rollback, detached, plus a tier-2 page naming the journal.
|
|
322
|
+
deps.log(
|
|
323
|
+
`upgrade to ${request.version} left an ${state.kind} journal — triggering the detached rollback`,
|
|
324
|
+
);
|
|
325
|
+
return triggerUpgradeRollback(deps, root, journal, request, progression, now(), "mid-flight failure");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ------------------------------------------------------- awaiting verification
|
|
329
|
+
const checks: UpgradeCheck[] = [];
|
|
330
|
+
await runUpgradeChecks(deps, request, checks);
|
|
331
|
+
const failed = checks.find((check) => !check.ok);
|
|
332
|
+
if (failed !== undefined) {
|
|
333
|
+
return triggerUpgradeRollback(
|
|
334
|
+
deps,
|
|
335
|
+
root,
|
|
336
|
+
journal,
|
|
337
|
+
request,
|
|
338
|
+
progression,
|
|
339
|
+
now(),
|
|
340
|
+
`verification failed on ${failed.name} (${failed.detail ?? "no detail"})`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Good. Resume dispatch only when the install paused it, and only while the
|
|
345
|
+
// sentinel is still the install's own — an operator's later hold is never
|
|
346
|
+
// lifted by an upgrade closing.
|
|
347
|
+
if (request.initialPaused === false) {
|
|
348
|
+
const owned = deps.pause(request.pauseKey);
|
|
349
|
+
if (owned !== undefined && owned.source === "upgrade") {
|
|
350
|
+
deps.resume(request.pauseKey);
|
|
351
|
+
deps.log(`upgrade verified — dispatch resumed (${request.pauseKey ?? "global"})`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
deps.enqueue({
|
|
355
|
+
project: deps.projectName,
|
|
356
|
+
kind: "material",
|
|
357
|
+
body: [
|
|
358
|
+
`omp-conductor@${request.version} is installed and verified on the first tick after the restart.`,
|
|
359
|
+
"",
|
|
360
|
+
...checks.map((check) => `${check.ok ? "ok" : "FAIL"} ${check.name}${check.detail === undefined ? "" : ` — ${check.detail}`}`),
|
|
361
|
+
"",
|
|
362
|
+
"Dispatch restored to its prior state.",
|
|
363
|
+
].join("\n"),
|
|
364
|
+
at: now(),
|
|
365
|
+
dedupeKey: `upgrade:${request.version}:verified`,
|
|
366
|
+
});
|
|
367
|
+
journal({ kind: "outcome", phase: "verified", ok: true, checks });
|
|
368
|
+
return { handled: "verified", version: request.version };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Lift the install's own pause sentinel, and only that one. `pauseKey` is the
|
|
373
|
+
* sentinel the engine paused; a fixed `initialPaused` true means the fleet was
|
|
374
|
+
* already held and the install never paused it, so nothing is lifted. A pause
|
|
375
|
+
* that an operator re-created (source changed) is left standing.
|
|
376
|
+
*/
|
|
377
|
+
function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequest): void {
|
|
378
|
+
if (request.initialPaused === true) return;
|
|
379
|
+
const owned = deps.pause(request.pauseKey);
|
|
380
|
+
if (owned !== undefined && owned.source === "upgrade") {
|
|
381
|
+
deps.resume(request.pauseKey);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The independent checks the returning process runs. Each compares a live
|
|
387
|
+
* fact with the journal's record of the world the install began in:
|
|
388
|
+
*
|
|
389
|
+
* - `version` — the installed CLI (all three surfaces are pinned to one
|
|
390
|
+
* release by the engine, so the CLI read stands for the set);
|
|
391
|
+
* - `ticks` / `pane` / `herdr` — delta against the pre-install layers, the
|
|
392
|
+
* same predicate the in-process upgrade used, so a fleet that was armed
|
|
393
|
+
* stays armed and a live pane stays live;
|
|
394
|
+
* - `health` — the daemon that came back answers `/healthz`;
|
|
395
|
+
* - `doctor` — no failing finding.
|
|
396
|
+
*/
|
|
397
|
+
export async function runUpgradeChecks(
|
|
398
|
+
deps: UpgradeVerifyDeps,
|
|
399
|
+
request: PendingUpgradeRequest,
|
|
400
|
+
checks: UpgradeCheck[],
|
|
401
|
+
): Promise<void> {
|
|
402
|
+
const version = await deps.run("omp-conductor", ["--version"]);
|
|
403
|
+
checks.push({
|
|
404
|
+
name: "version",
|
|
405
|
+
ok: version.code === 0 && version.stdout.trim() === request.version,
|
|
406
|
+
detail: version.code === 0 ? version.stdout.trim() : version.stderr.trim() || `exit ${version.code}`,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const layers = deps.layers(deps.projectName);
|
|
410
|
+
const before = request.initial;
|
|
411
|
+
if (before !== undefined) {
|
|
412
|
+
checks.push({ name: "ticks", ok: layers.ticks === before.ticks, detail: `${before.ticks} → ${layers.ticks}` });
|
|
413
|
+
checks.push({ name: "pane", ok: layers.pane === before.pane, detail: `${before.pane} → ${layers.pane}` });
|
|
414
|
+
if (before.herdr === "active") {
|
|
415
|
+
checks.push({ name: "herdr", ok: layers.herdr === "active", detail: layers.herdr });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
checks.push({
|
|
419
|
+
name: "health",
|
|
420
|
+
ok: layers.daemon.running && (await liveHealth(deps, layers.daemon.port ?? DEFAULT_PORT)).ok,
|
|
421
|
+
detail: layers.daemon.running ? undefined : "no daemon record",
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const doctor = await deps.doctor(deps.projectName);
|
|
425
|
+
const failing = doctor.findings.filter((finding) => finding.status === "fail");
|
|
426
|
+
checks.push({
|
|
427
|
+
name: "doctor",
|
|
428
|
+
ok: failing.length === 0,
|
|
429
|
+
detail: failing.length === 0 ? `status ${doctor.status}` : `${failing[0]!.id}: ${failing[0]!.summary}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function liveHealth(
|
|
434
|
+
deps: UpgradeVerifyDeps,
|
|
435
|
+
port: number,
|
|
436
|
+
): Promise<{ ok: boolean; body?: string }> {
|
|
437
|
+
try {
|
|
438
|
+
return await deps.health(port);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return { ok: false, body: err instanceof Error ? err.message : String(err) };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Spawn the detached rollback once and page the evidence, whatever the cause. */
|
|
445
|
+
async function triggerUpgradeRollback(
|
|
446
|
+
deps: UpgradeVerifyDeps,
|
|
447
|
+
root: string,
|
|
448
|
+
journal: (entry: Omit<UpgradeJournalEntry, "at">) => void,
|
|
449
|
+
request: PendingUpgradeRequest,
|
|
450
|
+
progress: readonly UpgradeJournalEntry[],
|
|
451
|
+
at: number,
|
|
452
|
+
why: string,
|
|
453
|
+
): Promise<UpgradeVerifyResult> {
|
|
454
|
+
// The marker is the spawn guard: present-and-ok means a rollback unit is
|
|
455
|
+
// already on its way and a crash after the spawn must not spawn a second
|
|
456
|
+
// one. Present-and-failed means nothing started, so a retry is a first
|
|
457
|
+
// attempt, not a double. Scoped to this request's progression — an older
|
|
458
|
+
// upgrade's rollback marker must not suppress a new one.
|
|
459
|
+
const marker = progress.find((entry) => entry.kind === "phase" && entry.phase === "rollback");
|
|
460
|
+
let spawned: { ok: true; unit: string } | { ok: false; stderr: string };
|
|
461
|
+
if (marker?.ok === true) {
|
|
462
|
+
spawned = { ok: true, unit: marker.unit ?? "omp-conductor-upgrade-rollback" };
|
|
463
|
+
} else {
|
|
464
|
+
spawned = await deps.launchRollback(request.version);
|
|
465
|
+
journal({
|
|
466
|
+
kind: "phase",
|
|
467
|
+
phase: "rollback",
|
|
468
|
+
ok: spawned.ok,
|
|
469
|
+
...(spawned.ok ? { unit: spawned.unit } : { detail: spawned.stderr }),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (!spawned.ok) {
|
|
473
|
+
// Nothing was spawned, so the next tick may legitimately retry a first
|
|
474
|
+
// attempt. Page with the journal path — the fleet may be half-upgraded
|
|
475
|
+
// and must not be quietly mixed.
|
|
476
|
+
journal({ kind: "outcome", phase: "rollback-unavailable", ok: false, detail: spawned.stderr });
|
|
477
|
+
deps.enqueue({
|
|
478
|
+
project: deps.projectName,
|
|
479
|
+
kind: "tier2",
|
|
480
|
+
body: [
|
|
481
|
+
`Fleet upgrade to omp-conductor@${request.version} is INCOMPLETE (${why}) and its detached rollback could not start.`,
|
|
482
|
+
`Reason: ${spawned.stderr}`,
|
|
483
|
+
"",
|
|
484
|
+
"Surfaces may be at mixed versions; do not resume dispatch.",
|
|
485
|
+
`Restore manually from ${upgradeJournalPath(root)}.`,
|
|
486
|
+
].join("\n"),
|
|
487
|
+
at,
|
|
488
|
+
dedupeKey: `upgrade:${request.version}:rollback-unavailable`,
|
|
489
|
+
});
|
|
490
|
+
return { handled: "rollback-unavailable", version: request.version, detail: spawned.stderr };
|
|
491
|
+
}
|
|
492
|
+
journal({ kind: "outcome", phase: "rollback-requested", ok: false, detail: why });
|
|
493
|
+
deps.enqueue({
|
|
494
|
+
project: deps.projectName,
|
|
495
|
+
kind: "tier2",
|
|
496
|
+
body: [
|
|
497
|
+
`Fleet upgrade to omp-conductor@${request.version} did not verify (${why}) and is being rolled back by ${spawned.unit}.`,
|
|
498
|
+
"Dispatch stays paused until the rollback lands and a later tick verifies the old version.",
|
|
499
|
+
"",
|
|
500
|
+
`Journal: ${upgradeJournalPath(root)}`,
|
|
501
|
+
].join("\n"),
|
|
502
|
+
at,
|
|
503
|
+
dedupeKey: `upgrade:${request.version}:rollback-requested`,
|
|
504
|
+
});
|
|
505
|
+
return { handled: "rollback-requested", version: request.version, detail: `${why}; rollback unit ${spawned.unit}` };
|
|
506
|
+
}
|