omp-conductor 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/unblock.ts CHANGED
@@ -38,6 +38,7 @@
38
38
  import { hasContinuationBudget, hasFailedAttemptBudget } from "./admission.ts";
39
39
  import { projectLabels } from "./label-projection.ts";
40
40
  import { LIVE_STATES } from "./store.ts";
41
+ import { wakeDispatch } from "./wake.ts";
41
42
  import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts";
42
43
 
43
44
  /** What one `unblock` did, and the state it found around it. */
@@ -54,6 +55,10 @@ export interface UnblockOutcome {
54
55
  * to "will the dispatcher hold this issue as issue-active?" that #178
55
56
  * found this verb guessing at. */
56
57
  active: boolean;
58
+ /** What the resident-daemon wake did, when this unblock restored the queue
59
+ * label and therefore made work claimable now (#878). Absent when nothing
60
+ * was requeued: there would be nothing for a pass to find. */
61
+ woken?: string;
57
62
  /** Set when a live worker (a claimed/running run) is on the issue. The
58
63
  * dispatcher holds a worker-backed run as issue-active unconditionally,
59
64
  * while worker-free pushed occupancy can bypass it (#175) — so this is
@@ -242,9 +247,19 @@ export async function unblockIssue(
242
247
  if (stillPending > 0) labelSyncQueued = stillPending;
243
248
  }
244
249
 
250
+ // An unblock that actually restored the queue label has made work claimable
251
+ // now, so the resident daemon is poked instead of leaving the issue for the
252
+ // next scheduled pass (#878). Only on a real restore: a withheld or skipped
253
+ // requeue changed nothing to dispatch, and a wake following a no-op is a claim
254
+ // of progress that did not happen. Best effort by construction — the label
255
+ // mutation already committed, so an unreachable daemon is a reported line,
256
+ // never an error here.
257
+ const woken = requeued !== undefined ? await wakeDispatch(project.name) : undefined;
258
+
245
259
  return {
246
260
  cleared,
247
261
  ...counts,
262
+ ...(woken === undefined ? {} : { woken }),
248
263
  ...(live ? { live: true as const } : {}),
249
264
  ...(latest === undefined ? {} : { latest }),
250
265
  ...(held === undefined ? {} : { forced: true as const }),
@@ -418,5 +433,12 @@ export function formatUnblock(
418
433
  lines.push(` queue "${project.queueLabel}" left untouched (--no-requeue)`);
419
434
  }
420
435
 
436
+ // What happens next in time, not just in principle (#878): a restored label
437
+ // that woke the daemon claims on an immediate pass, and one that could not
438
+ // reach it waits for the scheduled pass. Both are honest; a report that says
439
+ // "eligible again" without saying which invites the reader to assume the
440
+ // faster one.
441
+ if (o.woken !== undefined) lines.push(` dispatch ${o.woken}`);
442
+
421
443
  return lines.join("\n");
422
444
  }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The bounded unit-template reconcile (#905).
3
+ *
4
+ * `upgrade` swaps the package that *renders* the host units, which silently
5
+ * invalidates the installed copies on every release that touches a template —
6
+ * and then hands the operator a chore: re-run `omp-conductor setup host`
7
+ * yourself. That advisory (#598) is worse than a chore, because `setup host`
8
+ * restarts `herdr-fleet.service` at the end and kills its own calling pane
9
+ * (#834). This module is the other half of that finding: the smallest thing
10
+ * that makes the installed units match the render, and nothing else.
11
+ *
12
+ * Two properties make it safe to run inside the upgrade transaction:
13
+ *
14
+ * - **It is bounded to unit templates.** The staged copies of the daemon
15
+ * unit, the recovery unit and its playbook, the herdr session unit and the
16
+ * harness mount unit; then `install` for exactly the destinations whose
17
+ * live bytes differ, then one `daemon-reload`. No account creation, no
18
+ * ACLs, no `enable`, no `restart`, no herdr config merge, no tick config,
19
+ * no brief link — every one of those stays `setup host`'s, and a change
20
+ * that needs them is reported rather than attempted.
21
+ * - **It never overwrites an operator's edit.** The caller names the
22
+ * destinations that were *already* drifting before the new package landed;
23
+ * those are hand edits rather than drift this upgrade created, and they are
24
+ * skipped and reported. A host whose units already match is left untouched
25
+ * byte for byte, because the render-and-compare in
26
+ * {@link HostRuntimePlan.drift} is what decides.
27
+ *
28
+ * It lives in its own module, and runs as its own CLI verb, because the render
29
+ * is *code*: `upgradeConductor` replaces the package underneath itself, so
30
+ * every in-process render after the install phase is still the OLD version's
31
+ * templates. Only a freshly spawned process can render what the new release
32
+ * actually ships, which is why the transaction shells out to this verb instead
33
+ * of calling `writeHostRuntime` directly.
34
+ */
35
+
36
+ import { writeFileSync } from "node:fs";
37
+ import { dirname, join } from "node:path";
38
+
39
+ import {
40
+ HARNESS_MOUNT_UNIT_NAME,
41
+ RECOVER_SCRIPT_INSTALL_PATH,
42
+ RECOVER_SERVICE_NAME,
43
+ STAGED_SERVICE_NAME,
44
+ type HostRuntimePlan,
45
+ } from "./setup-host.ts";
46
+ import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
47
+ import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
48
+ import type { WizardUi } from "./wizard-ui.ts";
49
+
50
+ /** The confirm key, so a non-interactive caller can answer it up front. */
51
+ export const UNIT_RECONCILE_ANSWER_KEY = "reconcile-units";
52
+
53
+ /** One staged file and the destination it belongs at. */
54
+ export interface UnitDestination {
55
+ /** What this destination is, for the plan and the log. */
56
+ label: string;
57
+ /** The staged copy under the state directory — the render's own bytes. */
58
+ source: string;
59
+ /** The live path the system reads. */
60
+ installed: string;
61
+ /** `install -m` mode: units are data, the playbook is executed. */
62
+ mode: string;
63
+ /** The rendered content, so the staged copy can be written before install. */
64
+ content: string;
65
+ }
66
+
67
+ /**
68
+ * Every destination this reconcile is allowed to touch, in dependency order:
69
+ * the recovery playbook and unit before the daemon unit that names it in
70
+ * `OnFailure=`, so systemd never reloads a unit referencing one it cannot
71
+ * load.
72
+ *
73
+ * Deliberately absent, and not an oversight: the herdr pane-shell config and
74
+ * the herdr-conductor `config.env` (fleet-account files whose refresh implies
75
+ * a herdr restart), the worker identity, the tick config and the brief link.
76
+ * Those are `setup host`'s to own.
77
+ */
78
+ export function unitDestinations(plan: HostRuntimePlan): UnitDestination[] {
79
+ const unitDir = dirname(plan.installedPath);
80
+ const destinations: UnitDestination[] = [
81
+ {
82
+ label: "recovery playbook",
83
+ source: plan.recoverScript.path,
84
+ installed: RECOVER_SCRIPT_INSTALL_PATH,
85
+ mode: "0755",
86
+ content: plan.recoverScript.content,
87
+ },
88
+ {
89
+ label: RECOVER_SERVICE_NAME,
90
+ source: plan.recoverUnit.path,
91
+ installed: join(unitDir, RECOVER_SERVICE_NAME),
92
+ mode: "0644",
93
+ content: plan.recoverUnit.content,
94
+ },
95
+ {
96
+ label: STAGED_SERVICE_NAME,
97
+ source: plan.service.path,
98
+ installed: plan.installedPath,
99
+ mode: "0644",
100
+ content: plan.service.content,
101
+ },
102
+ ];
103
+ if (plan.herdrUnit !== undefined) {
104
+ destinations.push({
105
+ label: DEFAULT_HERDR_UNIT,
106
+ source: plan.herdrUnit.path,
107
+ installed: join(unitDir, DEFAULT_HERDR_UNIT),
108
+ mode: "0644",
109
+ content: plan.herdrUnit.content,
110
+ });
111
+ }
112
+ return destinations;
113
+ }
114
+
115
+ export interface UnitReconcilePlan {
116
+ /** Destinations that will be refreshed, in execution order. */
117
+ refresh: UnitDestination[];
118
+ /**
119
+ * Host state this version retires, from {@link HostRuntimePlan.retire} (#895).
120
+ *
121
+ * It rides here rather than in a verb of its own because `upgrade` already
122
+ * spawns this reconcile as a child of the just-installed CLI — the only
123
+ * process that can render what the new release ships, and therefore the only
124
+ * one that knows what it stopped shipping. Retirement is NOT drift: a unit
125
+ * this version no longer renders cannot be compared against a render, so it
126
+ * is never protected by `--protect` and never counted as out of scope.
127
+ */
128
+ retire: readonly string[];
129
+ /** Drifted destinations left alone because the caller protected them. */
130
+ protectedDrift: string[];
131
+ /** Drifted destinations outside this reconcile's remit, named so the caller
132
+ * can say precisely what still needs `setup host` — never a blanket
133
+ * "re-run setup host". */
134
+ outOfScope: string[];
135
+ /** The privileged batch: one `install` per refreshed destination, then one
136
+ * `daemon-reload`. Empty when nothing drifts. */
137
+ steps: PrivilegedStep[];
138
+ }
139
+
140
+ /**
141
+ * What reconciling this plan would do. Pure: it renders no file and runs no
142
+ * command, so a caller can print it, test it, or decide there is nothing to do.
143
+ *
144
+ * `protect` names destinations whose installed bytes already differed from the
145
+ * *previous* version's render — operator hand edits, which this never
146
+ * overwrites.
147
+ */
148
+ export function planUnitReconcile(
149
+ plan: HostRuntimePlan,
150
+ protect: readonly string[] = [],
151
+ /** `false` retires only — the posture `upgrade` uses when it could not read
152
+ * a drift baseline, so a refresh cannot tell an operator's edit from its
153
+ * own, but a retirement still must not be skipped (#895). */
154
+ refreshDestinations = true,
155
+ ): UnitReconcilePlan {
156
+ const protectedSet = new Set(protect);
157
+ const drifted = new Set(plan.drift);
158
+ const known = unitDestinations(plan);
159
+ const refresh: UnitDestination[] = [];
160
+ const protectedDrift: string[] = [];
161
+ for (const destination of known) {
162
+ if (!refreshDestinations) continue;
163
+ if (!drifted.has(destination.installed)) continue;
164
+ if (protectedSet.has(destination.installed)) {
165
+ protectedDrift.push(destination.installed);
166
+ continue;
167
+ }
168
+ refresh.push(destination);
169
+ }
170
+ const mine = new Set(known.map((d) => d.installed));
171
+ const outOfScope = refreshDestinations ? plan.drift.filter((path) => !mine.has(path)) : [];
172
+ const retireSteps = plan.retire?.steps ?? [];
173
+ const steps: PrivilegedStep[] =
174
+ refresh.length === 0 && retireSteps.length === 0
175
+ ? []
176
+ : [
177
+ // Retirement first, and before any install: an obsolete unit is
178
+ // disabled and unmounted while the daemon this reconcile serves is
179
+ // still the one the caller drained for.
180
+ ...retireSteps,
181
+ ...refresh.map((destination) => ({
182
+ title: `install ${destination.label} (${destination.installed})`,
183
+ argv: ["install", "-m", destination.mode, destination.source, destination.installed],
184
+ })),
185
+ // One reload for the whole batch: systemd re-reads every unit file,
186
+ // so per-unit reloads would be the same work repeated. No `enable`
187
+ // and no `restart` — the units are already enabled, and restarting
188
+ // is the caller's decision, taken inside its own drain window.
189
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
190
+ ];
191
+ const retire = plan.retire === undefined ? [] : [...plan.retire.units, ...plan.retire.staged];
192
+ return { refresh, retire, protectedDrift, outOfScope, steps };
193
+ }
194
+
195
+ export type UnitReconcileOutcome =
196
+ /** Nothing drifted and nothing to retire: the host already matches this
197
+ * version. */
198
+ | { kind: "current"; protectedDrift: string[]; outOfScope: string[] }
199
+ /** The batch ran. `refreshed` are the destinations now matching the render,
200
+ * and `retired` the host state this version removed (#895). */
201
+ | { kind: "reconciled"; refreshed: string[]; retired: string[]; protectedDrift: string[]; outOfScope: string[] }
202
+ /** The confirm was declined; nothing was written or installed. */
203
+ | { kind: "declined"; pending: string[] }
204
+ /** A step failed. `failed` names it; nothing after it ran. */
205
+ | { kind: "failed"; failed: string; detail: string; remaining: string[] };
206
+
207
+ /**
208
+ * Stage the rendered unit files and install the ones whose live bytes differ.
209
+ *
210
+ * The staged writes happen first and unprivileged (they land under the state
211
+ * directory the fleet account owns), and only for destinations that will
212
+ * actually be installed — so a declined confirm leaves the state directory as
213
+ * it was, and a current host is not rewritten just to prove it is current.
214
+ */
215
+ export async function reconcileUnits(
216
+ plan: HostRuntimePlan,
217
+ ui: WizardUi,
218
+ options: {
219
+ protect?: readonly string[];
220
+ /** `false` retires only, refreshing nothing (#895). */
221
+ refresh?: boolean;
222
+ /** Pre-answered confirm for a non-interactive caller (the upgrade
223
+ * transaction, whose operator already consented to the whole upgrade). */
224
+ deps?: PrivilegedDeps;
225
+ /** Injected in tests; defaults to a real file write. */
226
+ write?: (path: string, content: string) => void;
227
+ } = {},
228
+ ): Promise<UnitReconcileOutcome> {
229
+ const reconcile = planUnitReconcile(plan, options.protect ?? [], options.refresh ?? true);
230
+ if (reconcile.steps.length === 0) {
231
+ return { kind: "current", protectedDrift: reconcile.protectedDrift, outOfScope: reconcile.outOfScope };
232
+ }
233
+ const write = options.write ?? ((path: string, content: string) => writeFileSync(path, content));
234
+ const outcome = await runPrivileged(reconcile.steps, ui, {
235
+ ...(options.deps === undefined ? {} : { deps: options.deps }),
236
+ title: "Install the re-rendered host units now?",
237
+ answerKey: UNIT_RECONCILE_ANSWER_KEY,
238
+ preamble: [
239
+ ...(reconcile.refresh.length === 0
240
+ ? []
241
+ : ["This release renders host unit files that differ from the installed copies."]),
242
+ ...(reconcile.retire.length === 0
243
+ ? []
244
+ : [`This release retires host state an earlier one installed: ${reconcile.retire.join(", ")}.`]),
245
+ "Units only: nothing is enabled, restarted, or re-provisioned here.",
246
+ ],
247
+ // Staged copies are written under the same consent as the install that
248
+ // reads them, and after it — never before a declined confirm.
249
+ beforeRun: async () => {
250
+ for (const destination of reconcile.refresh) write(destination.source, destination.content);
251
+ },
252
+ });
253
+ if (outcome.kind === "declined") {
254
+ return { kind: "declined", pending: [...reconcile.retire, ...reconcile.refresh.map((d) => d.installed)] };
255
+ }
256
+ if (outcome.kind === "failed") {
257
+ return {
258
+ kind: "failed",
259
+ failed: outcome.step.title,
260
+ detail: outcome.stderr.trim().split("\n")[0] ?? `exit ${outcome.exitCode}`,
261
+ remaining: outcome.remaining.map((step) => step.title),
262
+ };
263
+ }
264
+ return {
265
+ kind: "reconciled",
266
+ refreshed: reconcile.refresh.map((d) => d.installed),
267
+ retired: [...reconcile.retire],
268
+ protectedDrift: reconcile.protectedDrift,
269
+ outOfScope: reconcile.outOfScope,
270
+ };
271
+ }
272
+
273
+ /** One line per outcome, for the CLI and the upgrade journal. */
274
+ export function formatUnitReconcile(outcome: UnitReconcileOutcome): string {
275
+ const tail = (protectedDrift: readonly string[], outOfScope: readonly string[]): string =>
276
+ [
277
+ protectedDrift.length === 0
278
+ ? ""
279
+ : ` left alone (modified outside conductor): ${protectedDrift.join(", ")}.`,
280
+ outOfScope.length === 0
281
+ ? ""
282
+ : ` still owed to \`omp-conductor setup host\`: ${outOfScope.join(", ")}.`,
283
+ ].join("");
284
+ switch (outcome.kind) {
285
+ case "current":
286
+ return `host units: already match this version's render.${tail(outcome.protectedDrift, outcome.outOfScope)}`;
287
+ case "reconciled":
288
+ return (
289
+ (outcome.refreshed.length === 0
290
+ ? "host units: nothing to refresh."
291
+ : `host units: reconciled ${outcome.refreshed.length} destination(s) — ${outcome.refreshed.join(", ")}.`) +
292
+ (outcome.retired.length === 0 ? "" : ` Retired: ${outcome.retired.join(", ")}.`) +
293
+ tail(outcome.protectedDrift, outcome.outOfScope)
294
+ );
295
+ case "declined":
296
+ return `host units: not reconciled (declined) — still drifted: ${outcome.pending.join(", ")}.`;
297
+ case "failed":
298
+ return (
299
+ `host units: reconcile failed at "${outcome.failed}" (${outcome.detail}); ` +
300
+ `${outcome.remaining.length} step(s) did not run.`
301
+ );
302
+ }
303
+ }
@@ -39,13 +39,20 @@ export interface UpgradeCommandResult {
39
39
  stderr: string;
40
40
  }
41
41
 
42
- export async function runCommand(command: string, args: readonly string[]): Promise<UpgradeCommandResult> {
42
+ export async function runCommand(
43
+ command: string,
44
+ args: readonly string[],
45
+ /** Where to run it. Absent means this process's cwd, which is what every
46
+ * step but the bootstrap's source checks wants (#908). */
47
+ cwd?: string,
48
+ ): Promise<UpgradeCommandResult> {
43
49
  try {
44
50
  const child = Bun.spawn([command, ...args], {
45
51
  stdin: "ignore",
46
52
  stdout: "pipe",
47
53
  stderr: "pipe",
48
54
  env: process.env,
55
+ ...(cwd === undefined ? {} : { cwd }),
49
56
  });
50
57
  const stdout = new Response(child.stdout).text();
51
58
  const stderr = new Response(child.stderr).text();