omp-conductor 0.19.7 → 0.20.1
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 +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/arm-challenge.ts
CHANGED
|
@@ -1,48 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authenticated pending-challenge state for the arming handshake (conductor
|
|
3
|
-
* #415, transaction reworked by #614, storage hardened by review of #896
|
|
3
|
+
* #415, transaction reworked by #614, storage hardened by review of #896,
|
|
4
|
+
* targets recorded when the console took over verification in phase 1 of the
|
|
5
|
+
* orchestrator-workflow redesign).
|
|
4
6
|
*
|
|
5
7
|
* `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* files the challenge here. Verification classifies the operator's message
|
|
9
|
+
* against these records and arms the projects the challenge named — either
|
|
10
|
+
* mechanically in the orchestrator session, which owns the project topic the
|
|
11
|
+
* challenge is sent to (#1061), or through the CLI step
|
|
12
|
+
* `omp-conductor arm --reply "<the operator's message>"` from a console host
|
|
13
|
+
* whose DM the operator answered in.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Nothing waits for the reply any more. The console session owns the operator
|
|
16
|
+
* DM, and no tick extension runs there — so the in-session acknowledgement
|
|
17
|
+
* wait `armTicks` used to hold could never be satisfied. What survives is the
|
|
18
|
+
* durable half: the record that says which code is live, until when, and which
|
|
19
|
+
* projects it arms.
|
|
20
|
+
*
|
|
21
|
+
* This module stays a leaf so both roles can reach the state without an import
|
|
22
|
+
* cycle: `fleet.ts` imports `daemon.ts`, `daemon.ts` imports
|
|
23
|
+
* `orchestrator-tick.ts`, so `orchestrator-tick.ts` can never import
|
|
24
|
+
* `fleet.ts`. Everything here reads only `config.ts`.
|
|
17
25
|
*
|
|
18
26
|
* The handshake keeps no shared mutable state at all — every file is named by
|
|
19
27
|
* its own key, and every deletion is id-addressed, so two processes can
|
|
20
28
|
* neither lose each other's updates nor delete each other's proofs:
|
|
21
29
|
*
|
|
22
30
|
* - `arm-challenges/<project key>.json` — one pending challenge per project:
|
|
23
|
-
* `{ project, id, hash, sentAt, expiresAt }`. Host-owned:
|
|
24
|
-
* writes it (record before the send, settle on
|
|
25
|
-
* failure), always through the atomic tmp+rename write
|
|
26
|
-
* uses. A re-armed project overwrites its own file —
|
|
27
|
-
* prior id's acknowledgement; a concurrent arm for a
|
|
28
|
-
* touches a different file.
|
|
31
|
+
* `{ project, id, hash, sentAt, expiresAt, targets?, owner? }`. Host-owned:
|
|
32
|
+
* only the arm ceremony writes it (record before the send, settle on
|
|
33
|
+
* consumption or send failure), always through the atomic tmp+rename write
|
|
34
|
+
* the admission ack uses. A re-armed project overwrites its own file —
|
|
35
|
+
* pruning only its own prior id's acknowledgement; a concurrent arm for a
|
|
36
|
+
* different project touches a different file.
|
|
29
37
|
* - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
|
|
30
|
-
* `{ challengeId, acknowledgedAt }`. The
|
|
38
|
+
* `{ challengeId, acknowledgedAt }`. The reply step's acknowledgement is a
|
|
31
39
|
* single-file create/overwrite via rename — it never reads or rewrites
|
|
32
|
-
* another transaction's record
|
|
33
|
-
*
|
|
40
|
+
* another transaction's record, and `doctor` reads it to report a handshake
|
|
41
|
+
* that was answered but never settled. Files that outlive their transaction
|
|
42
|
+
* (a crash between the acknowledgement and the settle) are inert by
|
|
34
43
|
* construction and removed only by age, never by membership in any
|
|
35
44
|
* directory snapshot (see {@link gcAgedAcks}).
|
|
36
45
|
*
|
|
46
|
+
* `targets` is why the reply step never re-derives what to arm: the challenge
|
|
47
|
+
* the operator answered named specific projects, and the config could have
|
|
48
|
+
* changed between the send and the reply. Arming what the record says is the
|
|
49
|
+
* only reading that matches what the operator was asked. It is optional purely
|
|
50
|
+
* for backward tolerance — a record written before this field existed still
|
|
51
|
+
* verifies (see {@link resolveArmReply}).
|
|
52
|
+
*
|
|
37
53
|
* Only the sha-256 of the code is ever persisted — never the code, whose
|
|
38
|
-
* plaintext appearance in the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* fleet with a lookalike.
|
|
54
|
+
* plaintext appearance in the chat is the whole proof and must not leak into a
|
|
55
|
+
* durable report, an issue comment, or a diagnostic line the way a new
|
|
56
|
+
* artifact could. The id is a random UUID cut with the challenge, so
|
|
57
|
+
* acknowledgement records are challenge-specific without carrying anything
|
|
58
|
+
* guessable. No `FLEET-` prefix is ever trusted: a reply is classified by
|
|
59
|
+
* hashing its whitespace-separated tokens against the pending record, so no
|
|
60
|
+
* lookalike can arm a fleet.
|
|
46
61
|
*/
|
|
47
62
|
|
|
48
63
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -53,6 +68,21 @@ import { stateDir } from "./config.ts";
|
|
|
53
68
|
const ARM_CHALLENGES_DIR = "arm-challenges";
|
|
54
69
|
const ARM_ACKS_DIR = "arm-challenge-acks";
|
|
55
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One project a challenge arms: recorded with the challenge so the reply step
|
|
73
|
+
* arms exactly what the operator was asked about, never a re-derivation.
|
|
74
|
+
*/
|
|
75
|
+
export interface ArmTarget {
|
|
76
|
+
/**
|
|
77
|
+
* The configured project name, absent only for a legacy unstamped
|
|
78
|
+
* single-project fleet — the same value `resolveArmState` needs to decide
|
|
79
|
+
* whether the pre-per-project shared marker still speaks for this project.
|
|
80
|
+
*/
|
|
81
|
+
project?: string;
|
|
82
|
+
/** The arm marker path this project's heartbeat reads. */
|
|
83
|
+
armedFile: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
interface PendingChallenge {
|
|
57
87
|
/** The project key this pending belongs to, mirrored for read-back checks. */
|
|
58
88
|
project: string;
|
|
@@ -64,12 +94,24 @@ interface PendingChallenge {
|
|
|
64
94
|
sentAt: number;
|
|
65
95
|
/** Unix ms after which a matching reply is no longer an active proof. */
|
|
66
96
|
expiresAt: number;
|
|
97
|
+
/**
|
|
98
|
+
* The projects this challenge arms. Absent on a record written before the
|
|
99
|
+
* field existed; callers fall back rather than crash.
|
|
100
|
+
*/
|
|
101
|
+
targets?: ArmTarget[];
|
|
102
|
+
/**
|
|
103
|
+
* The chat the challenge was actually sent to, so the marker the reply step
|
|
104
|
+
* writes attributes the arm to the operator who was asked — not to whatever
|
|
105
|
+
* the paired channel happens to say minutes later. Absent on a pre-targets
|
|
106
|
+
* record.
|
|
107
|
+
*/
|
|
108
|
+
owner?: string;
|
|
67
109
|
}
|
|
68
110
|
|
|
69
111
|
interface ArmAcknowledgement {
|
|
70
112
|
/** The exact challenge id this record satisfies. */
|
|
71
113
|
challengeId: string;
|
|
72
|
-
/** Unix ms the
|
|
114
|
+
/** Unix ms the reply step classified the operator's message as this proof. */
|
|
73
115
|
acknowledgedAt: number;
|
|
74
116
|
}
|
|
75
117
|
|
|
@@ -125,6 +167,31 @@ function writeFileAtomic(path: string, content: string): void {
|
|
|
125
167
|
}
|
|
126
168
|
}
|
|
127
169
|
|
|
170
|
+
/**
|
|
171
|
+
* A recorded target list, validated element by element, or undefined when the
|
|
172
|
+
* record carries none. `invalid` is deliberately distinct from `absent`: a
|
|
173
|
+
* torn target list must fail the whole record closed (below) rather than
|
|
174
|
+
* silently degrade into the legacy fallback, which would arm a project the
|
|
175
|
+
* challenge may never have named.
|
|
176
|
+
*/
|
|
177
|
+
function readTargets(raw: unknown): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; targets: ArmTarget[] } {
|
|
178
|
+
if (raw === undefined) return { kind: "absent" };
|
|
179
|
+
if (!Array.isArray(raw) || raw.length === 0) return { kind: "invalid" };
|
|
180
|
+
const targets: ArmTarget[] = [];
|
|
181
|
+
for (const entry of raw) {
|
|
182
|
+
if (entry === null || typeof entry !== "object") return { kind: "invalid" };
|
|
183
|
+
const target = entry as { project?: unknown; armedFile?: unknown };
|
|
184
|
+
if (typeof target.armedFile !== "string" || target.armedFile.length === 0) return { kind: "invalid" };
|
|
185
|
+
if (target.project !== undefined && typeof target.project !== "string") return { kind: "invalid" };
|
|
186
|
+
targets.push(
|
|
187
|
+
target.project === undefined
|
|
188
|
+
? { armedFile: target.armedFile }
|
|
189
|
+
: { project: target.project, armedFile: target.armedFile },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return { kind: "ok", targets };
|
|
193
|
+
}
|
|
194
|
+
|
|
128
195
|
/**
|
|
129
196
|
* This project's pending challenge parsed from its own file, with every field
|
|
130
197
|
* validated. Anything absent, torn, garbage, pre-#614, or naming another
|
|
@@ -144,11 +211,17 @@ function readPendingFor(key: string): PendingChallenge | undefined {
|
|
|
144
211
|
pending.project !== key ||
|
|
145
212
|
typeof pending.id !== "string" ||
|
|
146
213
|
typeof pending.hash !== "string" ||
|
|
147
|
-
typeof pending.expiresAt !== "number"
|
|
214
|
+
typeof pending.expiresAt !== "number" ||
|
|
215
|
+
(pending.owner !== undefined && typeof pending.owner !== "string")
|
|
148
216
|
) {
|
|
149
217
|
return undefined;
|
|
150
218
|
}
|
|
151
|
-
|
|
219
|
+
const targets = readTargets(pending.targets);
|
|
220
|
+
if (targets.kind === "invalid") return undefined;
|
|
221
|
+
return {
|
|
222
|
+
...(pending as PendingChallenge),
|
|
223
|
+
...(targets.kind === "ok" ? { targets: targets.targets } : {}),
|
|
224
|
+
};
|
|
152
225
|
}
|
|
153
226
|
|
|
154
227
|
/** sha-256 hex of the challenge code — the persisted token, never the code. */
|
|
@@ -182,14 +255,13 @@ export function looksLikeChallengeCode(token: string): boolean {
|
|
|
182
255
|
|
|
183
256
|
/**
|
|
184
257
|
* Acknowledgement files outliving their transaction — a crash between the
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* removal.
|
|
258
|
+
* classification and the settle — are inert by construction: nothing ever
|
|
259
|
+
* reads their id again, and ids are UUIDs, so no future transaction can
|
|
260
|
+
* collide with one. They are therefore not swept by membership (a directory
|
|
261
|
+
* listing captured before another process records is stale the moment it is
|
|
262
|
+
* taken, and acting on it deletes on-time proofs), only by age: a file older
|
|
263
|
+
* than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong to a live handshake under
|
|
264
|
+
* any configured window, so its mtime alone decides removal.
|
|
193
265
|
*/
|
|
194
266
|
const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
195
267
|
|
|
@@ -212,6 +284,17 @@ function gcAgedAcks(now: number): void {
|
|
|
212
284
|
}
|
|
213
285
|
}
|
|
214
286
|
|
|
287
|
+
/**
|
|
288
|
+
* What a challenge arms, recorded with it: the projects the operator was told
|
|
289
|
+
* about and the chat they were asked in.
|
|
290
|
+
*/
|
|
291
|
+
export interface ArmChallengePlan {
|
|
292
|
+
/** Every project a matching reply arms — one for `arm --project X`, all of them for a fleet ceremony. */
|
|
293
|
+
targets: readonly ArmTarget[];
|
|
294
|
+
/** The chat the challenge is being sent to. */
|
|
295
|
+
owner: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
215
298
|
/**
|
|
216
299
|
* Register a new active arming challenge for the project — overwriting the
|
|
217
300
|
* project's own pending file — and return the transaction id. Before the
|
|
@@ -219,18 +302,31 @@ function gcAgedAcks(now: number): void {
|
|
|
219
302
|
* acknowledgement is pruned: project-local cleanup with no directory
|
|
220
303
|
* snapshot, so another project's handshake cannot lose its proof here no
|
|
221
304
|
* matter how the calls interleave.
|
|
305
|
+
*
|
|
306
|
+
* `plan` is optional only so the pre-targets call shape stays valid for the
|
|
307
|
+
* surfaces (doctor's fixtures, the CLI's own tests) that record a challenge
|
|
308
|
+
* without meaning to arm anything through the reply step. The ceremony always
|
|
309
|
+
* passes one.
|
|
222
310
|
*/
|
|
223
311
|
export function recordArmChallenge(
|
|
224
312
|
project: string | undefined,
|
|
225
313
|
code: string,
|
|
226
314
|
sentAt: number,
|
|
227
315
|
expiresAt: number,
|
|
316
|
+
plan?: ArmChallengePlan,
|
|
228
317
|
): string {
|
|
229
318
|
const key = projectKey(project);
|
|
230
319
|
// Read the prior record BEFORE the overwrite shadows it.
|
|
231
320
|
const prior = readPendingFor(key);
|
|
232
321
|
const id = randomUUID();
|
|
233
|
-
const record: PendingChallenge = {
|
|
322
|
+
const record: PendingChallenge = {
|
|
323
|
+
project: key,
|
|
324
|
+
id,
|
|
325
|
+
hash: challengeHash(code),
|
|
326
|
+
sentAt,
|
|
327
|
+
expiresAt,
|
|
328
|
+
...(plan === undefined ? {} : { targets: [...plan.targets], owner: plan.owner }),
|
|
329
|
+
};
|
|
234
330
|
writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
|
|
235
331
|
if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
|
|
236
332
|
gcAgedAcks(sentAt);
|
|
@@ -238,35 +334,53 @@ export function recordArmChallenge(
|
|
|
238
334
|
}
|
|
239
335
|
|
|
240
336
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* atomically write the challenge-id-specific acknowledgement record the host
|
|
244
|
-
* waits on. Returns whether the reply is an active arming proof, driving the
|
|
245
|
-
* existing deterministic UX path.
|
|
246
|
-
*
|
|
247
|
-
* Writes only this challenge's own file — it never reads or rewrites any other
|
|
248
|
-
* record — so a wrong token, wrong project, expired challenge, or a racing
|
|
249
|
-
* host settle all fail closed without touching anyone else's handshake, and
|
|
250
|
-
* two projects acknowledging concurrently cannot clobber each other.
|
|
251
|
-
*/
|
|
252
|
-
/**
|
|
253
|
-
* What an inbound reply was, so the adapter can answer it (#991).
|
|
337
|
+
* What a reply was, so the console can answer it (#991, retained now that the
|
|
338
|
+
* console — not a tick session — does the answering).
|
|
254
339
|
*
|
|
255
|
-
* Before this, a non-matching or expired code was simply ignored: the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
340
|
+
* Before this, a non-matching or expired code was simply ignored: the operator
|
|
341
|
+
* had no idea they had been heard, and the fastest way to be sure you held the
|
|
342
|
+
* current code was to scroll the chat. That is exactly the work
|
|
343
|
+
* conductor-owned verification exists to end.
|
|
259
344
|
*
|
|
260
345
|
* - `matched` — an active proof; the acknowledgement is written.
|
|
261
346
|
* - `expired` — a code that hashes to a pending record whose window has closed.
|
|
262
347
|
* - `unknown` — a challenge-shaped token that matches nothing readable here.
|
|
263
|
-
* - `none` — no challenge-shaped token at all: ordinary chat,
|
|
348
|
+
* - `none` — no challenge-shaped token at all: ordinary chat, arm nothing.
|
|
264
349
|
*/
|
|
265
350
|
export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
|
|
266
351
|
|
|
352
|
+
/** The transaction a matching reply proved, and what the challenge said it arms. */
|
|
353
|
+
export interface ArmReplyMatch {
|
|
354
|
+
/**
|
|
355
|
+
* The state key the matched record lives under — a project key (possibly the
|
|
356
|
+
* empty key of an unstamped config) or {@link FLEET_ARM_KEY}. Passed straight
|
|
357
|
+
* back to {@link clearArmTransaction}, so the settle is addressed to the
|
|
358
|
+
* record that was actually consumed.
|
|
359
|
+
*/
|
|
360
|
+
key: string;
|
|
361
|
+
/** The matched transaction's id; every deletion is addressed by it. */
|
|
362
|
+
id: string;
|
|
363
|
+
/**
|
|
364
|
+
* The projects the challenge recorded. Absent on a record written before
|
|
365
|
+
* targets existed — the caller falls back rather than crashing.
|
|
366
|
+
*/
|
|
367
|
+
targets?: ArmTarget[];
|
|
368
|
+
/** The chat the challenge was sent to. Absent on a pre-targets record. */
|
|
369
|
+
owner?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Discriminated so a caller cannot reach for `match` on a verdict that has
|
|
374
|
+
* none: only `matched` carries the transaction, and only `matched` may write a
|
|
375
|
+
* marker.
|
|
376
|
+
*/
|
|
377
|
+
export type ArmReplyResolution =
|
|
378
|
+
| { verdict: "matched"; match: ArmReplyMatch }
|
|
379
|
+
| { verdict: Exclude<ArmReplyVerdict, "matched">; match?: undefined };
|
|
380
|
+
|
|
267
381
|
/**
|
|
268
|
-
* Classify one
|
|
269
|
-
*
|
|
382
|
+
* Classify one operator reply against this host's own records and, on a match,
|
|
383
|
+
* write the challenge-id-specific acknowledgement.
|
|
270
384
|
*
|
|
271
385
|
* Two records are consulted and no others: this project's, and the fleet-wide
|
|
272
386
|
* one (#991). That bound is the non-disclosure property — a reply that matches
|
|
@@ -274,25 +388,35 @@ export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
|
|
|
274
388
|
* challenge, because no other project's record is ever read.
|
|
275
389
|
*
|
|
276
390
|
* Fail-closed ordering is unchanged: the project's own active challenge wins,
|
|
277
|
-
* then the fleet ceremony, and an expired record is never a proof.
|
|
391
|
+
* then the fleet ceremony, and an expired record is never a proof. The write
|
|
392
|
+
* touches only the matched challenge's own file, so a wrong token, a wrong
|
|
393
|
+
* project, an expired challenge or a racing settle all fail closed without
|
|
394
|
+
* touching anyone else's handshake.
|
|
395
|
+
*
|
|
396
|
+
* The acknowledgement record is no longer something a waiter polls — nothing
|
|
397
|
+
* waits any more. It is kept because it is the only durable evidence that a
|
|
398
|
+
* reply was seen, which is what `doctor`'s `arm-ack` finding reports when a
|
|
399
|
+
* ceremony was answered but never settled.
|
|
278
400
|
*/
|
|
279
|
-
export function
|
|
401
|
+
export function resolveArmReply(
|
|
280
402
|
project: string | undefined,
|
|
281
403
|
replyText: string,
|
|
282
404
|
now: number,
|
|
283
|
-
):
|
|
405
|
+
): ArmReplyResolution {
|
|
284
406
|
// Challenge codes contain no whitespace, so tokenising on whitespace never
|
|
285
407
|
// splits one; empty replies simply yield no token.
|
|
286
408
|
const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0);
|
|
287
|
-
if (tokens.length === 0) return "none";
|
|
288
|
-
const candidates = [
|
|
289
|
-
|
|
290
|
-
|
|
409
|
+
if (tokens.length === 0) return { verdict: "none" };
|
|
410
|
+
const candidates: { key: string; pending: PendingChallenge }[] = [];
|
|
411
|
+
for (const key of [projectKey(project), FLEET_ARM_KEY]) {
|
|
412
|
+
const pending = readPendingFor(key);
|
|
413
|
+
if (pending !== undefined) candidates.push({ key, pending });
|
|
414
|
+
}
|
|
291
415
|
|
|
292
416
|
let expired = false;
|
|
293
417
|
for (const token of tokens) {
|
|
294
418
|
const hash = challengeHash(token);
|
|
295
|
-
for (const pending of candidates) {
|
|
419
|
+
for (const { key, pending } of candidates) {
|
|
296
420
|
if (pending.hash !== hash) continue;
|
|
297
421
|
if (now >= pending.expiresAt) {
|
|
298
422
|
// Keep looking: a fresh record for the same code is a proof, and only
|
|
@@ -300,27 +424,25 @@ export function classifyArmReply(
|
|
|
300
424
|
expired = true;
|
|
301
425
|
continue;
|
|
302
426
|
}
|
|
303
|
-
// Keyed by the challenge id, so replays overwrite the one record
|
|
304
|
-
//
|
|
305
|
-
// here.
|
|
427
|
+
// Keyed by the challenge id, so replays overwrite the one record this
|
|
428
|
+
// transaction owns; a stale id's file can never be created here.
|
|
306
429
|
const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
|
|
307
430
|
writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
|
|
308
|
-
return
|
|
431
|
+
return {
|
|
432
|
+
verdict: "matched",
|
|
433
|
+
match: {
|
|
434
|
+
key,
|
|
435
|
+
id: pending.id,
|
|
436
|
+
...(pending.targets === undefined ? {} : { targets: pending.targets }),
|
|
437
|
+
...(pending.owner === undefined ? {} : { owner: pending.owner }),
|
|
438
|
+
},
|
|
439
|
+
};
|
|
309
440
|
}
|
|
310
441
|
}
|
|
311
|
-
if (expired) return "expired";
|
|
312
|
-
// Shape only, and only to decide
|
|
313
|
-
//
|
|
314
|
-
return tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* The inbound adapter's acknowledgement (conductor #614), kept as the boolean
|
|
319
|
-
* the availability gate already reads. {@link classifyArmReply} is the same
|
|
320
|
-
* pass with the non-matching cases named.
|
|
321
|
-
*/
|
|
322
|
-
export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
|
|
323
|
-
return classifyArmReply(project, replyText, now) === "matched";
|
|
442
|
+
if (expired) return { verdict: "expired" };
|
|
443
|
+
// Shape only, and only to decide how the operator is answered. A token that
|
|
444
|
+
// is not challenge-shaped is ordinary chat.
|
|
445
|
+
return { verdict: tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none" };
|
|
324
446
|
}
|
|
325
447
|
|
|
326
448
|
/** The acknowledgement record for one exact challenge id, or undefined. */
|
|
@@ -379,3 +501,51 @@ export function observeArmChallenge(project: string | undefined): ArmChallengeSi
|
|
|
379
501
|
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
380
502
|
};
|
|
381
503
|
}
|
|
504
|
+
|
|
505
|
+
/** One expired, unsettled ceremony, with the key the notice clears it under. */
|
|
506
|
+
export interface ExpiredArmChallenge {
|
|
507
|
+
/**
|
|
508
|
+
* The record's own state key — the project key or {@link FLEET_ARM_KEY} —
|
|
509
|
+
* passed straight back to {@link clearArmTransaction} after the notice, so
|
|
510
|
+
* the clear is addressed to the record that actually expired.
|
|
511
|
+
*/
|
|
512
|
+
key: string;
|
|
513
|
+
/** The expired transaction's id. */
|
|
514
|
+
id: string;
|
|
515
|
+
/** The sighting, acknowledgement included when a reply was seen. */
|
|
516
|
+
sighting: ArmChallengeSighting;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Every pending challenge that is no longer a proof — the project's own record
|
|
521
|
+
* and the fleet-wide one, in the same consultation order as
|
|
522
|
+
* {@link resolveArmReply} — whose window has passed without being settled.
|
|
523
|
+
* An acknowledgement may already exist (the reply was seen but the settle
|
|
524
|
+
* never completed), which is still unsettled: the notice must name that
|
|
525
|
+
* reason, and {@link doctor} does the same. This is what lets the fleet
|
|
526
|
+
* session tell the operator a ceremony died instead of letting the record sit
|
|
527
|
+
* invisible until the next arm replaces it.
|
|
528
|
+
*/
|
|
529
|
+
export function expiredArmChallenges(
|
|
530
|
+
project: string | undefined,
|
|
531
|
+
now: number,
|
|
532
|
+
): ExpiredArmChallenge[] {
|
|
533
|
+
const keys = projectKey(project) === FLEET_ARM_KEY ? [FLEET_ARM_KEY] : [projectKey(project), FLEET_ARM_KEY];
|
|
534
|
+
const expired: ExpiredArmChallenge[] = [];
|
|
535
|
+
for (const key of keys) {
|
|
536
|
+
const pending = readPendingFor(key);
|
|
537
|
+
if (pending === undefined || pending.expiresAt > now) continue;
|
|
538
|
+
const ack = readArmAcknowledgement(pending.id);
|
|
539
|
+
expired.push({
|
|
540
|
+
key,
|
|
541
|
+
id: pending.id,
|
|
542
|
+
sighting: {
|
|
543
|
+
id: pending.id,
|
|
544
|
+
...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
|
|
545
|
+
...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
|
|
546
|
+
...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return expired;
|
|
551
|
+
}
|