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
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `status` — layered fleet report: dispatch, ticks, pane, herdr, Telegram target, daemon, caps and active runs.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { stateDir } from "../config.ts";
13
+ import { renderStatus } from "../fleet.ts";
14
+ import { STALL_MARKER_FILE } from "../orchestrator-tick.ts";
15
+
16
+ /**
17
+ * The orchestrator half, and the one thing `status` has ever known about the
18
+ * supervising session: the stall marker its heartbeat writes when its own
19
+ * prompts stop being consumed (see {@link STALL_MARKER_FILE}).
20
+ *
21
+ * The marker is written in the *session's* cwd, which this process has no way
22
+ * to discover — so this reads the state directory, on the reference deploy's
23
+ * convention that the orchestrator session runs from exactly there. That makes
24
+ * the reading one-directional: a line printed here is proof of a wedge, and no
25
+ * line is proof of nothing at all. On a fleet whose session lives elsewhere the
26
+ * check is simply inert, which is why it never prints a reassuring "healthy".
27
+ */
28
+ function stallLine(): string | undefined {
29
+ let raw: string;
30
+ try {
31
+ raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ // "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
36
+ // still gets reported: that the marker exists at all is the news.
37
+ const cut = raw.indexOf(" ");
38
+ const since = cut < 0 ? raw : raw.slice(0, cut);
39
+ const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
40
+ return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
41
+ }
42
+
43
+ export async function statusCommand(ctx: CommandContext): Promise<void> {
44
+ const project = ctx.projectFlag;
45
+ const text = await renderStatus(project);
46
+ const stalled = stallLine();
47
+ process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${stalled}\n`}`);
48
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `stop` — stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon. Pane stays up unless --pane is passed.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
11
+ import { stopDaemon } from "../lifecycle.ts";
12
+
13
+ export async function stopCommand(ctx: CommandContext): Promise<void> {
14
+ const withPane = ctx.argv.includes("--pane");
15
+ const targets = ctx.targetProjects().map((project) => {
16
+ // `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
17
+ // `hold` affordance. Assert the invariant instead of printing a path that
18
+ // might not exist.
19
+ const held = hold(project.name, "halt");
20
+ if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
21
+ return {
22
+ project,
23
+ hold: { ...held, disarmed: held.disarmed },
24
+ pin: withPane ? pinPaneHalt(project.name).path : undefined,
25
+ };
26
+ });
27
+ const stop = await stopDaemon();
28
+ const stopLine =
29
+ stop.kind === "not-running"
30
+ ? "daemon was not running"
31
+ : `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
32
+ for (const target of targets) {
33
+ if (target.pin !== undefined) {
34
+ const pane = await stopConductorPane(target.project.name);
35
+ process.stdout.write(
36
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
37
+ `${stopLine}\n` +
38
+ `pane recovery pinned at ${target.pin}\n` +
39
+ `pane stop: ${pane.stopped} — ${pane.detail}\n` +
40
+ ` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
41
+ ` resume clears the pin when you want recovery again)\n`,
42
+ );
43
+ } else {
44
+ process.stdout.write(
45
+ `stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
46
+ `${stopLine}\n` +
47
+ `pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
48
+ );
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `tail` — follow the newest run for an issue the way `tail -f` follows a log, finishing once the run has left the live states.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
11
+ import { findProject, loadConfig } from "../config.ts";
12
+ import { dbPath, LIVE_STATES, openStore } from "../store.ts";
13
+ import { formatTranscriptLine } from "../transcript.ts";
14
+
15
+ /** How often `tail` re-stats the transcript it is following. */
16
+ const TAIL_POLL_MS = 1_000;
17
+
18
+ /**
19
+ * How long the transcript must stay unchanged, after its run has left the live
20
+ * states, before `tail` calls it over. The state flips from the daemon's thread
21
+ * while the harness may still be flushing its last message, so exiting on the
22
+ * state alone truncates the ending an operator ran this command to watch.
23
+ */
24
+ const TAIL_QUIET_MS = 5_000;
25
+
26
+ /**
27
+ * Follow one run's transcript the way `tail -f` follows a log.
28
+ *
29
+ * Reads from byte zero rather than from the end: attaching to a worker that is
30
+ * already ten turns in and then showing nothing until turn eleven is not
31
+ * watching the run. Polls `stat` instead of taking a file watcher because the
32
+ * transcript is a plain append-only file that may sit on a filesystem where
33
+ * change events are a polite fiction, and one stat a second costs nothing.
34
+ *
35
+ * SIGINT is deliberately left to its default, which is immediate exit. Nothing
36
+ * here is buffered, and a handler could only add a poll interval of latency to
37
+ * every Ctrl-C.
38
+ */
39
+ async function tailRun(project: string, issue: number): Promise<void> {
40
+ // Read-only in practice: the store is opened WAL with a busy timeout, so this
41
+ // never contends with the daemon writing the same rows.
42
+ const store = openStore(dbPath());
43
+ try {
44
+ const run = store.latestRun(project, issue);
45
+ if (run === undefined) throw new Error(`no run recorded for #${issue}`);
46
+ const path = run.sessionFile;
47
+ // Claimed but not yet started, or an attempt whose session never opened one.
48
+ if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
49
+
50
+ const fd = openSync(path, "r");
51
+ try {
52
+ let offset = 0;
53
+ let pending = Buffer.alloc(0);
54
+ let lastChange = Date.now();
55
+
56
+ for (;;) {
57
+ let size = offset;
58
+ try {
59
+ size = statSync(path).size;
60
+ } catch {
61
+ // A transcript that vanishes mid-follow is not worth crashing over.
62
+ // The run's own state, below, is what decides when this command ends.
63
+ }
64
+ // Shorter than what we have already read means truncated or replaced;
65
+ // resuming from the old offset would read the middle of another file.
66
+ if (size < offset) {
67
+ offset = 0;
68
+ pending = Buffer.alloc(0);
69
+ }
70
+ if (size > offset) {
71
+ const chunk = Buffer.allocUnsafe(size - offset);
72
+ const read = readSync(fd, chunk, 0, chunk.length, offset);
73
+ offset += read;
74
+ // Split on newlines as bytes, not as text: a UTF-8 sequence straddling
75
+ // a read boundary would be mangled by decoding each chunk on its own.
76
+ pending = Buffer.concat([pending, chunk.subarray(0, read)]);
77
+ for (;;) {
78
+ const nl = pending.indexOf(0x0a);
79
+ if (nl < 0) break;
80
+ const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
81
+ pending = pending.subarray(nl + 1);
82
+ if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
83
+ }
84
+ if (read > 0) lastChange = Date.now();
85
+ }
86
+
87
+ // Re-read this exact run every poll — not `latestRun`, which would jump
88
+ // to a retry started meanwhile and report its state against the wrong
89
+ // transcript. The daemon writes the row from another process, so looking
90
+ // is the only way to notice the run finished.
91
+ const state = store.getRun(run.id)?.state ?? run.state;
92
+ if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
93
+ process.stdout.write(`run ended: ${state}\n`);
94
+ return;
95
+ }
96
+ await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
97
+ }
98
+ } finally {
99
+ closeSync(fd);
100
+ }
101
+ } finally {
102
+ store.close();
103
+ }
104
+ }
105
+
106
+ export async function tailCommand(ctx: CommandContext): Promise<void> {
107
+ const issue = ctx.issueArg("tail", ctx.argv[1]);
108
+ await tailRun(findProject(loadConfig(), ctx.projectFlag).name, issue);
109
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `unblock` — clear an issue's blocked and failed labels and restore its queue label so the next tick can claim it again.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig, resolveCaps } from "../config.ts";
11
+ import { dbPath, openStore } from "../store.ts";
12
+ import { makeTracker } from "../tracker/github.ts";
13
+ import { formatUnblock, unblockIssue } from "../unblock.ts";
14
+
15
+ export async function unblockCommand(ctx: CommandContext): Promise<void> {
16
+ const issue = ctx.issueArg("unblock", ctx.argv[1]);
17
+ const cfg = loadConfig();
18
+ const project = findProject(cfg, ctx.projectFlag);
19
+ const store = openStore(dbPath());
20
+ try {
21
+ const caps = resolveCaps(project, cfg.defaults);
22
+ const outcome = await unblockIssue(
23
+ project,
24
+ makeTracker(project),
25
+ store,
26
+ issue,
27
+ {
28
+ force: ctx.argv.includes("--force"),
29
+ requeue: !ctx.argv.includes("--no-requeue"),
30
+ },
31
+ caps,
32
+ );
33
+ process.stdout.write(`${formatUnblock(issue, outcome, project, caps)}\n`);
34
+ // A refusal must not read as success to a script or a board keypress.
35
+ if (outcome.refused !== undefined) process.exitCode = 3;
36
+ } finally {
37
+ store.close();
38
+ }
39
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `upgrade-install` — run the upgrade transaction as the detached fleet
3
+ * installer (#486): the same engine as `upgrade`, with every surface journaled
4
+ * and the in-process verification and dispatch restore handed to the first
5
+ * tick of the post-restart daemon.
6
+ *
7
+ * This is the command the transient unit spawned by `conductor_install` runs —
8
+ * a process in the system manager, outside the pane's process tree and the
9
+ * daemon's cgroup, so the restarts the transaction performs pass it by. It is
10
+ * not for typing in a pane: like `upgrade`, it refuses to run inside the
11
+ * target Herdr session, because a process the restart kills cannot verify
12
+ * what it installed.
13
+ */
14
+
15
+ import type { CommandContext } from "./context.ts";
16
+ import { runDetachedUpgrade } from "../upgrade.ts";
17
+
18
+ export async function upgradeInstallCommand(ctx: CommandContext): Promise<void> {
19
+ const result = await runDetachedUpgrade({
20
+ version: ctx.flag("to"),
21
+ project: ctx.projectFlag,
22
+ });
23
+ process.stdout.write(
24
+ `install complete — awaiting first-tick verification:\n` +
25
+ ` Bun-global CLI omp-conductor@${result.version}\n` +
26
+ ` omp plugin omp-conductor@${result.version}\n` +
27
+ ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
28
+ ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
29
+ ` dispatch paused — the first tick after the restart verifies and restores it\n`,
30
+ );
31
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `upgrade-rollback` — the detached rollback unit (#486).
3
+ *
4
+ * Reads the state-dir upgrade journal, restores every surface the failed
5
+ * install touched from the durable pre-install snapshot, reloads the services
6
+ * the install reloaded, and writes the outcome back to the journal plus the
7
+ * durable outbox. Runs as its own transient unit so the restarts it performs
8
+ * pass it by — the same trust boundary as the install: the process that rolls
9
+ * back is not the process that failed.
10
+ */
11
+
12
+ import type { CommandContext } from "./context.ts";
13
+ import { rollbackFromJournal } from "../upgrade.ts";
14
+
15
+ export async function upgradeRollbackCommand(ctx: CommandContext): Promise<void> {
16
+ // Nothing here varies by project (#514): the rollback restores every
17
+ // surface the failed install touched, from the host-wide journal. The
18
+ // flag only ever misled, so refuse it with a reason.
19
+ if (ctx.projectFlag !== undefined) {
20
+ process.stderr.write(
21
+ `omp-conductor: upgrade-rollback does not take a project — it restores the whole host from the upgrade journal\n`,
22
+ );
23
+ process.exit(2);
24
+ }
25
+ const result = await rollbackFromJournal();
26
+ process.stdout.write(
27
+ `rolled back to omp-conductor@${result.restoredVersion} after the failed ` +
28
+ `upgrade to ${result.failedVersion}\n` +
29
+ ` surfaces restored from the pre-install snapshot\n` +
30
+ ` dispatch paused — the next tick verifies the old version and resumes\n`,
31
+ );
32
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `upgrade` — update the Bun-global CLI, omp plugin, Herdr recovery plugin and brief as one pinned release.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { upgradeConductor } from "../upgrade.ts";
11
+
12
+ export async function upgradeCommand(ctx: CommandContext): Promise<void> {
13
+ const result = await upgradeConductor({
14
+ version: ctx.flag("to"),
15
+ project: ctx.projectFlag,
16
+ });
17
+ process.stdout.write(
18
+ `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
19
+ ` Bun-global CLI omp-conductor@${result.version}\n` +
20
+ ` omp plugin omp-conductor@${result.version}\n` +
21
+ ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
22
+ ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
23
+ ` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
24
+ );
25
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `verb` — run one `conductor_*` verb as the orchestrator, from the CLI: through the daemon's own checks and ledger rows.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig } from "../config.ts";
11
+ import { isPaused, pausedAt } from "../daemon.ts";
12
+ import { readBaseChain } from "../gitops.ts";
13
+ import { dbPath, openStore } from "../store.ts";
14
+ import { makeTracker } from "../tracker/github.ts";
15
+ import { VERB_NAMES } from "../types.ts";
16
+ import { githubVerbActions } from "../verbs/actions.ts";
17
+ import { handleVerbCall, type VerbChannel } from "../verbs/server.ts";
18
+
19
+ export async function verbCommand(ctx: CommandContext): Promise<void> {
20
+ const name = ctx.argv[1];
21
+ if (name === undefined || !VERB_NAMES.some((n) => n === name)) {
22
+ process.stderr.write(
23
+ `omp-conductor: unknown verb "${name ?? ""}". Known verbs: ${VERB_NAMES.join(", ")}\n`,
24
+ );
25
+ process.exit(2);
26
+ }
27
+ const cfg = loadConfig();
28
+ const project = findProject(cfg, ctx.projectFlag);
29
+ const args: Record<string, string> = {};
30
+ for (let i = 2; i < ctx.argv.length; i++) {
31
+ const token = ctx.argv[i];
32
+ if (token === "--arg") {
33
+ const pair = ctx.argv[i + 1];
34
+ if (pair === undefined || !pair.includes("=")) {
35
+ process.stderr.write(`omp-conductor: verb --arg needs k=v, got "${pair ?? ""}"\n`);
36
+ process.exit(2);
37
+ }
38
+ const eq = pair.indexOf("=");
39
+ args[pair.slice(0, eq)] = pair.slice(eq + 1);
40
+ i++;
41
+ } else if (token === "--project" || token?.startsWith("--project=") === true) {
42
+ // Consumed by `ctx.projectFlag` above; both spellings skip here.
43
+ if (token === "--project") i++;
44
+ } else {
45
+ process.stderr.write(
46
+ `omp-conductor: verb: unexpected argument "${token ?? ""}" (pass verb arguments as --arg k=v)\n`,
47
+ );
48
+ process.exit(2);
49
+ }
50
+ }
51
+ const store = openStore(dbPath());
52
+ try {
53
+ const channel: VerbChannel = {
54
+ kind: "orchestrator",
55
+ path: "cli",
56
+ project: project.name,
57
+ role: "orchestrator",
58
+ };
59
+ const reply = await handleVerbCall(
60
+ {
61
+ project: () => findProject(loadConfig(), project.name),
62
+ store,
63
+ tracker: makeTracker(project),
64
+ actions: githubVerbActions(project),
65
+ fleetStop: () =>
66
+ isPaused(project.name)
67
+ ? "claiming is paused for this fleet (omp-conductor hold or stop)"
68
+ : undefined,
69
+ pausedAt: () => pausedAt(project.name),
70
+ log: (m) => process.stderr.write(`${m}\n`),
71
+ now: () => Date.now(),
72
+ chain: { readBaseChain },
73
+ },
74
+ channel,
75
+ { verb: name, args },
76
+ );
77
+ process.stdout.write(`${reply.text}\n`);
78
+ // A refusal must not read as success to a script calling this.
79
+ if (!reply.ok) process.exitCode = 3;
80
+ } finally {
81
+ store.close();
82
+ }
83
+ }
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The installed package version, read from the package manifest once per run.
6
+ * The path is one level deeper than cli.ts's original — this module lives in
7
+ * src/commands/ — but resolves to the same omp/package.json.
8
+ */
9
+ function packageVersion(): string {
10
+ const parsed = JSON.parse(
11
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
12
+ ) as {
13
+ version?: unknown;
14
+ };
15
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
16
+ throw new Error("installed package.json has no version");
17
+ }
18
+ return parsed.version;
19
+ }
20
+
21
+ /**
22
+ * `--version`, `-V` and `version`: print the installed package version.
23
+ *
24
+ * Moved out of cli.ts's switch by the per-verb module split (#462); only the
25
+ * case wrapper and the imports changed from the original body.
26
+ */
27
+
28
+ export async function versionCommand(): Promise<void> {
29
+ process.stdout.write(`${packageVersion()}\n`);
30
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `worker` — pause/resume/stop one live worker session through the daemon's run-control HTTP endpoints.
3
+ *
4
+ * Moved out of cli.ts's switch by the per-verb module split (#462);
5
+ * only the case wrapper, the injected `ctx` lookups and the imports
6
+ * changed from the original bodies.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig } from "../config.ts";
11
+ import { livingDaemon } from "../lifecycle.ts";
12
+
13
+ export async function workerCommand(ctx: CommandContext): Promise<void> {
14
+ const sub = ctx.argv[1];
15
+ if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
16
+ process.stderr.write(
17
+ "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
18
+ );
19
+ process.exit(2);
20
+ }
21
+ const issue = ctx.issueArg("worker", ctx.argv[2]);
22
+ const rawReason = sub === "stop" ? ctx.flag("reason") : undefined;
23
+ const reason = rawReason?.trim().replace(/\s+/g, " ");
24
+ if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
25
+ process.stderr.write(
26
+ "omp-conductor: worker stop needs --reason with 1-500 characters\n",
27
+ );
28
+ process.exit(2);
29
+ }
30
+ const project = findProject(loadConfig(), ctx.projectFlag);
31
+ const daemon = livingDaemon();
32
+ if (daemon === undefined) throw new Error("daemon is not running");
33
+ if (daemon.project !== undefined && daemon.project !== project.name) {
34
+ throw new Error(
35
+ `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
36
+ );
37
+ }
38
+ const response = await fetch(
39
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
40
+ {
41
+ method: "PUT",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({
44
+ project: project.name,
45
+ ...(reason === undefined ? {} : { reason }),
46
+ }),
47
+ },
48
+ );
49
+ const payload = (await response.json()) as {
50
+ error?: unknown;
51
+ runId?: unknown;
52
+ phase?: unknown;
53
+ outcome?: unknown;
54
+ state?: unknown;
55
+ reason?: unknown;
56
+ salvageSha?: unknown;
57
+ salvageError?: unknown;
58
+ worktree?: unknown;
59
+ };
60
+ if (!response.ok) {
61
+ throw new Error(
62
+ typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
63
+ );
64
+ }
65
+ if (sub === "stop") {
66
+ if (
67
+ typeof payload.runId !== "string" ||
68
+ typeof payload.state !== "string" ||
69
+ (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
70
+ ) {
71
+ throw new Error("daemon returned an invalid worker-stop response");
72
+ }
73
+ if (payload.outcome === "already-terminal") {
74
+ process.stdout.write(
75
+ `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
76
+ );
77
+ return;
78
+ }
79
+ if (typeof payload.reason !== "string") {
80
+ throw new Error("daemon returned an invalid worker-stop response");
81
+ }
82
+ process.stdout.write(
83
+ `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
84
+ );
85
+ if (typeof payload.salvageSha === "string") {
86
+ process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
87
+ }
88
+ if (typeof payload.salvageError === "string") {
89
+ process.stdout.write(
90
+ `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
91
+ `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
92
+ );
93
+ }
94
+ return;
95
+ }
96
+ if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
97
+ throw new Error("daemon returned an invalid worker-control response");
98
+ }
99
+ process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
100
+ }
@@ -226,6 +226,7 @@ const authoritySchema = z
226
226
  .object({
227
227
  merge: authorityHolderEnum.default(DEFAULT_AUTHORITY.merge),
228
228
  release: authorityHolderEnum.default(DEFAULT_AUTHORITY.release),
229
+ promotion: authorityHolderEnum.default(DEFAULT_AUTHORITY.promotion),
229
230
  })
230
231
  .strict();
231
232
 
@@ -315,12 +316,21 @@ const projectSchema = z
315
316
  routing: routingSchema.optional(),
316
317
  caps: capsSchema.optional(),
317
318
  workerModel: z.unknown().optional(),
319
+ // The loader normalises both of these (trimmed entries / positive integer),
320
+ // so the schema admits any shape and lets the normaliser decide what is
321
+ // usable, exactly like `workerModel`.
322
+ modelFallbacks: z.unknown().optional(),
323
+ modelFallbackThreshold: z.unknown().optional(),
318
324
  escalation: escalationSchema.optional(),
319
325
  authority: authoritySchema.optional(),
320
326
  releasePolicy: releasePolicySchema.optional(),
321
327
  policy: projectPolicySchema.optional(),
322
328
  recoveryMerges: recoveryMergesSchema.optional(),
323
329
  reporting: reportingSchema.optional(),
330
+ // Hand-edited safety markers (commit SHAs or refs) a preserved continuation
331
+ // branch must contain before it may be reattached; the loader normalises
332
+ // them like `modelFallbacks`.
333
+ criticalBase: z.unknown().optional(),
324
334
  // Roots silently fall back to the default when unusable, so the schema
325
335
  // admits any shape and the normaliser picks the usable path.
326
336
  workspaceRoot: z.unknown().optional(),
@@ -352,7 +362,43 @@ export const ConfigSchema = configSchema;
352
362
  * validate a hand-written config against the same vocabulary the loader enforces.
353
363
  */
354
364
  export function configJsonSchema(): object {
355
- return z.toJSONSchema(ConfigSchema) as object;
365
+ return unrequireDefaultedKeys(z.toJSONSchema(ConfigSchema) as object);
366
+ }
367
+
368
+ /** True for a JSON object value (null and arrays excluded). */
369
+ function isObject(value: unknown): value is Record<string, unknown> {
370
+ return typeof value === "object" && value !== null;
371
+ }
372
+
373
+ /**
374
+ * zod's JSON-Schema renderer lists a field carrying `.default(...)` as
375
+ * `required` anyway — its required computation keys off optionality, while
376
+ * the only trace a default leaves in the output is the `default` annotation.
377
+ * The loader accepts a config that omits any defaulted key, so a schema that
378
+ * demands one rejects a file the daemon reads cleanly (the pre-#469
379
+ * back-compat promise). Walk the rendered schema and drop every `required`
380
+ * key whose property schema declares a `default`; a key with no default is
381
+ * untouched. Works in place on the tree `z.toJSONSchema` just built.
382
+ */
383
+ function unrequireDefaultedKeys(schema: object): object {
384
+ if (Array.isArray(schema)) {
385
+ for (const item of schema) if (isObject(item)) unrequireDefaultedKeys(item);
386
+ return schema;
387
+ }
388
+ const node = schema as Record<string, unknown>;
389
+ const properties = node.properties;
390
+ if (isObject(properties) && Array.isArray(node.required)) {
391
+ const kept = (node.required as unknown[]).filter((key) => {
392
+ const property = properties[String(key)];
393
+ return !isObject(property) || !("default" in property);
394
+ });
395
+ if (kept.length === 0) delete node.required;
396
+ else node.required = kept;
397
+ }
398
+ for (const value of Object.values(node)) {
399
+ if (isObject(value)) unrequireDefaultedKeys(value);
400
+ }
401
+ return schema;
356
402
  }
357
403
 
358
404
  // Re-exports the renderer consumes without duplicating the lists.