omp-conductor 0.15.11 → 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/REFERENCE.md +98 -56
- package/package.json +1 -1
- package/schema/config.schema.json +3 -0
- package/src/briefs/orchestrator.md +64 -11
- package/src/briefs/policy.md +19 -3
- package/src/briefs/worker.md +11 -8
- package/src/cli.ts +41 -21
- package/src/commands/context.ts +102 -1
- package/src/commands/doctor.ts +4 -2
- package/src/commands/intake.ts +26 -5
- package/src/commands/message.ts +80 -32
- package/src/commands/report.ts +38 -2
- package/src/commands/setup.ts +61 -11
- package/src/commands/upgrade-rollback.ts +9 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +35 -1
- package/src/daemon.ts +359 -7
- package/src/dashboard/app.js +398 -59
- package/src/dashboard/index.html +27 -0
- package/src/dashboard/server.ts +219 -5
- package/src/dashboard/style.css +169 -1
- package/src/doctor.ts +179 -42
- package/src/failure-class.ts +37 -0
- package/src/gitops.ts +157 -0
- package/src/model-fallback.ts +177 -0
- package/src/omp.ts +91 -13
- package/src/orchestrator-tick.ts +101 -5
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +212 -12
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +286 -60
- package/src/setup-install.ts +237 -28
- package/src/setup-wizard.ts +67 -19
- package/src/setup.ts +25 -0
- package/src/store.ts +13 -1
- package/src/tracker/github.ts +47 -0
- package/src/types.ts +71 -0
- package/src/upgrade.ts +90 -32
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor.service.example +7 -3
package/src/release-policy.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface ReleaseBlockContext {
|
|
|
58
58
|
export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
|
|
59
59
|
project: string;
|
|
60
60
|
source: "worker" | "orchestrator";
|
|
61
|
-
shape:
|
|
61
|
+
shape: GateShape;
|
|
62
62
|
at: string;
|
|
63
63
|
issue?: number;
|
|
64
64
|
runId?: string;
|
|
@@ -66,12 +66,108 @@ export interface ReleaseBlock extends Partial<ReleaseBlockContext> {
|
|
|
66
66
|
|
|
67
67
|
export type ReleaseDecision = { block: true; reason: string };
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The shared-host heavy-gate shape (#428): whole-package test runs and the
|
|
71
|
+
* shell suites a worker must be *unable* to run on the operator's shared host,
|
|
72
|
+
* rather than merely told not to.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately **not** a {@link ReleaseShape}: no grant covers it. An operator
|
|
75
|
+
* is never refused — the operator owns the host, and CI owns the full suite —
|
|
76
|
+
* and a worker is always, whatever the config says. So it stays out of the
|
|
77
|
+
* configurable grant vocabulary (`releaseShapeEnum` is drawn straight from
|
|
78
|
+
* {@link RELEASE_SHAPES}); it only rides the release-policy classifier and the
|
|
79
|
+
* block ledger so one seam sees it and its refusals land where the next tick
|
|
80
|
+
* already reads drift.
|
|
81
|
+
*/
|
|
82
|
+
export const SHARED_HOST_SHAPE = "shared-host-gate" as const;
|
|
83
|
+
export type SharedHostShape = typeof SHARED_HOST_SHAPE;
|
|
84
|
+
|
|
85
|
+
/** Every shape the classifier reports, grantable or not. */
|
|
86
|
+
export type GateShape = ReleaseShape | SharedHostShape;
|
|
87
|
+
|
|
69
88
|
const GIT_PUSH_TAG_SHAPE =
|
|
70
89
|
/(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
|
|
71
90
|
|
|
91
|
+
/** The separators the original quote-blind splitter divides a command on. Kept
|
|
92
|
+
* for the unterminated-quote fallback, which must reproduce today's behaviour
|
|
93
|
+
* rather than silently pass a command through. */
|
|
94
|
+
const COMMAND_SEPARATORS = /(?:&&|\|\||[;\n|])/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Split a shell command on its operators, ignoring ones inside single or
|
|
98
|
+
* double quotes. The old splitter was quote-blind, so a `|` inside a quoted
|
|
99
|
+
* argument (a grep alternation's `\|`, a `--jq` filter, a heredoc) shredded
|
|
100
|
+
* the command into phantom segments and a gated verb inside a string literal
|
|
101
|
+
* was refused as a release attempt (#526). A small scanner is enough: no shell
|
|
102
|
+
* parser, no dependency. An unterminated quote falls back to the original
|
|
103
|
+
* quote-blind split, because a gated command must not slip through merely
|
|
104
|
+
* because a quote never closed.
|
|
105
|
+
*/
|
|
106
|
+
function splitCommandSegments(command: string): string[] {
|
|
107
|
+
const segments: string[] = [];
|
|
108
|
+
let current = "";
|
|
109
|
+
let quote: "'" | '"' | null = null;
|
|
110
|
+
|
|
111
|
+
const push = () => {
|
|
112
|
+
segments.push(current);
|
|
113
|
+
current = "";
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < command.length; i++) {
|
|
117
|
+
const ch = command[i];
|
|
118
|
+
|
|
119
|
+
// Inside a quote only its closing quote matters — separators are data. A
|
|
120
|
+
// `\"` inside double quotes is a literal quote and must not close it.
|
|
121
|
+
if (quote !== null) {
|
|
122
|
+
current += ch;
|
|
123
|
+
if (ch === "\\" && quote === '"') {
|
|
124
|
+
if (i + 1 < command.length) current += command[++i];
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === quote) quote = null;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Outside quotes a backslash escapes the next character, so a literal
|
|
132
|
+
// `\|`, `\;` or `\"` is data, never an operator or the start of a quote.
|
|
133
|
+
if (ch === "\\") {
|
|
134
|
+
current += ch;
|
|
135
|
+
if (i + 1 < command.length) current += command[++i];
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ch === "'") {
|
|
140
|
+
quote = "'";
|
|
141
|
+
current += ch;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === '"') {
|
|
145
|
+
quote = '"';
|
|
146
|
+
current += ch;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if ((ch === "&" && command[i + 1] === "&") || (ch === "|" && command[i + 1] === "|")) {
|
|
151
|
+
push();
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === "|" || ch === ";" || ch === "\n") {
|
|
156
|
+
push();
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
current += ch;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (quote !== null) return command.split(COMMAND_SEPARATORS);
|
|
164
|
+
|
|
165
|
+
push();
|
|
166
|
+
return segments;
|
|
167
|
+
}
|
|
168
|
+
|
|
72
169
|
function commandSegments(command: string): string[] {
|
|
73
|
-
return command
|
|
74
|
-
.split(/(?:&&|\|\||[;\n|])/)
|
|
170
|
+
return splitCommandSegments(command)
|
|
75
171
|
.map((segment) => segment.trim())
|
|
76
172
|
.filter((segment) => segment.length > 0);
|
|
77
173
|
}
|
|
@@ -84,17 +180,74 @@ function stripCommandPrefix(segment: string): string {
|
|
|
84
180
|
}
|
|
85
181
|
|
|
86
182
|
/** Recognise the explicit release/deploy command shapes this policy promises to gate. */
|
|
87
|
-
export function releaseShapeFromCommand(command: string):
|
|
183
|
+
export function releaseShapeFromCommand(command: string): GateShape | undefined {
|
|
88
184
|
return releaseCommandMatch(command)?.shape;
|
|
89
185
|
}
|
|
90
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A whole-package `bun test`: `bun test` with no positional naming a specific
|
|
189
|
+
* test file. Flags alone — or nothing at all — still run the whole project,
|
|
190
|
+
* which is the gate (#428), and so do the path spellings of "everything":
|
|
191
|
+
* `bun test .`, `bun test ./`, `bun test src`. A command whose positional
|
|
192
|
+
* targets are test files — basename carrying the `*.test.*` / `*_test.*`
|
|
193
|
+
* marker bun itself keys on — is a focused run, a worker's proof path, and
|
|
194
|
+
* must stay available. A flag's own value is not a focused target: a
|
|
195
|
+
* `--preload x.ts` run with no file runs the whole package with a preload,
|
|
196
|
+
* while `--preload ./setup.ts src/daemon.test.ts` is still a test-file run.
|
|
197
|
+
*/
|
|
198
|
+
function wholePackageBunTest(segment: string): boolean {
|
|
199
|
+
const head = /^bun\s+test\b/.exec(segment);
|
|
200
|
+
if (head === null) return false;
|
|
201
|
+
const rest = segment.slice(head[0].length);
|
|
202
|
+
return !rest
|
|
203
|
+
.split(/\s+/)
|
|
204
|
+
.filter((token) => token.length > 0)
|
|
205
|
+
.filter((token) => !token.startsWith("-"))
|
|
206
|
+
.some((token) => /\.test\.|_test\./.test(token.slice(token.lastIndexOf("/") + 1)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The shared-host run/shell suites a worker must not execute on the operator's
|
|
211
|
+
* host (#428): `herdr/test/recover-test.sh`, `test/setup-test.sh` and
|
|
212
|
+
* `setup.sh`, each under the invocation prefixes a worker actually uses —
|
|
213
|
+
* plain `bash`/`sh`/`zsh`, POSIX `. ` and `source` (all so the script loads),
|
|
214
|
+
* and a bare or `./` path. Returns the script path that fired, for the
|
|
215
|
+
* matched-segment ledger record.
|
|
216
|
+
*
|
|
217
|
+
* `bash -n` is deliberately not a match: parsing a script never executes it,
|
|
218
|
+
* and the pre-push parse gate every worker brief runs is
|
|
219
|
+
* `bash -n herdr/bin/recover.sh herdr/test/recover-test.sh setup.sh
|
|
220
|
+
* test/setup-test.sh` — blocking that would make the gate unfollowable without
|
|
221
|
+
* buying a single byte of protection. A flag beyond `-n` (`bash -x`, `-e`) is
|
|
222
|
+
* a real execution and is not covered either: the incidents that overloaded
|
|
223
|
+
* the host were all plain invocations, and refusing the sanctioned parse check
|
|
224
|
+
* would teach workers to skip it rather than stop the load.
|
|
225
|
+
*/
|
|
226
|
+
const SHARED_HOST_SCRIPTS = [
|
|
227
|
+
"herdr/test/recover-test.sh",
|
|
228
|
+
"test/setup-test.sh",
|
|
229
|
+
"setup.sh",
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const SHARED_HOST_PREFIX = /^(?:(?:bash|sh|zsh|source)\s+|\.\s+)?\.?\/?/;
|
|
233
|
+
|
|
234
|
+
function sharedHostScriptMatch(segment: string): string | undefined {
|
|
235
|
+
const path = segment.replace(SHARED_HOST_PREFIX, "");
|
|
236
|
+
for (const script of SHARED_HOST_SCRIPTS) {
|
|
237
|
+
if (path === script || path.startsWith(`${script} `) || path.startsWith(`${script}\t`)) {
|
|
238
|
+
return script;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
91
244
|
/**
|
|
92
245
|
* Classify a shell command and the segment that fired. The segment is what a
|
|
93
246
|
* triager reads: `bun test src/foo.test.ts && npm publish` matches on its
|
|
94
247
|
* `npm publish` half, and the record says so instead of folding the whole
|
|
95
248
|
* command into the shape.
|
|
96
249
|
*/
|
|
97
|
-
function releaseCommandMatch(command: string): { shape:
|
|
250
|
+
function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
|
|
98
251
|
for (const raw of commandSegments(command)) {
|
|
99
252
|
const segment = stripCommandPrefix(raw);
|
|
100
253
|
// Patching the running conductor is gated like a release act: `upgrade`,
|
|
@@ -121,6 +274,13 @@ function releaseCommandMatch(command: string): { shape: ReleaseShape; matched: s
|
|
|
121
274
|
) {
|
|
122
275
|
return { shape: "deploy", matched: segment };
|
|
123
276
|
}
|
|
277
|
+
// The shared-host gate (#428). Disjoint from every release shape above, so
|
|
278
|
+
// it is checked last without disturbing any of them. `matched` names the
|
|
279
|
+
// script path for a suite and the offending segment for a whole-package
|
|
280
|
+
// `bun test`, so the ledger and the digest say exactly which one fired.
|
|
281
|
+
if (wholePackageBunTest(segment)) return { shape: SHARED_HOST_SHAPE, matched: segment };
|
|
282
|
+
const script = sharedHostScriptMatch(segment);
|
|
283
|
+
if (script !== undefined) return { shape: SHARED_HOST_SHAPE, matched: script };
|
|
124
284
|
}
|
|
125
285
|
return undefined;
|
|
126
286
|
}
|
|
@@ -256,7 +416,7 @@ function releaseTokenMatch(
|
|
|
256
416
|
export function releaseToolMatch(
|
|
257
417
|
toolName: string,
|
|
258
418
|
input: Record<string, unknown> | undefined,
|
|
259
|
-
): { shape:
|
|
419
|
+
): { shape: GateShape; matched: string } | undefined {
|
|
260
420
|
if (input === undefined) return undefined;
|
|
261
421
|
if (toolName === "bash") {
|
|
262
422
|
return typeof input.command === "string" ? releaseCommandMatch(input.command) : undefined;
|
|
@@ -278,10 +438,31 @@ export function releaseToolMatch(
|
|
|
278
438
|
export function releaseShapeFromTool(
|
|
279
439
|
toolName: string,
|
|
280
440
|
input: Record<string, unknown> | undefined,
|
|
281
|
-
):
|
|
441
|
+
): GateShape | undefined {
|
|
282
442
|
return releaseToolMatch(toolName, input)?.shape;
|
|
283
443
|
}
|
|
284
444
|
|
|
445
|
+
/**
|
|
446
|
+
* The refusal wording for the shared-host gate (#428). The whole-package
|
|
447
|
+
* `bun test` form names the focused alternative (a guard that only denies
|
|
448
|
+
* teaches nothing and gets worked around); the shell suites get their own
|
|
449
|
+
* because there is no focused form of a suite script.
|
|
450
|
+
*/
|
|
451
|
+
function sharedHostRefusalReason(matched: string | undefined): string {
|
|
452
|
+
if (matched !== undefined && /^bun\s+test\b/.test(matched)) {
|
|
453
|
+
return (
|
|
454
|
+
"Blocked by sharedHostPolicy: the whole-package `bun test` is not a worker's proof path on this shared " +
|
|
455
|
+
"host (it overloads the 4-core VPS and, before #399, SIGTERMed the production daemon). " +
|
|
456
|
+
"Run a focused `bun test <file>.test.ts` instead."
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return (
|
|
460
|
+
"Blocked by sharedHostPolicy: this shell suite is not a worker's proof path on this shared host " +
|
|
461
|
+
"(it overloads the 4-core VPS that also runs Langfuse and the fleet). " +
|
|
462
|
+
"Run focused `bun test <file>.test.ts` unit tests instead."
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
285
466
|
/**
|
|
286
467
|
* The refusal a `role` session gets for `shape` under `grants`, or `undefined`
|
|
287
468
|
* when the grant covers it.
|
|
@@ -296,8 +477,16 @@ export function releaseShapeFromTool(
|
|
|
296
477
|
export function releaseRefusal(
|
|
297
478
|
grants: ResolvedGrants,
|
|
298
479
|
role: SessionRole,
|
|
299
|
-
shape:
|
|
480
|
+
shape: GateShape,
|
|
300
481
|
): ReleaseDecision | undefined {
|
|
482
|
+
// The shared-host gate is not a grant anyone holds: a worker is always
|
|
483
|
+
// refused — the shared operator host cannot absorb a whole-package suite,
|
|
484
|
+
// which is the exact load that stopped it — and an operator session is
|
|
485
|
+
// never, because the operator owns the host and CI owns the full suite.
|
|
486
|
+
if (shape === SHARED_HOST_SHAPE) {
|
|
487
|
+
if (role !== "worker") return undefined;
|
|
488
|
+
return { block: true, reason: sharedHostRefusalReason(undefined) };
|
|
489
|
+
}
|
|
301
490
|
const holder = grants[shape];
|
|
302
491
|
if (holder === role) return undefined;
|
|
303
492
|
return {
|
|
@@ -319,11 +508,22 @@ export function releaseDecision(
|
|
|
319
508
|
role: SessionRole,
|
|
320
509
|
toolName: string,
|
|
321
510
|
input: Record<string, unknown>,
|
|
322
|
-
): { shape:
|
|
511
|
+
): { shape: GateShape; matched: string; decision: ReleaseDecision } | undefined {
|
|
323
512
|
const match = releaseToolMatch(toolName, input);
|
|
324
513
|
if (match === undefined) return undefined;
|
|
325
514
|
const decision = releaseRefusal(grants, role, match.shape);
|
|
326
|
-
|
|
515
|
+
if (decision === undefined) return undefined;
|
|
516
|
+
// The shared-host gate's wording is matched-specific (name the focused form
|
|
517
|
+
// for a whole-package `bun test`, name the suite for a script), so it is
|
|
518
|
+
// read off the offending segment here rather than from `releaseRefusal`.
|
|
519
|
+
if (match.shape === SHARED_HOST_SHAPE) {
|
|
520
|
+
return {
|
|
521
|
+
shape: match.shape,
|
|
522
|
+
matched: match.matched,
|
|
523
|
+
decision: { block: true, reason: sharedHostRefusalReason(match.matched) },
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return { shape: match.shape, matched: match.matched, decision };
|
|
327
527
|
}
|
|
328
528
|
|
|
329
529
|
const RELEASE_ARG_ALLOWLIST = new Set([
|
|
@@ -424,7 +624,7 @@ interface ReleasePolicyPi {
|
|
|
424
624
|
export function releasePolicyTripwire(
|
|
425
625
|
grants: ResolvedGrants,
|
|
426
626
|
role: SessionRole,
|
|
427
|
-
onBlocked: (shape:
|
|
627
|
+
onBlocked: (shape: GateShape, context: ReleaseBlockContext) => void = () => {},
|
|
428
628
|
): (pi: ReleasePolicyPi) => void {
|
|
429
629
|
return (pi) => {
|
|
430
630
|
pi.on("tool_call", (event) => {
|
|
@@ -450,7 +650,7 @@ export function releasePolicyTripwire(
|
|
|
450
650
|
export function recordReleaseBlock(
|
|
451
651
|
project: string,
|
|
452
652
|
source: ReleaseBlock["source"],
|
|
453
|
-
shape:
|
|
653
|
+
shape: GateShape,
|
|
454
654
|
details: Omit<ReleaseBlock, "project" | "source" | "shape" | "at"> = {},
|
|
455
655
|
root = stateDir(),
|
|
456
656
|
now = new Date(),
|
package/src/session-host.ts
CHANGED
|
@@ -22,9 +22,8 @@
|
|
|
22
22
|
import { connect } from "node:net";
|
|
23
23
|
|
|
24
24
|
import { createLocalSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
25
|
-
import type { ReleaseBlockContext } from "./release-policy.ts";
|
|
26
|
-
|
|
27
|
-
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
25
|
+
import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
|
|
26
|
+
import type { ResolvedGrants, SessionRole } from "./types.ts";
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
29
|
* Everything the child needs to build the session. Plain JSON on purpose:
|
|
@@ -68,7 +67,7 @@ export type HostToParent =
|
|
|
68
67
|
| { t: "session-file"; path: string }
|
|
69
68
|
| { t: "prompt-result"; id: number; ok: boolean; error?: string }
|
|
70
69
|
| { t: "park-result"; id: number; ok: boolean; error?: string }
|
|
71
|
-
| ({ t: "release-blocked"; shape:
|
|
70
|
+
| ({ t: "release-blocked"; shape: GateShape } & ReleaseBlockContext);
|
|
72
71
|
|
|
73
72
|
/**
|
|
74
73
|
* Depth at which a harness event stops being copied for the wire.
|
|
@@ -196,7 +195,14 @@ export async function runSessionHost(
|
|
|
196
195
|
});
|
|
197
196
|
} catch (err) {
|
|
198
197
|
send({ t: "start-error", message: err instanceof Error ? err.message : String(err) });
|
|
199
|
-
|
|
198
|
+
// The frame must reach the parent before this process exits: `process.exit`
|
|
199
|
+
// does not flush the socket, and a parent that only observes the exit used
|
|
200
|
+
// to report a bare exit code instead of the child's own words.
|
|
201
|
+
await new Promise<void>((resolve) => {
|
|
202
|
+
socket.once("finish", () => resolve());
|
|
203
|
+
socket.once("error", () => resolve());
|
|
204
|
+
socket.end();
|
|
205
|
+
});
|
|
200
206
|
return;
|
|
201
207
|
}
|
|
202
208
|
|