omp-conductor 0.15.12 → 0.16.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.
- package/REFERENCE.md +81 -6
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +93 -54
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +66 -34
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +485 -577
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +418 -8
- package/src/escalate.ts +122 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +55 -377
- package/src/gitops.ts +86 -1
- package/src/lifecycle.ts +113 -2
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +63 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +14 -1
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +314 -113
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +119 -0
- package/src/store.ts +533 -11
- package/src/types.ts +298 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +27 -8
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +52 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +183 -10
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `restore-db` — put `conductor.db` back to a restorable snapshot.
|
|
3
|
+
*
|
|
4
|
+
* The store is the verb ledger, the decision rows, the run rows and the
|
|
5
|
+
* material-event ledger (#579); a restore replaces the live file wholesale
|
|
6
|
+
* from a snapshot the snapshot primitive produced. It refuses to run while a
|
|
7
|
+
* daemon is live — overwriting the store a live dispatch loop is writing is a
|
|
8
|
+
* corruption path, and `livingDaemon()` is the existing detect for it — and
|
|
9
|
+
* replaces the file atomically (temp file in the same directory, then rename),
|
|
10
|
+
* then drops any stale `-wal`/`-shm` sidecars so the restored database opens
|
|
11
|
+
* cleanly. The pre-restore `conductor.db-wal`/`-shm` are gone by design: the
|
|
12
|
+
* old WAL belongs to the file being replaced.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { copyFileSync, existsSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import type { CommandContext } from "./context.ts";
|
|
21
|
+
import { dbBackupDirFor, loadConfig, stateDir } from "../config.ts";
|
|
22
|
+
import { livingDaemon } from "../lifecycle.ts";
|
|
23
|
+
import { DB_SNAPSHOT_STEM, dbPath } from "../store.ts";
|
|
24
|
+
|
|
25
|
+
const RESTORE_DB_USAGE = `omp-conductor restore-db [SNAPSHOT]
|
|
26
|
+
|
|
27
|
+
Put conductor.db back to a restorable snapshot, replacing the live file
|
|
28
|
+
wholesale and dropping stale -wal/-shm sidecars so the result opens cleanly.
|
|
29
|
+
|
|
30
|
+
Refuses to run while a daemon is live — restoring under a running daemon is a
|
|
31
|
+
corruption path, not an interruption. Stop the daemon first
|
|
32
|
+
(\`omp-conductor stop\`).
|
|
33
|
+
|
|
34
|
+
usage:
|
|
35
|
+
omp-conductor restore-db restore the newest snapshot in the
|
|
36
|
+
configured db backup directory
|
|
37
|
+
omp-conductor restore-db PATH restore this exact snapshot file
|
|
38
|
+
|
|
39
|
+
SNAPSHOT is a file the store snapshot primitive produced. A relative PATH is
|
|
40
|
+
resolved against the working directory.
|
|
41
|
+
|
|
42
|
+
Before restoring, the command verifies the restored database reads cleanly
|
|
43
|
+
(PRAGMA integrity_check) and prints the verb_ledger / decisions / runs counts.`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The newest snapshot already published under `dir`, by mtime — the default
|
|
47
|
+
* restore source. Undefined when none exists (a fresh store with no snapshot).
|
|
48
|
+
*/
|
|
49
|
+
function newestSnapshot(dir: string): string | undefined {
|
|
50
|
+
let names: string[];
|
|
51
|
+
try {
|
|
52
|
+
names = readdirSync(dir).filter((name) => name.startsWith(DB_SNAPSHOT_STEM));
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (names.length === 0) return undefined;
|
|
57
|
+
const withMtime = names
|
|
58
|
+
.map((name) => ({ name, mtime: statSync(join(dir, name)).mtimeMs }))
|
|
59
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
60
|
+
return join(dir, withMtime[0]!.name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Replace `conductor.db` with `snapshot`, atomically, dropping stale sidecars. */
|
|
64
|
+
function overwriteStoreFrom(snapshot: string): void {
|
|
65
|
+
const target = dbPath();
|
|
66
|
+
const temporary = join(stateDir(), `.${DB_SNAPSHOT_STEM}-restore.${process.pid}.${randomUUID()}.tmp`);
|
|
67
|
+
copyFileSync(snapshot, temporary);
|
|
68
|
+
renameSync(temporary, target);
|
|
69
|
+
rmSync(`${target}-wal`, { force: true });
|
|
70
|
+
rmSync(`${target}-shm`, { force: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RowsRead {
|
|
74
|
+
integrity: string;
|
|
75
|
+
runs: number;
|
|
76
|
+
decisions: number;
|
|
77
|
+
verbLedger: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read the restored store back — the proof the restore landed cleanly. */
|
|
81
|
+
function readRestoredStore(target: string): RowsRead {
|
|
82
|
+
const db = new Database(target);
|
|
83
|
+
try {
|
|
84
|
+
const integrity = (db.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get() as {
|
|
85
|
+
integrity_check: string;
|
|
86
|
+
}).integrity_check;
|
|
87
|
+
const count = (table: string): number =>
|
|
88
|
+
(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
|
|
89
|
+
return { integrity, runs: count("runs"), decisions: count("decisions"), verbLedger: count("verb_ledger") };
|
|
90
|
+
} finally {
|
|
91
|
+
db.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function restoreDbCommand(ctx: CommandContext): Promise<void> {
|
|
96
|
+
// Help first: parsing stops before any config read or liveness check.
|
|
97
|
+
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
98
|
+
process.stdout.write(`${RESTORE_DB_USAGE}\n`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Host-scoped: the store is one host-wide file, so --project cannot narrow it.
|
|
102
|
+
if (ctx.projectFlag !== undefined) {
|
|
103
|
+
process.stderr.write(`omp-conductor: restore-db does not take a project — the store is host-wide\n`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
const positionals = ctx.argv.slice(1).filter((a) => !a.startsWith("--"));
|
|
107
|
+
const flags = ctx.argv.slice(1).filter((a) => a.startsWith("--"));
|
|
108
|
+
if (flags.length > 0) {
|
|
109
|
+
process.stderr.write(`omp-conductor: restore-db: unexpected argument "${flags[0]}"\n`);
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
if (positionals.length > 1) {
|
|
113
|
+
process.stderr.write(`omp-conductor: restore-db takes at most one snapshot path\n`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The corruption-path guard: never restore under a daemon that is writing.
|
|
118
|
+
const daemon = livingDaemon();
|
|
119
|
+
if (daemon !== undefined) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`restore-db refuses while a daemon is running (pid ${daemon.pid}) — it would overwrite the store a live dispatch is writing; stop it first (\`omp-conductor stop\`)`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cfg = loadConfig();
|
|
126
|
+
const snapDir = dbBackupDirFor(cfg);
|
|
127
|
+
const requested = positionals[0];
|
|
128
|
+
const snapshot = requested === undefined ? newestSnapshot(snapDir) : resolve(requested);
|
|
129
|
+
if (snapshot === undefined) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`no conductor.db snapshot in ${snapDir} — take one (the snapshot primitive) or pass an explicit snapshot path`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(snapshot)) {
|
|
135
|
+
throw new Error(`snapshot ${snapshot} does not exist`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
overwriteStoreFrom(snapshot);
|
|
139
|
+
const restored = readRestoredStore(dbPath());
|
|
140
|
+
const integrityLine =
|
|
141
|
+
restored.integrity === "ok" ? "integrity ok" : `integrity: ${restored.integrity}`;
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
`restored conductor.db from ${snapshot}\n` +
|
|
144
|
+
`\u2003${integrityLine} · runs ${restored.runs} · decisions ${restored.decisions} · verb_ledger ${restored.verbLedger}\n`,
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/commands/stop.ts
CHANGED
|
@@ -7,45 +7,77 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
|
+
import { buildStopProvenance, liveWorkload, refuseBusyDaemon } from "../stop-provenance.ts";
|
|
10
11
|
import { hold, pinPaneHalt, stopConductorPane } from "../fleet.ts";
|
|
11
12
|
import { stopDaemon } from "../lifecycle.ts";
|
|
13
|
+
import { openStore, dbPath } from "../store.ts";
|
|
12
14
|
|
|
13
15
|
export async function stopCommand(ctx: CommandContext): Promise<void> {
|
|
14
16
|
const withPane = ctx.argv.includes("--pane");
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
17
|
+
const forced = ctx.argv.includes("--force");
|
|
18
|
+
const store = openStore(dbPath());
|
|
19
|
+
try {
|
|
20
|
+
// Refusal (#545): the shared daemon serves every configured project, so a
|
|
21
|
+
// stop orphans any project's live workers — not just the one named. Refuse
|
|
22
|
+
// while anything is live, naming project + issues, before any hold/disarm/
|
|
23
|
+
// pin side-effect, so a refused stop leaves the fleet untouched. `--force`
|
|
24
|
+
// skips the whole gate (so a wedged daemon stays stoppable even when the
|
|
25
|
+
// store is unreadable) and the override is recorded in the provenance.
|
|
26
|
+
if (!forced) refuseBusyDaemon(liveWorkload(store), { override: false, verb: "stop" });
|
|
27
|
+
const targets = ctx.targetProjects().map((project) => {
|
|
28
|
+
// `stop` takes the fleet down, so it always disarms — `--keep-ticks` is a
|
|
29
|
+
// `hold` affordance. Assert the invariant instead of printing a path that
|
|
30
|
+
// might not exist.
|
|
31
|
+
const held = hold(project.name, "halt");
|
|
32
|
+
if (held.disarmed === undefined) throw new Error("stop must disarm ticks");
|
|
33
|
+
return {
|
|
34
|
+
project,
|
|
35
|
+
hold: { ...held, disarmed: held.disarmed },
|
|
36
|
+
pin: withPane ? pinPaneHalt(project.name).path : undefined,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
// Provenance (#378): the shared daemon serves every configured project, so
|
|
40
|
+
// the record names the request's own project (or the global scope) AND every
|
|
41
|
+
// sibling with its live-run count — no silent cross-project stop. A forced
|
|
42
|
+
// stop keeps the override visible in the control path, never silent.
|
|
43
|
+
const provenance = buildStopProvenance({
|
|
44
|
+
controlPath: forced ? "cli stop --force" : "cli stop",
|
|
45
|
+
reason: "operator stop",
|
|
46
|
+
scope: targets.length > 1 ? "global" : "project",
|
|
47
|
+
project: targets.length === 1 ? targets[0]!.project.name : undefined,
|
|
48
|
+
});
|
|
49
|
+
const stop = await stopDaemon({
|
|
50
|
+
provenance,
|
|
51
|
+
// The durable row is written by the lifecycle at the exact chokepoint —
|
|
52
|
+
// immediately before systemctl/SIGTERM, with the delivery method that is
|
|
53
|
+
// about to be used — which is what makes "provenance precedes signalling"
|
|
54
|
+
// provable rather than claimed.
|
|
55
|
+
record: (signal) => store.recordDaemonStop(signal.provenance),
|
|
56
|
+
});
|
|
57
|
+
const stopLine =
|
|
58
|
+
stop.kind === "not-running"
|
|
59
|
+
? "daemon was not running"
|
|
60
|
+
: `daemon stopped — pid ${stop.pid}${stop.via === "systemctl" ? " (via systemctl)" : ""}`;
|
|
61
|
+
for (const target of targets) {
|
|
62
|
+
if (target.pin !== undefined) {
|
|
63
|
+
const pane = await stopConductorPane(target.project.name);
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
66
|
+
`${stopLine}\n` +
|
|
67
|
+
`pane recovery pinned at ${target.pin}\n` +
|
|
68
|
+
`pane stop: ${pane.stopped} — ${pane.detail}\n` +
|
|
69
|
+
` (conductor agent "${pane.agentName}" only — herdr-fleet.service was NOT stopped;\n` +
|
|
70
|
+
` resume clears the pin when you want recovery again)\n`,
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
process.stdout.write(
|
|
74
|
+
`stopped — claiming paused; ticks disarmed at ${target.hold.disarmed.path}\n` +
|
|
75
|
+
`${stopLine}\n` +
|
|
76
|
+
`pane left running (pass --pane to stop the conductor agent and pin recovery)\n`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
49
79
|
}
|
|
80
|
+
} finally {
|
|
81
|
+
store.close();
|
|
50
82
|
}
|
|
51
83
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unfreeze <repo>` — the operator's sanctioned override for a base-red-freeze.
|
|
3
|
+
*
|
|
4
|
+
* A base-red-freeze is a mechanical gate: `prMergeVerb` refuses every
|
|
5
|
+
* `conductor_pr_merge` to a repo whose base is red, and the floor binds even
|
|
6
|
+
* the orchestrator to that refusal. This verb is the *one* sanctioned way to
|
|
7
|
+
* lift it early — after the operator has judged the base repaired (or the
|
|
8
|
+
* merge warranted anyway) — and it is ledger-recorded: the freeze row keeps
|
|
9
|
+
* `clearedBy`/`clearedReason`/`clearedAt` as its durable override audit, and a
|
|
10
|
+
* material event names the override for the queue digest. It is deliberately
|
|
11
|
+
* not label surgery or a raw DB edit, and it does not re-arm — a still-red base
|
|
12
|
+
* will re-freeze the repo on the next watch observation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CommandContext } from "./context.ts";
|
|
16
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
17
|
+
import { dbPath, openStore } from "../store.ts";
|
|
18
|
+
|
|
19
|
+
export async function unfreezeCommand(ctx: CommandContext): Promise<void> {
|
|
20
|
+
const repo = ctx.argv[1];
|
|
21
|
+
if (repo === undefined || repo.length === 0 || repo.startsWith("-")) {
|
|
22
|
+
process.stderr.write("omp-conductor: unfreeze needs the routed repo name, e.g. `omp-conductor unfreeze api`\n");
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
const reason = ctx.flag("--reason") ?? "operator-override";
|
|
26
|
+
const cfg = loadConfig();
|
|
27
|
+
const project = findProject(cfg, ctx.projectFlag);
|
|
28
|
+
const store = openStore(dbPath());
|
|
29
|
+
try {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const lifted = store.clearBaseFreeze(project.name, repo, "operator", reason, now);
|
|
32
|
+
if (!lifted) {
|
|
33
|
+
process.stdout.write(
|
|
34
|
+
`unfreeze: ${repo} has no active base-red freeze in ${project.name} (merges already allowed). Nothing to lift.\n`,
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
store.recordMaterialEvent({
|
|
39
|
+
project: project.name,
|
|
40
|
+
category: "unfreeze",
|
|
41
|
+
summary: `operator unfroze merges to ${repo} (${reason})`,
|
|
42
|
+
evidence:
|
|
43
|
+
`An operator lifted the base-red-freeze on ${repo} with reason "${reason}". ` +
|
|
44
|
+
"This is the sanctioned override path — the freeze re-arms automatically if the base is " +
|
|
45
|
+
"observed red again.",
|
|
46
|
+
occurredAt: now,
|
|
47
|
+
recordedAt: now,
|
|
48
|
+
});
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`unfreeze: ${repo} base-red freeze lifted in ${project.name} (reason: ${reason}). ` +
|
|
51
|
+
"Merges to this repo resume; a still-red base will re-freeze on the next watch observation.\n",
|
|
52
|
+
);
|
|
53
|
+
} finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `watch` — set a condition for the orchestrator itself, with no human in the
|
|
3
|
+
* loop (#459).
|
|
4
|
+
*
|
|
5
|
+
* The split exists because the old `decision open --resolves-when` kept
|
|
6
|
+
* producing rows rendered as questions put to the operator — the fleet looked
|
|
7
|
+
* like it was waiting on a person when it was waiting on GitHub. A watch is a
|
|
8
|
+
* row the orchestrator opened for itself: either a condition the daemon checks
|
|
9
|
+
* (`--resolves-when`) that wakes the next tick when met, or a carry note the
|
|
10
|
+
* next tick should read, neither of which ever needs an operator answer. It is
|
|
11
|
+
* distinguished durably by its `kind`, never by whether it carries a
|
|
12
|
+
* condition — a real question may carry one too.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CommandContext } from "./context.ts";
|
|
16
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
17
|
+
import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
|
|
18
|
+
import { dbPath, openStore } from "../store.ts";
|
|
19
|
+
|
|
20
|
+
export async function watchCommand(ctx: CommandContext): Promise<void> {
|
|
21
|
+
const sub = ctx.argv[1];
|
|
22
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
23
|
+
const store = openStore(dbPath());
|
|
24
|
+
try {
|
|
25
|
+
if (sub === "add") {
|
|
26
|
+
const note = ctx.flag("note")?.trim();
|
|
27
|
+
if (note === undefined || note.length === 0 || note.startsWith("--")) {
|
|
28
|
+
process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
const condition = ctx.flag("resolves-when")?.trim();
|
|
32
|
+
if (condition !== undefined && parseCondition(condition) === undefined) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
|
|
35
|
+
);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
const blocks = ctx.flag("blocks")?.trim();
|
|
39
|
+
const watch = store.createDecision({
|
|
40
|
+
project: project.name,
|
|
41
|
+
question: note,
|
|
42
|
+
kind: "watch",
|
|
43
|
+
...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
|
|
44
|
+
...(condition === undefined ? {} : { condition }),
|
|
45
|
+
at: Date.now(),
|
|
46
|
+
});
|
|
47
|
+
const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
|
|
48
|
+
process.stdout.write(`watch ${watch.id} added — ${wake} (no operator answer needed)\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === "list" || sub === undefined) {
|
|
53
|
+
const watches = store.openDecisions(project.name).filter((d) => d.kind === "watch");
|
|
54
|
+
if (watches.length === 0) {
|
|
55
|
+
process.stdout.write("no watches\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const d of watches) {
|
|
60
|
+
const condition =
|
|
61
|
+
d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
|
|
62
|
+
const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
`${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`omp-conductor: unknown watch subcommand "${sub}" — expected add or list\n`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
} finally {
|
|
75
|
+
store.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -321,6 +321,10 @@ const projectSchema = z
|
|
|
321
321
|
// usable, exactly like `workerModel`.
|
|
322
322
|
modelFallbacks: z.unknown().optional(),
|
|
323
323
|
modelFallbackThreshold: z.unknown().optional(),
|
|
324
|
+
// The fleet-owned omp settings overlay (#537): an opaque map omp's own
|
|
325
|
+
// schema owns. Conductor validates YAML shape only — the loader keeps it
|
|
326
|
+
// when it is a mapping and drops anything else, like `modelFallbacks`.
|
|
327
|
+
ompSettings: z.unknown().optional(),
|
|
324
328
|
escalation: escalationSchema.optional(),
|
|
325
329
|
authority: authoritySchema.optional(),
|
|
326
330
|
releasePolicy: releasePolicySchema.optional(),
|
|
@@ -344,6 +348,11 @@ const configSchema = z
|
|
|
344
348
|
$schema: z.string().optional().describe("Path to the shipped config.schema.json"),
|
|
345
349
|
version: z.number().describe(`The config format version (${READABLE_CONFIG_VERSIONS.join(" or ")})`),
|
|
346
350
|
defaults: capsSchema.optional(),
|
|
351
|
+
dbBackupDir: z
|
|
352
|
+
.string()
|
|
353
|
+
.min(1, "must name a directory")
|
|
354
|
+
.optional()
|
|
355
|
+
.describe("Absolute directory for restorable conductor.db snapshots; defaults to <stateDir>/backups/db"),
|
|
347
356
|
projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`),
|
|
348
357
|
})
|
|
349
358
|
.loose()
|
package/src/config.ts
CHANGED
|
@@ -124,6 +124,18 @@ export function configBackupDir(): string {
|
|
|
124
124
|
return join(stateDir(), "backups", "config");
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Where restorable `conductor.db` snapshots land: the configured `dbBackupDir`,
|
|
129
|
+
* or the state-root default `<stateDir()>/backups/db` beside the config
|
|
130
|
+
* backups when the field is absent. A `~/`-prefixed configured value is
|
|
131
|
+
* expanded like every other hand-written path field.
|
|
132
|
+
*/
|
|
133
|
+
export function dbBackupDirFor(cfg: Pick<ConductorConfig, "dbBackupDir"> | undefined): string {
|
|
134
|
+
const configured = cfg?.dbBackupDir;
|
|
135
|
+
const dir = configured !== undefined && configured.trim() !== "" ? expandHome(configured) : join(stateDir(), "backups", "db");
|
|
136
|
+
return dir;
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
/**
|
|
128
140
|
* Where per-run worktrees and bare mirrors live when a project names neither.
|
|
129
141
|
*
|
|
@@ -1099,6 +1111,17 @@ function finalizeProject(
|
|
|
1099
1111
|
? rawThreshold
|
|
1100
1112
|
: undefined;
|
|
1101
1113
|
|
|
1114
|
+
// The fleet-owned omp settings overlay (#537): an opaque map omp's own schema
|
|
1115
|
+
// owns, so the loader validates YAML shape only — a non-mapping is dropped
|
|
1116
|
+
// like an unusable `modelFallbacks` entry rather than failing the load, and
|
|
1117
|
+
// an absent field keeps today's dispatch byte for byte. Everything inside the
|
|
1118
|
+
// map is omp's to interpret, never conductor's.
|
|
1119
|
+
const rawOmpSettings = p["ompSettings"];
|
|
1120
|
+
const ompSettings =
|
|
1121
|
+
typeof rawOmpSettings === "object" && rawOmpSettings !== null && !Array.isArray(rawOmpSettings)
|
|
1122
|
+
? (rawOmpSettings as Record<string, unknown>)
|
|
1123
|
+
: undefined;
|
|
1124
|
+
|
|
1102
1125
|
// Marked critical-base/safety markers (commit SHAs or refs). A continuation
|
|
1103
1126
|
// branch must contain every one before the dispatcher may reattach it;
|
|
1104
1127
|
// unusable entries are dropped like `modelFallbacks` and an absent or empty
|
|
@@ -1128,6 +1151,7 @@ function finalizeProject(
|
|
|
1128
1151
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
1129
1152
|
...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
|
|
1130
1153
|
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1154
|
+
...(ompSettings === undefined ? {} : { ompSettings }),
|
|
1131
1155
|
escalation,
|
|
1132
1156
|
authority,
|
|
1133
1157
|
releasePolicy,
|