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.
- package/README.md +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release-composition` — the operator's explicit lifecycle for a project's
|
|
3
|
+
* active release (#850).
|
|
4
|
+
*
|
|
5
|
+
* A reliability patch is only trustworthy while nothing unrelated slips into
|
|
6
|
+
* it, so a project can declare one active release composition: a campaign
|
|
7
|
+
* identifier plus the exact PRs allowed into it. While one is active,
|
|
8
|
+
* `conductor_pr_merge` refuses every other PR with the
|
|
9
|
+
* `outside-active-release` refusal — this command family is the only way to
|
|
10
|
+
* move that guard: `declare` it, admit exactly one extra PR with `override`,
|
|
11
|
+
* or retire it with `complete` / `cancel`. Every transition is written to the
|
|
12
|
+
* store as durable state that survives daemon restarts and recorded as an
|
|
13
|
+
* immutable material event as its audit trail; a merge call's free-form
|
|
14
|
+
* reason is never an override.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CommandContext } from "./context.ts";
|
|
18
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
19
|
+
import { dbPath, openStore } from "../store.ts";
|
|
20
|
+
|
|
21
|
+
/** Every `--pr URL` / `--pr=URL` value in the invocation tail, in order. */
|
|
22
|
+
function prUrls(argv: readonly string[]): string[] {
|
|
23
|
+
const urls: string[] = [];
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const token = argv[i]!;
|
|
26
|
+
if (token === "--pr") {
|
|
27
|
+
const value = argv[i + 1];
|
|
28
|
+
if (value !== undefined && !value.startsWith("-")) {
|
|
29
|
+
urls.push(value);
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
} else if (token.startsWith("--pr=")) {
|
|
33
|
+
urls.push(token.slice("--pr=".length));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return urls;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A full pull-request URL — the exact shape the merge gate compares against. */
|
|
40
|
+
function isFullPrUrl(url: string): boolean {
|
|
41
|
+
return /^https:\/\/[^/]+\/.+\/pull\/\d+$/.test(url);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const USAGE_LINE =
|
|
45
|
+
"omp-conductor: release-composition needs a subcommand: " +
|
|
46
|
+
"declare | override | complete | cancel | status\n";
|
|
47
|
+
|
|
48
|
+
export async function releaseCompositionCommand(ctx: CommandContext): Promise<void> {
|
|
49
|
+
const sub = ctx.argv[1];
|
|
50
|
+
if (sub === undefined || sub.length === 0 || sub.startsWith("-")) {
|
|
51
|
+
process.stderr.write(USAGE_LINE);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Argument shape is validated before anything opens: a malformed invocation
|
|
56
|
+
// never touches the store, and every store-backed refusal below still runs
|
|
57
|
+
// the `close()` in the finally before the process exits.
|
|
58
|
+
let campaign: string | undefined;
|
|
59
|
+
let urls: string[] = [];
|
|
60
|
+
if (sub === "declare") {
|
|
61
|
+
campaign = ctx.flag("campaign");
|
|
62
|
+
if (campaign === undefined || campaign.length === 0) {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
"omp-conductor: release-composition declare needs --campaign ID, e.g. v0.18.1-reliability\n",
|
|
65
|
+
);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
urls = prUrls(ctx.argv.slice(2));
|
|
69
|
+
const malformed = urls.filter((url) => !isFullPrUrl(url));
|
|
70
|
+
if (malformed.length > 0) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
"omp-conductor: --pr takes full pull-request URLs like https://github.com/owner/repo/pull/7; " +
|
|
73
|
+
`refusing: ${malformed.join(", ")}\n`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (sub === "override") {
|
|
79
|
+
urls = prUrls(ctx.argv.slice(2));
|
|
80
|
+
if (urls.length !== 1 || !isFullPrUrl(urls[0]!)) {
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
"omp-conductor: release-composition override needs exactly one full --pr URL " +
|
|
83
|
+
"(https://github.com/owner/repo/pull/N)\n",
|
|
84
|
+
);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cfg = loadConfig();
|
|
90
|
+
const project = findProject(cfg, ctx.projectFlag);
|
|
91
|
+
const store = openStore(dbPath());
|
|
92
|
+
let failed = false;
|
|
93
|
+
try {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
switch (sub) {
|
|
96
|
+
case "status": {
|
|
97
|
+
const active = store.activeReleaseComposition(project.name);
|
|
98
|
+
if (active === undefined) {
|
|
99
|
+
process.stdout.write(
|
|
100
|
+
`${project.name}: no active release composition — merges are ungated by release scope.\n`,
|
|
101
|
+
);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const overrides = store.releaseCompositionOverrides(project.name, active.campaign);
|
|
105
|
+
process.stdout.write(
|
|
106
|
+
`${project.name}: active release "${active.campaign}", declared ` +
|
|
107
|
+
`${new Date(active.declaredAt).toISOString()}${active.declaredBy ? ` by ${active.declaredBy}` : ""}. ` +
|
|
108
|
+
`Allowed PRs (${active.allowedPrUrls.length}): ` +
|
|
109
|
+
`${active.allowedPrUrls.length === 0 ? "none yet" : active.allowedPrUrls.join(", ")}. ` +
|
|
110
|
+
`Operator overrides: ${overrides.length === 0 ? "none" : overrides.map((o) => o.prUrl).join(", ")}. ` +
|
|
111
|
+
"conductor_pr_merge refuses every other PR until complete|cancel.\n",
|
|
112
|
+
);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "declare": {
|
|
116
|
+
const conflict = store.declareReleaseComposition(project.name, {
|
|
117
|
+
campaign: campaign!,
|
|
118
|
+
allowedPrUrls: urls,
|
|
119
|
+
declaredBy: "operator",
|
|
120
|
+
declaredAt: now,
|
|
121
|
+
});
|
|
122
|
+
if (conflict !== undefined) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`release-composition: ${project.name} already has an active release "${conflict.campaign}" ` +
|
|
125
|
+
`(declared ${new Date(conflict.declaredAt).toISOString()}, ${conflict.allowedPrUrls.length} PR(s) allowed). ` +
|
|
126
|
+
"One release at a time: complete or cancel it first with " +
|
|
127
|
+
"`omp-conductor release-composition complete|cancel`.\n",
|
|
128
|
+
);
|
|
129
|
+
failed = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
store.recordMaterialEvent({
|
|
133
|
+
project: project.name,
|
|
134
|
+
category: "release-composition",
|
|
135
|
+
summary: `operator declared release ${campaign} (${urls.length} PR(s) allowed)`,
|
|
136
|
+
evidence:
|
|
137
|
+
`An operator declared the active release composition "${campaign}" for ${project.name} with ` +
|
|
138
|
+
`${urls.length} allowed PR(s): ${urls.length === 0 ? "none yet" : urls.join(", ")}. While it is ` +
|
|
139
|
+
"active, conductor_pr_merge refuses every other PR with outside-active-release until the " +
|
|
140
|
+
"release completes, is cancelled, or a PR is explicitly overridden.",
|
|
141
|
+
occurredAt: now,
|
|
142
|
+
recordedAt: now,
|
|
143
|
+
});
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
`release-composition: ${project.name} now assembling release "${campaign}" with ` +
|
|
146
|
+
`${urls.length} allowed PR(s). Unrelated merges are refused until complete|cancel.\n`,
|
|
147
|
+
);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "override": {
|
|
151
|
+
const active = store.activeReleaseComposition(project.name);
|
|
152
|
+
if (active === undefined) {
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
`release-composition: ${project.name} has no active release to override — ` +
|
|
155
|
+
"merges are already ungated by release scope.\n",
|
|
156
|
+
);
|
|
157
|
+
failed = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const reason = ctx.flag("reason") ?? "operator-override";
|
|
161
|
+
const granted = store.grantReleaseCompositionOverride(project.name, {
|
|
162
|
+
campaign: active.campaign,
|
|
163
|
+
prUrl: urls[0]!,
|
|
164
|
+
by: "operator",
|
|
165
|
+
reason,
|
|
166
|
+
at: now,
|
|
167
|
+
});
|
|
168
|
+
store.recordMaterialEvent({
|
|
169
|
+
project: project.name,
|
|
170
|
+
category: "release-composition",
|
|
171
|
+
summary: `operator overrode release ${active.campaign} for ${granted.prUrl}`,
|
|
172
|
+
evidence:
|
|
173
|
+
`An operator admitted ${granted.prUrl} into the active release "${active.campaign}" of ` +
|
|
174
|
+
`${project.name} with reason "${reason}". The override bypasses only the composition check — ` +
|
|
175
|
+
"authority, pause, base-red-freeze, exact-head, green-checks and single-flight still apply.",
|
|
176
|
+
occurredAt: now,
|
|
177
|
+
recordedAt: now,
|
|
178
|
+
});
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
`release-composition: ${granted.prUrl} is now allowed into release "${active.campaign}" ` +
|
|
181
|
+
`in ${project.name} (reason: ${reason}). Every other merge gate still applies.\n`,
|
|
182
|
+
);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "complete":
|
|
186
|
+
case "cancel": {
|
|
187
|
+
const retired =
|
|
188
|
+
sub === "complete"
|
|
189
|
+
? store.completeReleaseComposition(
|
|
190
|
+
project.name,
|
|
191
|
+
"operator",
|
|
192
|
+
ctx.flag("reason") ?? "operator-completed",
|
|
193
|
+
now,
|
|
194
|
+
)
|
|
195
|
+
: store.cancelReleaseComposition(
|
|
196
|
+
project.name,
|
|
197
|
+
"operator",
|
|
198
|
+
ctx.flag("reason") ?? "operator-cancelled",
|
|
199
|
+
now,
|
|
200
|
+
);
|
|
201
|
+
if (!retired) {
|
|
202
|
+
process.stdout.write(
|
|
203
|
+
`${project.name}: no active release composition — nothing to ${sub}. Merges are ungated by release scope.\n`,
|
|
204
|
+
);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
store.recordMaterialEvent({
|
|
208
|
+
project: project.name,
|
|
209
|
+
category: "release-composition",
|
|
210
|
+
summary: `operator ${sub === "complete" ? "completed" : "cancelled"} the active release`,
|
|
211
|
+
evidence:
|
|
212
|
+
`An operator ${sub}d the active release composition of ${project.name} with reason ` +
|
|
213
|
+
`"${ctx.flag("reason") ?? `operator-${sub}d`}". conductor_pr_merge merges are no longer ` +
|
|
214
|
+
"restricted by that release's scope; the audit trail keeps the closed row and its overrides.",
|
|
215
|
+
occurredAt: now,
|
|
216
|
+
recordedAt: now,
|
|
217
|
+
});
|
|
218
|
+
process.stdout.write(
|
|
219
|
+
`release-composition: ${project.name}'s active release ${sub}d. Merges are no longer gated ` +
|
|
220
|
+
"by release scope; overrides stay in the audit trail scoped to their campaign.\n",
|
|
221
|
+
);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
process.stderr.write(USAGE_LINE);
|
|
226
|
+
process.exit(2);
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
store.close();
|
|
230
|
+
}
|
|
231
|
+
if (failed) process.exit(2);
|
|
232
|
+
}
|
package/src/commands/resume.ts
CHANGED
|
@@ -13,32 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { CommandContext } from "./context.ts";
|
|
15
15
|
import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
/** Wakes a running daemon, returning the human line that states what happened.
|
|
19
|
-
* State-only clients (no live daemon) get an honest fallback rather than an
|
|
20
|
-
* error: the hold is already cleared and the next pass or daemon start
|
|
21
|
-
* claims without this poke. */
|
|
22
|
-
async function wakeDaemon(projectName: string): Promise<string> {
|
|
23
|
-
const daemon = livingDaemon();
|
|
24
|
-
if (daemon === undefined) {
|
|
25
|
-
return "daemon not running — claiming starts when the daemon next starts or ticks";
|
|
26
|
-
}
|
|
27
|
-
let response: Response;
|
|
28
|
-
try {
|
|
29
|
-
response = await fetch(`http://127.0.0.1:${daemon.port}/wake`, {
|
|
30
|
-
method: "POST",
|
|
31
|
-
headers: { "content-type": "application/json" },
|
|
32
|
-
body: JSON.stringify({ project: projectName }),
|
|
33
|
-
});
|
|
34
|
-
} catch {
|
|
35
|
-
return "daemon wake failed — daemon unreachable; the next scheduled pass will claim";
|
|
36
|
-
}
|
|
37
|
-
if (!response.ok) {
|
|
38
|
-
return `daemon wake failed (HTTP ${response.status}); the next scheduled pass will claim`;
|
|
39
|
-
}
|
|
40
|
-
return "daemon dispatch loop woken — claiming starts on an immediate pass";
|
|
41
|
-
}
|
|
16
|
+
import { wakeDispatch } from "../wake.ts";
|
|
42
17
|
|
|
43
18
|
export async function resumeCommand(ctx: CommandContext): Promise<void> {
|
|
44
19
|
for (const project of ctx.targetProjects()) {
|
|
@@ -46,7 +21,7 @@ for (const project of ctx.targetProjects()) {
|
|
|
46
21
|
const pin = clearPaneHaltIfResolvable(project.name);
|
|
47
22
|
const arm = armState(project.name);
|
|
48
23
|
process.stdout.write(
|
|
49
|
-
`resumed — claiming allowed — ${await
|
|
24
|
+
`resumed — claiming allowed — ${await wakeDispatch(project.name)}\n` +
|
|
50
25
|
(pin.wasHalted
|
|
51
26
|
? `pane recovery pin cleared — ${pin.path}\n`
|
|
52
27
|
: "no pane recovery pin to clear\n") +
|
package/src/commands/setup.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* changed from the original bodies.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import type { CommandContext } from "./context.ts";
|
|
14
14
|
import type { CompletionShell } from "./complete.ts";
|
|
15
|
-
import { findProject, loadConfig, resolveCaps } from "../config.ts";
|
|
15
|
+
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "../config.ts";
|
|
16
16
|
import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
|
|
17
17
|
import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
|
|
18
18
|
import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
|
|
@@ -21,8 +21,11 @@ import {
|
|
|
21
21
|
answersUi,
|
|
22
22
|
guardNonInteractiveUi,
|
|
23
23
|
loadAnswersFile,
|
|
24
|
+
readSetupResume,
|
|
24
25
|
recordingAnswersUi,
|
|
25
26
|
saveAnswersFile,
|
|
27
|
+
saveSetupResume,
|
|
28
|
+
setupConfigHash,
|
|
26
29
|
type RecordedAnswersUi,
|
|
27
30
|
} from "../setup-answers.ts";
|
|
28
31
|
import { terminalUi, type WizardUi } from "../wizard-ui.ts";
|
|
@@ -39,7 +42,7 @@ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json,
|
|
|
39
42
|
and the staged host files behind one confirm.
|
|
40
43
|
|
|
41
44
|
usage:
|
|
42
|
-
omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]
|
|
45
|
+
omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--resume FILE] [--project NAME]
|
|
43
46
|
omp-conductor setup host [NAME] [--project NAME]
|
|
44
47
|
omp-conductor setup graph [--no-seed] [--print] [--project NAME]
|
|
45
48
|
|
|
@@ -57,7 +60,9 @@ flags:
|
|
|
57
60
|
for \`setup host\`)
|
|
58
61
|
--no-ai ask every question, propose nothing (no AI repo reads)
|
|
59
62
|
--answers FILE answer every prompt from JSON; never opens a prompt
|
|
60
|
-
--save-answers FILE save successful prompt answers as replayable JSON
|
|
63
|
+
--save-answers FILE save successful prompt answers as replayable JSON
|
|
64
|
+
--resume FILE replay the answers a failed apply kept, bound to the
|
|
65
|
+
config they were given against; refuses a changed config`;
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
68
|
* Resolve the project an install subcommand (`setup host`, `setup graph`)
|
|
@@ -126,7 +131,37 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
|
|
|
126
131
|
ui.notify(`Installed completions at ${installed.scriptPath}; sourced from ${installed.rcPath}.`);
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
/** This build's version — the schema fence a resume file is checked against. */
|
|
135
|
+
const PACKAGE_VERSION: string = ((): string => {
|
|
136
|
+
try {
|
|
137
|
+
const raw: unknown = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"));
|
|
138
|
+
const version = raw !== null && typeof raw === "object" ? Reflect.get(raw, "version") : undefined;
|
|
139
|
+
return typeof version === "string" && version.length > 0 ? version : "unknown";
|
|
140
|
+
} catch {
|
|
141
|
+
return "unknown";
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
|
|
145
|
+
/** The live config generation a resume file must match, or `absent`. */
|
|
146
|
+
function liveSetupConfigHash(): string {
|
|
147
|
+
return setupConfigHash(() => {
|
|
148
|
+
try {
|
|
149
|
+
return readFileSync(configPath(), "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Where a failed post-interview apply leaves its answers (#864), per project so
|
|
158
|
+
* two fleets on one host cannot resume each other's interview.
|
|
159
|
+
*/
|
|
160
|
+
export function setupResumePath(project: string | undefined): string {
|
|
161
|
+
return join(stateDir(), `setup-resume-${project ?? "host"}.json`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers" | "--resume"): string | undefined {
|
|
130
165
|
const inline = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
131
166
|
const at = argv.indexOf(name);
|
|
132
167
|
if (inline !== undefined && at !== -1) throw new Error(`${name} may be passed only once`);
|
|
@@ -154,18 +189,42 @@ const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
|
|
|
154
189
|
// protocol, while --answers installs a prompt-free driver.
|
|
155
190
|
const answersPath = setupValueFlag(ctx.argv, "--answers");
|
|
156
191
|
const saveAnswersPath = setupValueFlag(ctx.argv, "--save-answers");
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
192
|
+
// `--resume` is `--answers` bound to the config it was answered against (#864):
|
|
193
|
+
// the apply phase failed, the interview did not, and re-typing it is the cost
|
|
194
|
+
// this flag removes. The binding is checked before a single prompt is skipped,
|
|
195
|
+
// because replaying answers over a config that changed since is how an old plan
|
|
196
|
+
// silently overwrites newer policy.
|
|
197
|
+
const resumePath = setupValueFlag(ctx.argv, "--resume");
|
|
198
|
+
const resumed =
|
|
199
|
+
resumePath === undefined
|
|
200
|
+
? undefined
|
|
201
|
+
: readSetupResume(resumePath, liveSetupConfigHash(), PACKAGE_VERSION);
|
|
202
|
+
if (resumed !== undefined && !resumed.ok) {
|
|
203
|
+
process.stderr.write(`omp-conductor: setup: ${resumed.problem}\n`);
|
|
204
|
+
process.exitCode = 2;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const scriptedAnswers = resumed?.ok === true ? resumed.file.answers : undefined;
|
|
208
|
+
const useClack = answersPath === undefined && scriptedAnswers === undefined && interactiveUi();
|
|
209
|
+
const baseUi = scriptedAnswers
|
|
210
|
+
? answersUi(scriptedAnswers)
|
|
211
|
+
: answersPath
|
|
212
|
+
? answersUi(loadAnswersFile(answersPath))
|
|
213
|
+
: useClack
|
|
214
|
+
? (await import("../clack-ui.ts")).clackUi()
|
|
215
|
+
: terminalUi();
|
|
216
|
+
const guardedUi =
|
|
217
|
+
answersPath === undefined && scriptedAnswers === undefined && !process.stdin.isTTY
|
|
218
|
+
? guardNonInteractiveUi(baseUi)
|
|
219
|
+
: baseUi;
|
|
220
|
+
// Always recording now, not only under `--save-answers`: the answers of a failed
|
|
221
|
+
// apply are exactly what the retry needs, and a flag nobody passed in advance
|
|
222
|
+
// cannot help the failure that has already happened.
|
|
223
|
+
const recording: RecordedAnswersUi = recordingAnswersUi(guardedUi);
|
|
224
|
+
const ui = recording.ui;
|
|
167
225
|
let saveCompletedAnswers = false;
|
|
168
226
|
let closeMessage = "Setup failed.";
|
|
227
|
+
let applyFailed = false;
|
|
169
228
|
try {
|
|
170
229
|
// `host` and `graph` are install subcommands, checked BEFORE the amend
|
|
171
230
|
// areas. They are not areas — routing them through the area validation
|
|
@@ -244,12 +303,38 @@ try {
|
|
|
244
303
|
}
|
|
245
304
|
saveCompletedAnswers = completed;
|
|
246
305
|
closeMessage = completed ? "Setup complete." : "Setup incomplete.";
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// The interview succeeded and the apply phase did not — the shape of the
|
|
308
|
+
// 2026-08-21 incident, where five retries re-asked every question while the
|
|
309
|
+
// real fault was a runtime gate. Keep the answers, bound to the config they
|
|
310
|
+
// were given against, and name the exact command that replays them.
|
|
311
|
+
applyFailed = true;
|
|
312
|
+
throw err;
|
|
247
313
|
} finally {
|
|
248
314
|
try {
|
|
249
|
-
if (
|
|
315
|
+
if (applyFailed && Object.keys(recording.answers).length > 0) {
|
|
316
|
+
const path = setupResumePath(ctx.projectFlag);
|
|
317
|
+
saveSetupResume(path, {
|
|
318
|
+
configHash: liveSetupConfigHash(),
|
|
319
|
+
version: PACKAGE_VERSION,
|
|
320
|
+
...(positional === undefined ? {} : { area: positional }),
|
|
321
|
+
...(ctx.projectFlag === undefined ? {} : { project: ctx.projectFlag }),
|
|
322
|
+
answers: recording.answers,
|
|
323
|
+
});
|
|
324
|
+
ui.notify(
|
|
325
|
+
`Answers kept — retry without re-answering:\n omp-conductor setup${
|
|
326
|
+
positional === undefined ? "" : ` ${positional}`
|
|
327
|
+
}${ctx.projectFlag === undefined ? "" : ` --project ${ctx.projectFlag}`} --resume ${path}`,
|
|
328
|
+
"warning",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (saveCompletedAnswers && saveAnswersPath !== undefined) {
|
|
250
332
|
saveAnswersFile(saveAnswersPath, recording.answers);
|
|
251
333
|
ui.notify(`Saved setup answers to ${saveAnswersPath}.`);
|
|
252
334
|
}
|
|
335
|
+
// A completed setup retires its own resume file: a stale one invites a
|
|
336
|
+
// replay of answers that have already been applied.
|
|
337
|
+
if (saveCompletedAnswers) rmSync(setupResumePath(ctx.projectFlag), { force: true });
|
|
253
338
|
} catch (err) {
|
|
254
339
|
closeMessage = "Setup failed.";
|
|
255
340
|
throw err;
|
package/src/commands/stats.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { CommandContext } from "./context.ts";
|
|
13
13
|
import { findProject, loadConfig } from "../config.ts";
|
|
14
14
|
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
15
|
-
import { computeStats,
|
|
15
|
+
import { type StatsWindow, computeStats, parseStatsWindow, renderStatsHuman } from "../stats.ts";
|
|
16
16
|
import { heading } from "../ui/style.ts";
|
|
17
17
|
|
|
18
18
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
@@ -48,30 +48,6 @@ flags:
|
|
|
48
48
|
When nothing settled in the window the report says so explicitly ("empty:
|
|
49
49
|
true" in --json) rather than printing zeros that could read as measurements.`;
|
|
50
50
|
|
|
51
|
-
/** `--since` to a window. The bare date form means from 00:00:00Z of that day;
|
|
52
|
-
* the duration form (`7d`) is now minus whole days. Anything else exits 2,
|
|
53
|
-
* because silently defaulting a typo'd window would measure the wrong week. */
|
|
54
|
-
function sinceWindow(raw: string, now: number): Pick<StatsWindow, "sinceDay" | "sinceEpochMs"> {
|
|
55
|
-
const duration = DURATION_FORM.exec(raw);
|
|
56
|
-
if (duration !== null) {
|
|
57
|
-
const days = Number(duration[1]);
|
|
58
|
-
if (days >= 1) {
|
|
59
|
-
const sinceEpochMs = now - days * DAY_MS;
|
|
60
|
-
return { sinceEpochMs, sinceDay: utcDay(sinceEpochMs) };
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (DATE_FORM.test(raw)) {
|
|
64
|
-
const sinceEpochMs = Date.parse(`${raw}T00:00:00Z`);
|
|
65
|
-
if (Number.isFinite(sinceEpochMs)) {
|
|
66
|
-
return { sinceEpochMs, sinceDay: raw };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
process.stderr.write(
|
|
70
|
-
`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${raw}"\n`,
|
|
71
|
-
);
|
|
72
|
-
process.exit(2);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
51
|
export async function statsCommand(ctx: CommandContext): Promise<void> {
|
|
76
52
|
// Help first: parsing stops before any config read or store open.
|
|
77
53
|
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
@@ -108,11 +84,16 @@ export async function statsCommand(ctx: CommandContext): Promise<void> {
|
|
|
108
84
|
process.stderr.write(`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD\n`);
|
|
109
85
|
process.exit(2);
|
|
110
86
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
87
|
+
// One parser for the CLI and the dashboard endpoint (#297): two would
|
|
88
|
+
// eventually disagree about what `30d` means, and the dashboard's numbers are
|
|
89
|
+
// required to equal these for the same window.
|
|
90
|
+
const window = parseStatsWindow(rawSince ?? "7d", now);
|
|
91
|
+
if (window === undefined) {
|
|
92
|
+
process.stderr.write(
|
|
93
|
+
`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${rawSince ?? ""}"\n`,
|
|
94
|
+
);
|
|
95
|
+
process.exit(2);
|
|
96
|
+
}
|
|
116
97
|
const store = openStore(dbPath());
|
|
117
98
|
try {
|
|
118
99
|
const report = computeStats({
|
package/src/commands/tail.ts
CHANGED
|
@@ -153,6 +153,21 @@ function styleTailLine(line: string): string {
|
|
|
153
153
|
return line;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** A poll delay that ends early when the follow is aborted. */
|
|
157
|
+
function sleepUntilAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
158
|
+
if (signal === undefined) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
159
|
+
const abort = signal;
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const done = (): void => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
abort.removeEventListener("abort", done);
|
|
164
|
+
resolve();
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(done, ms);
|
|
167
|
+
abort.addEventListener("abort", done, { once: true });
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
/**
|
|
157
172
|
* Follow one run's transcripts the way `tail -f` follows a log: the worker's
|
|
158
173
|
* own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
|
|
@@ -181,6 +196,16 @@ export async function tailRun(
|
|
|
181
196
|
quietMs?: number;
|
|
182
197
|
/** Where rendered lines go; the CLI writes to stdout, tests capture. */
|
|
183
198
|
write?: (line: string) => void;
|
|
199
|
+
/**
|
|
200
|
+
* Stops the follow before the run is terminal (#296).
|
|
201
|
+
*
|
|
202
|
+
* The CLI passes none: `tail` runs until the run ends or the operator hits
|
|
203
|
+
* Ctrl-C, and SIGINT's default is immediate exit. The dashboard's SSE stream
|
|
204
|
+
* passes the request's signal, because a browser that closed its tab must
|
|
205
|
+
* not leave a poll loop holding file descriptors until the run finishes —
|
|
206
|
+
* repeated opens would accumulate one follow each.
|
|
207
|
+
*/
|
|
208
|
+
signal?: AbortSignal;
|
|
184
209
|
} = {},
|
|
185
210
|
): Promise<void> {
|
|
186
211
|
const store = deps.store ?? openStore(dbPath());
|
|
@@ -204,6 +229,9 @@ export async function tailRun(
|
|
|
204
229
|
let lastChange = Date.now();
|
|
205
230
|
|
|
206
231
|
for (;;) {
|
|
232
|
+
// Checked at the top as well as after the sleep: a signal that was
|
|
233
|
+
// already aborted must not buy one whole poll's worth of work.
|
|
234
|
+
if (deps.signal?.aborted === true) return;
|
|
207
235
|
let changed = false;
|
|
208
236
|
const primaryDelta = followLines(primary, path);
|
|
209
237
|
changed = primaryDelta.changed || changed;
|
|
@@ -264,7 +292,9 @@ export async function tailRun(
|
|
|
264
292
|
write(`run ended: ${state}`);
|
|
265
293
|
return;
|
|
266
294
|
}
|
|
267
|
-
|
|
295
|
+
// Wakes on the signal rather than after the full poll: a closed browser
|
|
296
|
+
// should release the descriptors now, not up to a second later.
|
|
297
|
+
await sleepUntilAbort(pollMs, deps.signal);
|
|
268
298
|
}
|
|
269
299
|
} finally {
|
|
270
300
|
closeSync(primary.fd);
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -10,14 +10,31 @@ import type { CommandContext } from "./context.ts";
|
|
|
10
10
|
import { upgradeConductor } from "../upgrade.ts";
|
|
11
11
|
|
|
12
12
|
export async function upgradeCommand(ctx: CommandContext): Promise<void> {
|
|
13
|
+
// `--bootstrap SHA --source PATH` installs an exact source/build identity
|
|
14
|
+
// instead of a published semver (#908): the path that exists because a
|
|
15
|
+
// reliability release cannot be cut when the installed conductor is what is
|
|
16
|
+
// broken. Both flags or neither — a sha with no source has nothing to run the
|
|
17
|
+
// package's own checks against, and a source with no sha is not an identity.
|
|
18
|
+
const sha = ctx.flag("bootstrap");
|
|
19
|
+
const source = ctx.flag("source");
|
|
20
|
+
if ((sha === undefined) !== (source === undefined)) {
|
|
21
|
+
process.stderr.write(
|
|
22
|
+
"upgrade: --bootstrap SHA and --source PATH go together — the sha is the identity, the source is " +
|
|
23
|
+
"the tree its checks run against\n",
|
|
24
|
+
);
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
13
28
|
const result = await upgradeConductor({
|
|
14
29
|
version: ctx.flag("to"),
|
|
30
|
+
...(sha === undefined || source === undefined ? {} : { bootstrap: { sha, source } }),
|
|
15
31
|
project: ctx.projectFlag,
|
|
16
32
|
});
|
|
33
|
+
const spec = sha === undefined ? `omp-conductor@${result.version}` : `${result.version} @ ${result.gitHead.slice(0, 12)}`;
|
|
17
34
|
process.stdout.write(
|
|
18
|
-
`${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
|
|
19
|
-
` Bun-global CLI
|
|
20
|
-
` omp plugin
|
|
35
|
+
`${result.alreadyCurrent ? "already current" : sha === undefined ? "upgrade complete" : "bootstrap complete"}:\n` +
|
|
36
|
+
` Bun-global CLI ${spec}\n` +
|
|
37
|
+
` omp plugin ${spec}\n` +
|
|
21
38
|
` Herdr plugin herdr-conductor@${result.gitHead}\n` +
|
|
22
39
|
` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
|
|
23
40
|
` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
|
package/src/commands/verb.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
11
|
import { isPaused, pausedAt } from "../daemon.ts";
|
|
12
|
-
import { readBaseChain } from "../gitops.ts";
|
|
12
|
+
import { probeRunLane, readBaseChain } from "../gitops.ts";
|
|
13
13
|
import { dbPath, openStore } from "../store.ts";
|
|
14
14
|
import { makeTracker } from "../tracker/github.ts";
|
|
15
15
|
import { VERB_NAMES } from "../types.ts";
|
|
@@ -70,6 +70,7 @@ try {
|
|
|
70
70
|
log: (m) => process.stderr.write(`${m}\n`),
|
|
71
71
|
now: () => Date.now(),
|
|
72
72
|
chain: { readBaseChain },
|
|
73
|
+
lane: { probeRunLane },
|
|
73
74
|
},
|
|
74
75
|
channel,
|
|
75
76
|
{ verb: name, args },
|
package/src/config-schema.ts
CHANGED
|
@@ -125,6 +125,14 @@ const capsSchema = z
|
|
|
125
125
|
.min(0)
|
|
126
126
|
.nullable()
|
|
127
127
|
.describe(`Rolling-day spend ceiling; null means no spend gate (default ${DEFAULT_CAPS.dailySpendUsd})`),
|
|
128
|
+
maxRunSpendUsd: z
|
|
129
|
+
.number()
|
|
130
|
+
.positive()
|
|
131
|
+
.nullable()
|
|
132
|
+
.describe(
|
|
133
|
+
"Ceiling on one run, reserved from the daily budget before it launches; " +
|
|
134
|
+
"null derives it from dailySpendUsd. Must not exceed dailySpendUsd",
|
|
135
|
+
),
|
|
128
136
|
planUsage: z.union([planUsageCap, z.null()]).describe(`Plan-allowance guard, or null for unmetered`),
|
|
129
137
|
workerMaxTurns: z.number().min(0).describe(`Turn ceiling for one worker (default ${DEFAULT_CAPS.workerMaxTurns})`),
|
|
130
138
|
workerMaxTurnsCeiling: z.number().min(0).describe(`Maximum turn budget assignable to one issue's next attempt`),
|
|
@@ -399,6 +407,17 @@ const projectSchema = z
|
|
|
399
407
|
// usable, exactly like `workerModel`.
|
|
400
408
|
modelFallbacks: z.unknown().optional(),
|
|
401
409
|
modelFallbackThreshold: z.unknown().optional(),
|
|
410
|
+
// One stronger opaque omp selector the first spinning cap in a chain
|
|
411
|
+
// retries on (#807). Normalised (trimmed, blank dropped) by the loader
|
|
412
|
+
// like `workerModel`, and deliberately separate from `modelFallbacks`:
|
|
413
|
+
// that chain answers provider faults only.
|
|
414
|
+
workerEscalationModel: z.unknown().optional(),
|
|
415
|
+
// Providers that must bill to a subscription credential (#852). Normalised
|
|
416
|
+
// by the loader (trimmed, blanks and non-strings dropped) like
|
|
417
|
+
// `modelFallbacks`, so a malformed entry weakens the fence's *scope* rather
|
|
418
|
+
// than failing the whole config load — and an entry that survives is one the
|
|
419
|
+
// fence will actually enforce.
|
|
420
|
+
requireOauthProviders: z.unknown().optional(),
|
|
402
421
|
// The fleet-owned omp settings overlay (#537): an opaque map omp's own
|
|
403
422
|
// schema owns. Conductor validates YAML shape only — the loader keeps it
|
|
404
423
|
// when it is a mapping and drops anything else, like `modelFallbacks`.
|