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.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -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 +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- 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 +150 -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 +100 -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 +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -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 +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- 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
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -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.
|