omp-conductor 0.18.0 → 0.18.2
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 +35 -1
- package/REFERENCE.md +61 -11
- package/agents/to-spec.md +94 -0
- package/package.json +2 -1
- package/schema/config.schema.json +35 -1
- package/src/admission.ts +204 -75
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +62 -21
- package/src/briefs/to-spec.md +88 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +124 -1
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +38 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/intake.ts +4 -19
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +51 -16
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +43 -6
- package/src/config.ts +65 -9
- package/src/daemon.ts +879 -41
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +243 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +60 -82
- package/src/escalate.ts +31 -14
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +239 -240
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +242 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +183 -21
- package/src/orchestrator-tick.ts +1591 -32
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +65 -6
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1225 -9
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +154 -3
- package/src/setup.ts +83 -17
- package/src/shell.ts +15 -0
- package/src/status-render.ts +216 -12
- package/src/store.ts +443 -42
- package/src/to-spec.ts +408 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +405 -19
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +765 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +12 -2
- package/src/worktree.ts +29 -12
package/src/arm-challenge.ts
CHANGED
|
@@ -1,69 +1,154 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authenticated pending-challenge state for the arming handshake (conductor
|
|
3
|
-
* #415).
|
|
3
|
+
* #415, transaction reworked by #614, storage hardened by review of #896).
|
|
4
4
|
*
|
|
5
5
|
* `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* waits for the orchestrator to acknowledge it. The acknowledgement is
|
|
7
|
+
* conductor-owned state: the orchestrator's inbound user-turn adapter
|
|
8
|
+
* (orchestrator-tick.ts) writes it the moment the real reply arrives as a user
|
|
9
|
+
* turn — never the model, and never a transcript scan, so the proof no longer
|
|
10
|
+
* depends on where (or whether) a session file lives.
|
|
9
11
|
*
|
|
10
12
|
* This module is the bridge between the two roles without an import cycle:
|
|
11
13
|
* `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
|
|
12
14
|
* so `orchestrator-tick.ts` can never import `fleet.ts`. Both already import
|
|
13
|
-
* `config.ts`, so the
|
|
14
|
-
*
|
|
15
|
+
* `config.ts`, so the state lives next to the other state under `stateDir()`
|
|
16
|
+
* and both sides reach it through *this* leaf module.
|
|
17
|
+
*
|
|
18
|
+
* The handshake keeps no shared mutable state at all — every file is named by
|
|
19
|
+
* its own key, and every deletion is id-addressed, so two processes can
|
|
20
|
+
* neither lose each other's updates nor delete each other's proofs:
|
|
21
|
+
*
|
|
22
|
+
* - `arm-challenges/<project key>.json` — one pending challenge per project:
|
|
23
|
+
* `{ project, id, hash, sentAt, expiresAt }`. Host-owned: only `armTicks`
|
|
24
|
+
* writes it (record before the send, settle on consumption/timeout/send
|
|
25
|
+
* failure), always through the atomic tmp+rename write the admission ack
|
|
26
|
+
* uses. A re-armed project overwrites its own file — pruning only its own
|
|
27
|
+
* prior id's acknowledgement; a concurrent arm for a different project
|
|
28
|
+
* touches a different file.
|
|
29
|
+
* - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
|
|
30
|
+
* `{ challengeId, acknowledgedAt }`. The adapter's acknowledgement is a
|
|
31
|
+
* single-file create/overwrite via rename — it never reads or rewrites
|
|
32
|
+
* another transaction's record. Files that outlive their transaction (a
|
|
33
|
+
* crash between the adapter's write and the host's settle) are inert by
|
|
34
|
+
* construction and removed only by age, never by membership in any
|
|
35
|
+
* directory snapshot (see {@link gcAgedAcks}).
|
|
15
36
|
*
|
|
16
37
|
* Only the sha-256 of the code is ever persisted — never the code, whose
|
|
17
38
|
* plaintext appearance in the protected session transcript is already the
|
|
18
|
-
* backend proof and must not leak into a durable report, an issue comment, or
|
|
19
|
-
* diagnostic line the way a new artifact could. The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
39
|
+
* backend proof and must not leak into a durable report, an issue comment, or
|
|
40
|
+
* a diagnostic line the way a new artifact could. The id is a random UUID cut
|
|
41
|
+
* with the challenge, so acknowledgement records are challenge-specific
|
|
42
|
+
* without carrying anything guessable. Neither side trusts a `FLEET-` prefix:
|
|
43
|
+
* a reply is classified by hashing its whitespace-separated tokens against
|
|
44
|
+
* the pending record, so nothing on the east side of the boundary can arm a
|
|
45
|
+
* fleet with a lookalike.
|
|
22
46
|
*/
|
|
23
47
|
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
25
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
49
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
50
|
import { dirname, join } from "node:path";
|
|
27
51
|
import { stateDir } from "./config.ts";
|
|
28
52
|
|
|
29
|
-
const
|
|
53
|
+
const ARM_CHALLENGES_DIR = "arm-challenges";
|
|
54
|
+
const ARM_ACKS_DIR = "arm-challenge-acks";
|
|
30
55
|
|
|
31
56
|
interface PendingChallenge {
|
|
57
|
+
/** The project key this pending belongs to, mirrored for read-back checks. */
|
|
58
|
+
project: string;
|
|
59
|
+
/** Random transaction id, cut when this challenge was recorded. */
|
|
60
|
+
id: string;
|
|
32
61
|
/** sha-256 hex of the challenge code — never the code itself. */
|
|
33
62
|
hash: string;
|
|
63
|
+
/** Unix ms the challenge was recorded, just before the send. */
|
|
64
|
+
sentAt: number;
|
|
34
65
|
/** Unix ms after which a matching reply is no longer an active proof. */
|
|
35
66
|
expiresAt: number;
|
|
36
67
|
}
|
|
37
68
|
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
interface ArmAcknowledgement {
|
|
70
|
+
/** The exact challenge id this record satisfies. */
|
|
71
|
+
challengeId: string;
|
|
72
|
+
/** Unix ms the inbound adapter acknowledged the reply. */
|
|
73
|
+
acknowledgedAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function challengesDir(): string {
|
|
77
|
+
return join(stateDir(), ARM_CHALLENGES_DIR);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function acksDir(): string {
|
|
81
|
+
return join(stateDir(), ARM_ACKS_DIR);
|
|
82
|
+
}
|
|
40
83
|
|
|
41
|
-
function
|
|
42
|
-
return join(
|
|
84
|
+
function ackPath(challengeId: string): string {
|
|
85
|
+
return join(acksDir(), `${challengeId}.json`);
|
|
43
86
|
}
|
|
44
87
|
|
|
45
88
|
function projectKey(project?: string): string {
|
|
46
89
|
return project ?? "";
|
|
47
90
|
}
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Filesystem-safe, injective encoding of a project key. Raw names that need
|
|
94
|
+
* no escaping are used verbatim; everything else (including the empty key of
|
|
95
|
+
* an unstamped config) becomes `=` plus hex bytes — `=` never appears in the
|
|
96
|
+
* raw class, so the two namespaces cannot collide.
|
|
97
|
+
*/
|
|
98
|
+
function encodeProjectKey(key: string): string {
|
|
99
|
+
if (/^[A-Za-z0-9._-]{1,64}$/.test(key) && key !== "." && key !== "..") return key;
|
|
100
|
+
return `=${[...Buffer.from(key, "utf8")].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pendingPath(key: string): string {
|
|
104
|
+
return join(challengesDir(), `${encodeProjectKey(key)}.json`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Atomic durable write, exactly like the admission ack: content lands at a
|
|
109
|
+
* unique temp path and is renamed into place, so every reader — including the
|
|
110
|
+
* one-shot inbound adapter racing the host — sees either the old file or the
|
|
111
|
+
* new one, never a mid-truncate parse.
|
|
112
|
+
*/
|
|
113
|
+
function writeFileAtomic(path: string, content: string): void {
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
|
|
50
116
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
117
|
+
// 0600 like the other conductor state an operator never shares; the
|
|
118
|
+
// records are hashes and random ids, but there is no reason to be less
|
|
119
|
+
// careful.
|
|
120
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
121
|
+
renameSync(tmp, path);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
rmSync(tmp, { force: true });
|
|
124
|
+
throw err;
|
|
57
125
|
}
|
|
58
|
-
return {};
|
|
59
126
|
}
|
|
60
127
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
128
|
+
/**
|
|
129
|
+
* This project's pending challenge parsed from its own file, with every field
|
|
130
|
+
* validated. Anything absent, torn, garbage, pre-#614, or naming another
|
|
131
|
+
* project reads as none — fail closed until the host's next transaction
|
|
132
|
+
* rewrites the file wholesale.
|
|
133
|
+
*/
|
|
134
|
+
function readPendingFor(key: string): PendingChallenge | undefined {
|
|
135
|
+
let parsed: unknown;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(readFileSync(pendingPath(key), "utf8"));
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
142
|
+
const pending = parsed as Partial<PendingChallenge>;
|
|
143
|
+
if (
|
|
144
|
+
pending.project !== key ||
|
|
145
|
+
typeof pending.id !== "string" ||
|
|
146
|
+
typeof pending.hash !== "string" ||
|
|
147
|
+
typeof pending.expiresAt !== "number"
|
|
148
|
+
) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return pending as PendingChallenge;
|
|
67
152
|
}
|
|
68
153
|
|
|
69
154
|
/** sha-256 hex of the challenge code — the persisted token, never the code. */
|
|
@@ -71,42 +156,150 @@ function challengeHash(code: string): string {
|
|
|
71
156
|
return createHash("sha256").update(code).digest("hex");
|
|
72
157
|
}
|
|
73
158
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Acknowledgement files outliving their transaction — a crash between the
|
|
161
|
+
* adapter's write and the host's settle — are inert by construction: no
|
|
162
|
+
* waiter ever polls their id again, and ids are UUIDs, so no future
|
|
163
|
+
* transaction can collide with one. They are therefore not swept by
|
|
164
|
+
* membership (a directory listing captured before another process records is
|
|
165
|
+
* stale the moment it is taken, and acting on it deletes on-time proofs),
|
|
166
|
+
* only by age: a file older than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong
|
|
167
|
+
* to a live handshake under any configured window, so its mtime alone decides
|
|
168
|
+
* removal.
|
|
169
|
+
*/
|
|
170
|
+
const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
171
|
+
|
|
172
|
+
function gcAgedAcks(now: number): void {
|
|
173
|
+
let names: string[] = [];
|
|
174
|
+
try {
|
|
175
|
+
names = readdirSync(acksDir());
|
|
176
|
+
} catch {
|
|
177
|
+
return; /* no acknowledgements yet */
|
|
178
|
+
}
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
if (!name.endsWith(".json")) continue;
|
|
181
|
+
const path = join(acksDir(), name);
|
|
182
|
+
try {
|
|
183
|
+
if (statSync(path).mtimeMs >= now - ACK_ORPHAN_GC_AFTER_MS) continue;
|
|
184
|
+
} catch {
|
|
185
|
+
continue; /* raced away */
|
|
186
|
+
}
|
|
187
|
+
rmSync(path, { force: true });
|
|
188
|
+
}
|
|
79
189
|
}
|
|
80
190
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Register a new active arming challenge for the project — overwriting the
|
|
193
|
+
* project's own pending file — and return the transaction id. Before the
|
|
194
|
+
* overwrite, the project's PRIOR id is read back and exactly that id's
|
|
195
|
+
* acknowledgement is pruned: project-local cleanup with no directory
|
|
196
|
+
* snapshot, so another project's handshake cannot lose its proof here no
|
|
197
|
+
* matter how the calls interleave.
|
|
198
|
+
*/
|
|
199
|
+
export function recordArmChallenge(
|
|
200
|
+
project: string | undefined,
|
|
201
|
+
code: string,
|
|
202
|
+
sentAt: number,
|
|
203
|
+
expiresAt: number,
|
|
204
|
+
): string {
|
|
84
205
|
const key = projectKey(project);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
206
|
+
// Read the prior record BEFORE the overwrite shadows it.
|
|
207
|
+
const prior = readPendingFor(key);
|
|
208
|
+
const id = randomUUID();
|
|
209
|
+
const record: PendingChallenge = { project: key, id, hash: challengeHash(code), sentAt, expiresAt };
|
|
210
|
+
writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
|
|
211
|
+
if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
|
|
212
|
+
gcAgedAcks(sentAt);
|
|
213
|
+
return id;
|
|
88
214
|
}
|
|
89
215
|
|
|
90
216
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* code to the model's other reads. An unsolicited lookalike that matches no
|
|
97
|
-
* active challenge, and a reply in the wrong project, both stay inert.
|
|
217
|
+
* The inbound adapter's acknowledgement (conductor #614): classify the reply
|
|
218
|
+
* against the project's non-expired pending challenge and, on a match,
|
|
219
|
+
* atomically write the challenge-id-specific acknowledgement record the host
|
|
220
|
+
* waits on. Returns whether the reply is an active arming proof, driving the
|
|
221
|
+
* existing deterministic UX path.
|
|
98
222
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
223
|
+
* Writes only this challenge's own file — it never reads or rewrites any other
|
|
224
|
+
* record — so a wrong token, wrong project, expired challenge, or a racing
|
|
225
|
+
* host settle all fail closed without touching anyone else's handshake, and
|
|
226
|
+
* two projects acknowledging concurrently cannot clobber each other.
|
|
101
227
|
*/
|
|
102
|
-
export function
|
|
103
|
-
const pending =
|
|
104
|
-
if (pending === undefined
|
|
105
|
-
|
|
228
|
+
export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
|
|
229
|
+
const pending = readPendingFor(projectKey(project));
|
|
230
|
+
if (pending === undefined) return false;
|
|
231
|
+
if (now >= pending.expiresAt) return false;
|
|
106
232
|
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
107
233
|
// splits one; empty replies simply yield no token.
|
|
234
|
+
const targetHash = pending.hash;
|
|
235
|
+
let matched = false;
|
|
108
236
|
for (const token of replyText.trim().split(/\s+/)) {
|
|
109
|
-
if (token.length > 0 && challengeHash(token) === targetHash)
|
|
237
|
+
if (token.length > 0 && challengeHash(token) === targetHash) {
|
|
238
|
+
matched = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
110
241
|
}
|
|
111
|
-
return false;
|
|
242
|
+
if (!matched) return false;
|
|
243
|
+
// Keyed by the challenge id, so replays overwrite the one record the single
|
|
244
|
+
// live waiter consumes; a stale id's file can never be created here.
|
|
245
|
+
const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
|
|
246
|
+
writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The acknowledgement record for one exact challenge id, or undefined. */
|
|
251
|
+
export function readArmAcknowledgement(challengeId: string): ArmAcknowledgement | undefined {
|
|
252
|
+
let parsed: unknown;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(readFileSync(ackPath(challengeId), "utf8"));
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined; /* absent, torn or garbage — not an acknowledgement */
|
|
257
|
+
}
|
|
258
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
259
|
+
const ack = parsed as { challengeId?: unknown; acknowledgedAt?: unknown };
|
|
260
|
+
if (ack.challengeId !== challengeId || typeof ack.acknowledgedAt !== "number") return undefined;
|
|
261
|
+
return { challengeId, acknowledgedAt: ack.acknowledgedAt };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Settle one arm transaction — consumption, timeout, or send failure: remove
|
|
266
|
+
* the project's pending file iff it still names this transaction, and drop
|
|
267
|
+
* its acknowledgement. Deletion is id-addressed only, so another project's
|
|
268
|
+
* records are untouchable by construction, and a same-project replacement
|
|
269
|
+
* (different id) survives the stale settle.
|
|
270
|
+
*/
|
|
271
|
+
export function clearArmTransaction(project: string | undefined, challengeId: string): void {
|
|
272
|
+
const key = projectKey(project);
|
|
273
|
+
const pending = readPendingFor(key);
|
|
274
|
+
if (pending !== undefined && pending.id === challengeId) rmSync(pendingPath(key), { force: true });
|
|
275
|
+
rmSync(ackPath(challengeId), { force: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ArmChallengeSighting {
|
|
279
|
+
/** The pending transaction's id — random, not the challenge code. */
|
|
280
|
+
id: string;
|
|
281
|
+
/** Unix ms the challenge was recorded, when the record carries it. */
|
|
282
|
+
sentAt?: number;
|
|
283
|
+
/** Unix ms the challenge expires, when the record carries it. */
|
|
284
|
+
expiresAt?: number;
|
|
285
|
+
/** Present once the inbound adapter acknowledged this challenge id. */
|
|
286
|
+
acknowledgedAt?: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Doctor's view of the project's open transaction, or undefined when none is
|
|
291
|
+
* recorded. An expired-but-uncleared record is reported, not hidden: it is
|
|
292
|
+
* inert (a reply past expiry is refused) but lingers until the project's next
|
|
293
|
+
* arm replaces it.
|
|
294
|
+
*/
|
|
295
|
+
export function observeArmChallenge(project: string | undefined): ArmChallengeSighting | undefined {
|
|
296
|
+
const pending = readPendingFor(projectKey(project));
|
|
297
|
+
if (pending === undefined || typeof pending.id !== "string") return undefined;
|
|
298
|
+
const ack = readArmAcknowledgement(pending.id);
|
|
299
|
+
return {
|
|
300
|
+
id: pending.id,
|
|
301
|
+
...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
|
|
302
|
+
...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
|
|
303
|
+
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
304
|
+
};
|
|
112
305
|
}
|