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/fleet.ts
CHANGED
|
@@ -27,8 +27,10 @@ import { findProject, loadConfig, resolveArmProof, stateDir } from "./config.ts"
|
|
|
27
27
|
import {
|
|
28
28
|
clearArmTransaction,
|
|
29
29
|
FLEET_ARM_KEY,
|
|
30
|
-
readArmAcknowledgement,
|
|
31
30
|
recordArmChallenge,
|
|
31
|
+
resolveArmReply,
|
|
32
|
+
type ArmReplyVerdict,
|
|
33
|
+
type ArmTarget,
|
|
32
34
|
} from "./arm-challenge.ts";
|
|
33
35
|
import {
|
|
34
36
|
claimedTelegramTopics,
|
|
@@ -101,6 +103,7 @@ import {
|
|
|
101
103
|
resolveArmState,
|
|
102
104
|
TICK_CONFIG_FILE,
|
|
103
105
|
tickConfigMatchesProject,
|
|
106
|
+
writeArmedMarker,
|
|
104
107
|
type ArmState,
|
|
105
108
|
type TickConfig,
|
|
106
109
|
type TickConfigResult,
|
|
@@ -283,19 +286,54 @@ export function disarmTicks(projectName?: string): { path: string; wasArmed: boo
|
|
|
283
286
|
return { path, wasArmed };
|
|
284
287
|
}
|
|
285
288
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
alreadyArmed: boolean;
|
|
289
|
-
owner: string;
|
|
290
|
-
/** The challenge code that proved arming, present only for `challenge` proof. */
|
|
291
|
-
challenge?: string;
|
|
289
|
+
/** One project whose arm marker was written. */
|
|
290
|
+
export interface ArmedProject {
|
|
292
291
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
292
|
+
* The configured project name, absent only for a legacy unstamped
|
|
293
|
+
* single-project fleet.
|
|
295
294
|
*/
|
|
295
|
+
project?: string;
|
|
296
|
+
path: string;
|
|
297
|
+
alreadyArmed: boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Markers written without any reply: the `claim-only` policy proof (#613). */
|
|
301
|
+
export interface ArmMarkersWritten {
|
|
302
|
+
outcome: "armed";
|
|
296
303
|
proof: ArmProof;
|
|
304
|
+
owner: string;
|
|
305
|
+
armed: ArmedProject[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A challenge filed and sent, with nothing armed yet.
|
|
310
|
+
*
|
|
311
|
+
* Arming used to block here for up to five minutes on an in-session
|
|
312
|
+
* acknowledgement. Nothing waits now: the reply is consumed mechanically by
|
|
313
|
+
* the session whose topic the challenge went to (the orchestrator pane,
|
|
314
|
+
* #1061), or by `omp-conductor arm --reply` on a console host whose DM the
|
|
315
|
+
* operator answered in. This is the send half's receipt.
|
|
316
|
+
*/
|
|
317
|
+
export interface ArmChallengeSent {
|
|
318
|
+
outcome: "challenge-sent";
|
|
319
|
+
/** Always `challenge`: `claim-only` never sends, so it never reaches here. */
|
|
320
|
+
proof: "challenge";
|
|
321
|
+
/** The chat the challenge went to, recorded with the transaction. */
|
|
322
|
+
owner: string;
|
|
323
|
+
/** The pending transaction the reply must prove. */
|
|
324
|
+
challengeId: string;
|
|
325
|
+
/** Unix ms after which the code stops being a proof. */
|
|
326
|
+
expiresAt: number;
|
|
327
|
+
/** mm:ss the code stays good for — the same clock the message quotes. */
|
|
328
|
+
validFor: string;
|
|
329
|
+
/** Exactly what a matching reply will arm, as recorded with the challenge. */
|
|
330
|
+
targets: ArmTarget[];
|
|
331
|
+
/** The verbatim command that completes the ceremony, copy-pasteable as-is. */
|
|
332
|
+
followUp: string;
|
|
297
333
|
}
|
|
298
334
|
|
|
335
|
+
export type ArmResult = ArmMarkersWritten | ArmChallengeSent;
|
|
336
|
+
|
|
299
337
|
export interface ArmDeps {
|
|
300
338
|
sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
|
|
301
339
|
/**
|
|
@@ -307,7 +345,7 @@ export interface ArmDeps {
|
|
|
307
345
|
*/
|
|
308
346
|
claimedSessionFile?: () => string | undefined;
|
|
309
347
|
now?: () => number;
|
|
310
|
-
|
|
348
|
+
/** How long a sent code stays a proof. */
|
|
311
349
|
timeoutMs?: number;
|
|
312
350
|
/**
|
|
313
351
|
* Liveness seams for the claim-only verdict (#613), with omp-telegram's own
|
|
@@ -321,11 +359,10 @@ export interface ArmDeps {
|
|
|
321
359
|
lockPidAlive?: (pid: number) => boolean;
|
|
322
360
|
lockFresh?: (mtimeMs: number) => boolean;
|
|
323
361
|
/**
|
|
324
|
-
* Where the
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* (the CLI, the wizard) pass theirs.
|
|
362
|
+
* Where the ceremony's one-line narration goes. Nothing waits any more, so
|
|
363
|
+
* this is no longer a liveness heartbeat (#861) — it is the send receipt and
|
|
364
|
+
* the follow-up instruction, for a surface that prints as it goes. Absent
|
|
365
|
+
* means no reporting.
|
|
329
366
|
*/
|
|
330
367
|
progress?: (line: string) => void;
|
|
331
368
|
}
|
|
@@ -367,30 +404,28 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
367
404
|
// one pane that can ask. The challenge names which one, or the operator is
|
|
368
405
|
// answering a question they cannot attribute.
|
|
369
406
|
const named = tick.config.project ?? projectName;
|
|
370
|
-
// The handshake state key must be exactly what the
|
|
371
|
-
//
|
|
372
|
-
// config. `named` may fall back to the CLI argument for the
|
|
373
|
-
// and config lookups; the state key must not
|
|
374
|
-
//
|
|
375
|
-
// an acknowledgement that can never be written.
|
|
407
|
+
// The handshake state key must be exactly what the reply step recomputes
|
|
408
|
+
// from this same tick config: TickConfig.project, undefined for a legacy
|
|
409
|
+
// unstamped config. `named` may fall back to the CLI argument for the
|
|
410
|
+
// challenge text and config lookups; the state key must not, or the reply
|
|
411
|
+
// would look for the challenge under a key nothing recorded.
|
|
376
412
|
const stateKey = tick.config.project;
|
|
377
413
|
|
|
378
414
|
// The arming proof is a declared per-project policy (#613). A config that
|
|
379
|
-
// cannot name the project fails safe to `challenge` —
|
|
415
|
+
// cannot name the project fails safe to `challenge` — the authenticated
|
|
380
416
|
// round-trip — so a missing or unreadable config never silently weakens the
|
|
381
417
|
// gate.
|
|
382
418
|
let proof: ArmProof = DEFAULT_ARM_PROOF;
|
|
383
419
|
try {
|
|
384
420
|
proof = resolveArmProof(findProject(loadConfig(), named));
|
|
385
421
|
} catch {
|
|
386
|
-
/* no project config — keep
|
|
422
|
+
/* no project config — keep the challenge behaviour */
|
|
387
423
|
}
|
|
388
424
|
|
|
389
425
|
// The orchestrator's live session file per omp-telegram's claim (#600) —
|
|
390
426
|
// input to the claim-only verdict's session-identity checks below. The
|
|
391
|
-
// challenge proof never reads it: its
|
|
392
|
-
//
|
|
393
|
-
// (#614).
|
|
427
|
+
// challenge proof never reads it: its proof is conductor state, so where (or
|
|
428
|
+
// whether) a transcript lives is no longer part of arming (#614).
|
|
394
429
|
const claimed =
|
|
395
430
|
deps.claimedSessionFile !== undefined
|
|
396
431
|
? deps.claimedSessionFile()
|
|
@@ -409,17 +444,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
409
444
|
}
|
|
410
445
|
|
|
411
446
|
const path = tick.config.armedFile;
|
|
412
|
-
// The gate as the heartbeat reads it, so "replaced previous marker" is not a
|
|
413
|
-
// lie about a fleet the shared marker was arming, and so the write below knows
|
|
414
|
-
// whether it is superseding that marker.
|
|
415
|
-
const arm = resolveArmState(path, named);
|
|
416
|
-
const alreadyArmed = arm.armed;
|
|
417
447
|
|
|
418
448
|
if (proof === "claim-only") {
|
|
419
449
|
// The human-intent gate is declared satisfied by policy, so #612's shared
|
|
420
450
|
// verdict is the whole proof: the same state reads and the same liveness
|
|
421
451
|
// rules the doctor's "telegram-plumbing" finding applies, on the route a
|
|
422
|
-
// challenge would have ridden. No Telegram send, no
|
|
452
|
+
// challenge would have ridden. No Telegram send, no reply step, no
|
|
423
453
|
// pending-challenge record. A failed fact refuses arming by name — never
|
|
424
454
|
// a silent pass from file existence, and never a marker.
|
|
425
455
|
const scan = armVerdictScanDirs(tick.cwd, claimed);
|
|
@@ -442,13 +472,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
442
472
|
`NOT armed; no marker was written`,
|
|
443
473
|
);
|
|
444
474
|
}
|
|
475
|
+
// The gate as the heartbeat reads it, so "replaced previous marker" is not
|
|
476
|
+
// a lie about a fleet the shared marker was arming, and so the write knows
|
|
477
|
+
// whether it is superseding that marker.
|
|
478
|
+
const arm = resolveArmState(path, named);
|
|
445
479
|
writeArmedMarker(path, channel.owner, arm);
|
|
446
|
-
return {
|
|
480
|
+
return {
|
|
481
|
+
outcome: "armed",
|
|
482
|
+
proof,
|
|
483
|
+
owner: channel.owner,
|
|
484
|
+
armed: [{ ...(named === undefined ? {} : { project: named }), path, alreadyArmed: arm.armed }],
|
|
485
|
+
};
|
|
447
486
|
}
|
|
448
487
|
|
|
449
488
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
450
489
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
451
490
|
const code = makeChallengeCode();
|
|
491
|
+
const followUp = armReplyCommand(projectName);
|
|
452
492
|
// Self-describing (#991): with two live challenges in one chat the operator
|
|
453
493
|
// was working out which was which from message order, and nothing said how
|
|
454
494
|
// long a code stayed good — so the safe move was to scroll for the newest,
|
|
@@ -457,15 +497,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
457
497
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
458
498
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
459
499
|
`Valid for ${armClock(timeoutMs)}. ` +
|
|
460
|
-
`Nothing
|
|
500
|
+
`Nothing is dispatched until that reply is verified.`;
|
|
461
501
|
const sentAt = (deps.now ?? Date.now)();
|
|
462
|
-
//
|
|
463
|
-
// challenge
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// a
|
|
468
|
-
const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs
|
|
502
|
+
// Recorded BEFORE the send, so a reply that beats this process's own return
|
|
503
|
+
// still finds an active challenge — and so a send that fails has a
|
|
504
|
+
// transaction to settle rather than a code loose in a chat (#415). The record
|
|
505
|
+
// carries what the reply will arm: the config could change before the
|
|
506
|
+
// operator answers, and arming anything but what the challenge named would
|
|
507
|
+
// arm a fleet nobody was asked about.
|
|
508
|
+
const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs, {
|
|
509
|
+
targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
|
|
510
|
+
owner: channel.owner,
|
|
511
|
+
});
|
|
469
512
|
try {
|
|
470
513
|
await send(token, channel.owner, text, sendTopic);
|
|
471
514
|
} catch (err) {
|
|
@@ -476,50 +519,33 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
476
519
|
`arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
|
|
477
520
|
);
|
|
478
521
|
}
|
|
479
|
-
// What is being waited on, before the wait starts: the window, the chat the
|
|
480
|
-
// reply has to land in, and the fact that dispatch is held until it does
|
|
481
|
-
// (#861). One line, so a five-minute wait opens with an explanation rather
|
|
482
|
-
// than with silence.
|
|
483
522
|
deps.progress?.(
|
|
484
523
|
`arm: challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} — ` +
|
|
485
|
-
`
|
|
524
|
+
`valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
|
|
486
525
|
);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
`arm: the challenge was never acknowledged in time — NOT armed.\n` +
|
|
498
|
-
`The orchestrator's inbound adapter acknowledges the reply when it lands as a user turn; ` +
|
|
499
|
-
`no acknowledgement for challenge ${challengeId} arrived.\n` +
|
|
500
|
-
`Inbound Telegram is not reaching the omp session. Check, in order:\n` +
|
|
501
|
-
` * is the bridge polling? attach and run: /telegram status\n` +
|
|
502
|
-
` * is another process holding this bot token? Telegram allows exactly one\n` +
|
|
503
|
-
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
504
|
-
` * did you reply in the DM with the bot, not another chat?\n`,
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
writeArmedMarker(path, channel.owner, arm);
|
|
509
|
-
// The acknowledgement landed and this project is armed: settling clears this
|
|
510
|
-
// transaction's pending record and acknowledgement — never a newer
|
|
511
|
-
// replacement's — so a later unsolicited lookalike stays inert past this
|
|
512
|
-
// handshake.
|
|
513
|
-
clearArmTransaction(stateKey, challengeId);
|
|
514
|
-
return { path, alreadyArmed, owner: channel.owner, challenge: code, proof };
|
|
526
|
+
return {
|
|
527
|
+
outcome: "challenge-sent",
|
|
528
|
+
proof: "challenge",
|
|
529
|
+
owner: channel.owner,
|
|
530
|
+
challengeId,
|
|
531
|
+
expiresAt: sentAt + timeoutMs,
|
|
532
|
+
validFor: armClock(timeoutMs),
|
|
533
|
+
targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
|
|
534
|
+
followUp,
|
|
535
|
+
};
|
|
515
536
|
}
|
|
516
537
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
538
|
+
/**
|
|
539
|
+
* The second half of the ceremony, verbatim. Printed by every surface that
|
|
540
|
+
* issues a challenge, and quoted in the refusals, because an agent reading a
|
|
541
|
+
* console pane has to be able to paste it without composing anything: the
|
|
542
|
+
* operator's message is the only variable.
|
|
543
|
+
*/
|
|
544
|
+
function armReplyCommand(projectName?: string): string {
|
|
545
|
+
return (
|
|
546
|
+
`omp-conductor arm --reply "<the operator's reply, verbatim>"` +
|
|
547
|
+
`${projectName === undefined ? "" : ` --project ${projectName}`}`
|
|
548
|
+
);
|
|
523
549
|
}
|
|
524
550
|
|
|
525
551
|
/**
|
|
@@ -527,10 +553,9 @@ export interface FleetArmResult {
|
|
|
527
553
|
*
|
|
528
554
|
* Arming was per project: a two-project fleet meant two sequential handshakes
|
|
529
555
|
* with two codes in one chat, though nothing about the fleet's state differed
|
|
530
|
-
* between them. This sends **one** challenge
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* current friction with one command wrapped around it.
|
|
556
|
+
* between them. This sends **one** challenge whose single matching reply arms
|
|
557
|
+
* every configured project — one fleet-wide pending record, not a loop that
|
|
558
|
+
* sends N challenges and collects N replies.
|
|
534
559
|
*
|
|
535
560
|
* Every project is validated before anything is sent, so a fleet whose second
|
|
536
561
|
* project has no `armedFile` refuses the ceremony instead of arming the first
|
|
@@ -541,11 +566,15 @@ export interface FleetArmResult {
|
|
|
541
566
|
* authenticated round-trip is strictly stronger than the plumbing verdict that
|
|
542
567
|
* policy would have accepted, so satisfying the weaker gate with the stronger
|
|
543
568
|
* proof cannot weaken it.
|
|
569
|
+
*
|
|
570
|
+
* Like {@link armTicks}, this returns as soon as the challenge is filed and
|
|
571
|
+
* sent: the reply settles it mechanically on the orchestrator pane's own
|
|
572
|
+
* topic, or through `omp-conductor arm --reply` from the console.
|
|
544
573
|
*/
|
|
545
574
|
export async function armFleet(
|
|
546
575
|
projectNames: readonly string[],
|
|
547
576
|
deps: ArmDeps = {},
|
|
548
|
-
): Promise<
|
|
577
|
+
): Promise<ArmChallengeSent> {
|
|
549
578
|
if (projectNames.length === 0) throw new Error("arm: no projects are configured");
|
|
550
579
|
|
|
551
580
|
// Resolve and validate every project first. Nothing is sent and no marker is
|
|
@@ -596,8 +625,8 @@ export async function armFleet(
|
|
|
596
625
|
|
|
597
626
|
// The first project that resolves a live topic carries the ceremony, and the
|
|
598
627
|
// message says so: a fleet-wide question still has to land somewhere an
|
|
599
|
-
// operator is reading, and
|
|
600
|
-
//
|
|
628
|
+
// operator is reading, and the reply step reaches the record from any project
|
|
629
|
+
// because it is fleet-wide rather than topic-scoped.
|
|
601
630
|
let sendTopic: number | undefined;
|
|
602
631
|
for (const target of targets) {
|
|
603
632
|
try {
|
|
@@ -612,14 +641,28 @@ export async function armFleet(
|
|
|
612
641
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
613
642
|
const code = makeChallengeCode();
|
|
614
643
|
const names = targets.map((t) => t.project).join(", ");
|
|
644
|
+
// No `--project`: the reply resolves the fleet record, which is the one this
|
|
645
|
+
// ceremony wrote. Naming a project here would be a narrower command than the
|
|
646
|
+
// challenge the operator answered.
|
|
647
|
+
const followUp = armReplyCommand();
|
|
615
648
|
const text =
|
|
616
649
|
`Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
|
|
617
650
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
618
651
|
`Valid for ${armClock(timeoutMs)}, and one reply arms all of them. ` +
|
|
619
|
-
`Nothing
|
|
652
|
+
`Nothing is dispatched until that reply is verified.`;
|
|
620
653
|
|
|
621
654
|
const sentAt = (deps.now ?? Date.now)();
|
|
622
|
-
|
|
655
|
+
// The record carries every project the message named, so the reply arms
|
|
656
|
+
// exactly the fleet the operator was asked about — not whatever the config
|
|
657
|
+
// says minutes later.
|
|
658
|
+
const armTargets: ArmTarget[] = targets.map((target) => ({
|
|
659
|
+
project: target.project,
|
|
660
|
+
armedFile: target.armedFile,
|
|
661
|
+
}));
|
|
662
|
+
const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs, {
|
|
663
|
+
targets: armTargets,
|
|
664
|
+
owner: channel.owner,
|
|
665
|
+
});
|
|
623
666
|
try {
|
|
624
667
|
await send(token, channel.owner, text, sendTopic);
|
|
625
668
|
} catch (err) {
|
|
@@ -630,31 +673,171 @@ export async function armFleet(
|
|
|
630
673
|
}
|
|
631
674
|
deps.progress?.(
|
|
632
675
|
`arm: one challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} for ${names} — ` +
|
|
633
|
-
`
|
|
676
|
+
`valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
|
|
634
677
|
);
|
|
678
|
+
return {
|
|
679
|
+
outcome: "challenge-sent",
|
|
680
|
+
proof: "challenge",
|
|
681
|
+
owner: channel.owner,
|
|
682
|
+
challengeId,
|
|
683
|
+
expiresAt: sentAt + timeoutMs,
|
|
684
|
+
validFor: armClock(timeoutMs),
|
|
685
|
+
targets: armTargets,
|
|
686
|
+
followUp,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
635
689
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
690
|
+
/** What a reply that armed nothing was, and what to do about it. */
|
|
691
|
+
export interface ArmReplyRefused {
|
|
692
|
+
outcome: "refused";
|
|
693
|
+
/** Never `matched`: a match is the armed outcome. */
|
|
694
|
+
verdict: Exclude<ArmReplyVerdict, "matched">;
|
|
695
|
+
/** One operator-facing sentence naming the verdict and the next move. */
|
|
696
|
+
message: string;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export interface ArmReplyAccepted {
|
|
700
|
+
outcome: "armed";
|
|
701
|
+
/** The chat the proved challenge was sent to — the marker's `owner=`. */
|
|
702
|
+
owner: string;
|
|
703
|
+
/** The transaction the reply proved and this call settled. */
|
|
704
|
+
challengeId: string;
|
|
705
|
+
/** Every project the challenge recorded, now armed. */
|
|
706
|
+
armed: ArmedProject[];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export type ArmReplyResult = ArmReplyAccepted | ArmReplyRefused;
|
|
648
710
|
|
|
649
|
-
|
|
650
|
-
|
|
711
|
+
/**
|
|
712
|
+
* The verification half of the ceremony: classify the operator's verbatim
|
|
713
|
+
* message and, on a match, arm exactly the projects the challenge recorded.
|
|
714
|
+
*
|
|
715
|
+
* This is the host CLI half, and it is one of two consumers of the same
|
|
716
|
+
* durable record: the orchestrator session's inbound path settles a reply
|
|
717
|
+
* sent to the project topic mechanically (#1061), while this command remains
|
|
718
|
+
* the console's route for a reply that landed in the operator DM. No session
|
|
719
|
+
* waits for anything: the challenge is durable state, so the halves are
|
|
720
|
+
* ordinary commands that can run minutes apart in different processes.
|
|
721
|
+
*
|
|
722
|
+
* The security properties are the send half's, unchanged. Only the project's
|
|
723
|
+
* own record and the fleet record are read (no other project's ceremony can be
|
|
724
|
+
* revealed or consumed), an expired record is never a proof, and a marker is
|
|
725
|
+
* written only for a `matched` verdict — every other verdict returns a refusal
|
|
726
|
+
* having written nothing.
|
|
727
|
+
*/
|
|
728
|
+
export function armReply(
|
|
729
|
+
replyText: string,
|
|
730
|
+
projectName?: string,
|
|
731
|
+
deps: Pick<ArmDeps, "now"> = {},
|
|
732
|
+
): ArmReplyResult {
|
|
733
|
+
const tick = resolveTickConfig(projectName);
|
|
734
|
+
if (tick.kind === "invalid") {
|
|
735
|
+
// Every derivation below would come from this file. A config that does not
|
|
736
|
+
// parse cannot be trusted to name a state key or a marker.
|
|
737
|
+
throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
|
|
738
|
+
}
|
|
739
|
+
// An absent config is not fatal here, which is the difference between the two
|
|
740
|
+
// halves: a fleet ceremony's record carries its own targets, and on a
|
|
741
|
+
// multi-project host the search roots for an unnamed project resolve no
|
|
742
|
+
// config at all (the fleet cwds each carry their own). Refusing here would
|
|
743
|
+
// make `arm --reply` unusable for exactly the ceremony that needs it most.
|
|
744
|
+
const config = tick.kind === "ok" ? tick.config : undefined;
|
|
745
|
+
// The same derivation the send half used, from the same file: the state key
|
|
746
|
+
// is TickConfig.project (undefined for a legacy unstamped config), never the
|
|
747
|
+
// CLI argument, or the reply would look under a key nothing recorded.
|
|
748
|
+
const stateKey = config?.project;
|
|
749
|
+
const now = (deps.now ?? Date.now)();
|
|
750
|
+
const resolved = resolveArmReply(stateKey, replyText, now);
|
|
751
|
+
if (resolved.verdict !== "matched") {
|
|
752
|
+
return {
|
|
753
|
+
outcome: "refused",
|
|
754
|
+
verdict: resolved.verdict,
|
|
755
|
+
message: armRefusalText(resolved.verdict, projectName),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const match = resolved.match;
|
|
759
|
+
// Recorded targets are the contract. A record written before targets existed
|
|
760
|
+
// (one release of overlap) can only be read as naming the project it is keyed
|
|
761
|
+
// under — or, for the fleet record, the project this command was given, since
|
|
762
|
+
// re-deriving the fleet from today's config could arm a project the operator
|
|
763
|
+
// was never asked about.
|
|
764
|
+
const targets: ArmTarget[] = match.targets ?? [legacyArmTarget(config, projectName)];
|
|
765
|
+
// The owner the challenge was actually sent to. Only a pre-targets record
|
|
766
|
+
// lacks it, and then the paired channel is the only other honest source.
|
|
767
|
+
const owner = match.owner ?? pairedChannelOwner(config?.accessFile);
|
|
651
768
|
const armed = targets.map((target) => {
|
|
652
769
|
const state = resolveArmState(target.armedFile, target.project);
|
|
653
|
-
writeArmedMarker(target.armedFile,
|
|
654
|
-
return {
|
|
770
|
+
writeArmedMarker(target.armedFile, owner, state);
|
|
771
|
+
return {
|
|
772
|
+
...(target.project === undefined ? {} : { project: target.project }),
|
|
773
|
+
path: target.armedFile,
|
|
774
|
+
alreadyArmed: state.armed,
|
|
775
|
+
};
|
|
655
776
|
});
|
|
656
|
-
|
|
657
|
-
|
|
777
|
+
// Settled by id against the key that matched, so a replayed reply cannot arm
|
|
778
|
+
// a second time and a newer replacement's record is never removed.
|
|
779
|
+
clearArmTransaction(match.key, match.id);
|
|
780
|
+
return { outcome: "armed", owner, challengeId: match.id, armed };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* What a pre-targets pending record arms. The tick config that resolved the
|
|
785
|
+
* state key also names this project's marker, which is exactly what the send
|
|
786
|
+
* half would have recorded. Without such a config there is nothing to fall back
|
|
787
|
+
* to, and guessing a marker path is not an option — a challenge from before the
|
|
788
|
+
* upgrade is simply re-run.
|
|
789
|
+
*/
|
|
790
|
+
function legacyArmTarget(config: TickConfig | undefined, projectName?: string): ArmTarget {
|
|
791
|
+
const named = config?.project ?? projectName;
|
|
792
|
+
if (config?.armedFile === undefined) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`arm: the pending challenge names no targets and no readable ${TICK_CONFIG_FILE} names an armedFile — ` +
|
|
795
|
+
`nothing to arm; re-run \`omp-conductor arm\` for a challenge that records its own targets`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return { ...(named === undefined ? {} : { project: named }), armedFile: config.armedFile };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** The paired owner, for the one record shape that does not carry its own. */
|
|
802
|
+
function pairedChannelOwner(accessFile: string | undefined): string {
|
|
803
|
+
if (accessFile === undefined) {
|
|
804
|
+
throw new Error(`${TICK_CONFIG_FILE} has no accessFile — the challenge's owner cannot be established`);
|
|
805
|
+
}
|
|
806
|
+
const channel = readPairedChannel(accessFile);
|
|
807
|
+
if (channel.kind === "down") {
|
|
808
|
+
throw new Error(
|
|
809
|
+
`escalation channel is not up (${accessFile}): ${channel.reason} — ` +
|
|
810
|
+
`this challenge predates owner recording, so the marker cannot name whom it armed`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return channel.owner;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Each non-matching verdict in the operator's words, with the next move. A
|
|
818
|
+
* silent no-op was the old failure mode: the operator could not tell a wrong
|
|
819
|
+
* code from an expired one from a command that never looked.
|
|
820
|
+
*/
|
|
821
|
+
function armRefusalText(verdict: Exclude<ArmReplyVerdict, "matched">, projectName?: string): string {
|
|
822
|
+
const reissue = `omp-conductor arm${projectName === undefined ? "" : ` --project ${projectName}`}`;
|
|
823
|
+
switch (verdict) {
|
|
824
|
+
case "expired":
|
|
825
|
+
return (
|
|
826
|
+
`arm: that code belongs to a challenge whose window has closed — NOT armed, no marker written. ` +
|
|
827
|
+
`An expired code is never a proof: send a fresh challenge with \`${reissue}\` and verify that one.`
|
|
828
|
+
);
|
|
829
|
+
case "unknown":
|
|
830
|
+
return (
|
|
831
|
+
`arm: that message carries an arming code, but no live challenge here matches it — NOT armed, ` +
|
|
832
|
+
`no marker written. Only this project's own challenge and the fleet ceremony are ever consulted. ` +
|
|
833
|
+
`Send a challenge with \`${reissue}\` and reply to that one.`
|
|
834
|
+
);
|
|
835
|
+
case "none":
|
|
836
|
+
return (
|
|
837
|
+
`arm: that message contains no arming code at all — NOT armed, no marker written. ` +
|
|
838
|
+
`Pass the operator's reply verbatim, including the FLEET-… code; if none was sent, run \`${reissue}\` first.`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
658
841
|
}
|
|
659
842
|
|
|
660
843
|
export interface HoldResult {
|
|
@@ -3361,11 +3544,11 @@ function makeChallengeCode(): string {
|
|
|
3361
3544
|
}
|
|
3362
3545
|
|
|
3363
3546
|
/**
|
|
3364
|
-
* mm:ss for
|
|
3547
|
+
* mm:ss for the window a challenge code stays good for. Deliberately not
|
|
3365
3548
|
* `formatDownDuration`, which rounds to whole minutes because it reports
|
|
3366
|
-
* hours-to-days outages: rounded to the minute,
|
|
3367
|
-
*
|
|
3368
|
-
*
|
|
3549
|
+
* hours-to-days outages: rounded to the minute, a code with 30 seconds left
|
|
3550
|
+
* would read "5m", which is the exact ambiguity these lines exist to remove
|
|
3551
|
+
* (#861).
|
|
3369
3552
|
*/
|
|
3370
3553
|
function armClock(ms: number): string {
|
|
3371
3554
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
@@ -3374,70 +3557,6 @@ function armClock(ms: number): string {
|
|
|
3374
3557
|
return minutes === 0 ? `${seconds}s` : `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
3375
3558
|
}
|
|
3376
3559
|
|
|
3377
|
-
/** How often the wait says it is still waiting. Six lines across the window. */
|
|
3378
|
-
const ARM_PROGRESS_INTERVAL_MS = 30_000;
|
|
3379
|
-
|
|
3380
|
-
/**
|
|
3381
|
-
* Polls the acknowledgement record for one exact challenge id until the
|
|
3382
|
-
* orchestrator's inbound adapter writes it or the deadline passes (#614).
|
|
3383
|
-
*
|
|
3384
|
-
* The state is a small JSON file re-read every pass, never snapshotted: the
|
|
3385
|
-
* acknowledgement may land at any point in the window, written by the live
|
|
3386
|
-
* orchestrator process. Only a record naming this exact id satisfies the
|
|
3387
|
-
* wait — an acknowledgement cut for a replaced challenge is inert here by
|
|
3388
|
-
* construction, and no transcript anywhere is opened.
|
|
3389
|
-
*
|
|
3390
|
-
* It also *says* it is waiting (#861). Measured 2026-08-21: an arm proof sat
|
|
3391
|
-
* silent for five minutes and was reported as a hung setup — the process was
|
|
3392
|
-
* healthy and the operator had no way to tell. A silent five-minute wait
|
|
3393
|
-
* inside a fence that holds dispatch is indistinguishable from a dead one, so
|
|
3394
|
-
* the elapsed/remaining line lands every {@link ARM_PROGRESS_INTERVAL_MS}
|
|
3395
|
-
* regardless of the (much shorter) poll cadence, and the terminal outcome is
|
|
3396
|
-
* always printed.
|
|
3397
|
-
*/
|
|
3398
|
-
async function waitForArmAcknowledgement(
|
|
3399
|
-
challengeId: string,
|
|
3400
|
-
timeoutMs: number,
|
|
3401
|
-
deps: ArmDeps,
|
|
3402
|
-
): Promise<boolean> {
|
|
3403
|
-
const now = deps.now ?? Date.now;
|
|
3404
|
-
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
3405
|
-
const report = deps.progress;
|
|
3406
|
-
const startedAt = now();
|
|
3407
|
-
const deadline = startedAt + timeoutMs;
|
|
3408
|
-
let nextReportAt = startedAt + ARM_PROGRESS_INTERVAL_MS;
|
|
3409
|
-
for (;;) {
|
|
3410
|
-
if (readArmAcknowledgement(challengeId) !== undefined) {
|
|
3411
|
-
report?.(`arm: reply acknowledged after ${armClock(now() - startedAt)} — the fleet is armed.`);
|
|
3412
|
-
return true;
|
|
3413
|
-
}
|
|
3414
|
-
const at = now();
|
|
3415
|
-
if (at >= deadline) return false;
|
|
3416
|
-
if (report !== undefined && at >= nextReportAt) {
|
|
3417
|
-
report(
|
|
3418
|
-
`arm: still waiting for the reply — ${armClock(at - startedAt)} elapsed, ` +
|
|
3419
|
-
`${armClock(deadline - at)} left. Nothing is stuck: reply in the Telegram chat with the code.`,
|
|
3420
|
-
);
|
|
3421
|
-
// Anchored to the clock, not to this pass, so a slow pass cannot make the
|
|
3422
|
-
// cadence drift into silence.
|
|
3423
|
-
while (nextReportAt <= at) nextReportAt += ARM_PROGRESS_INTERVAL_MS;
|
|
3424
|
-
}
|
|
3425
|
-
await sleep(5_000);
|
|
3426
|
-
}
|
|
3427
|
-
}
|
|
3428
|
-
|
|
3429
|
-
/**
|
|
3430
|
-
* The one armed-marker write both proofs share: same content, same mode, and
|
|
3431
|
-
* the same restamp of the pre-per-project shared marker the heartbeat still
|
|
3432
|
-
* honours — a project that just armed must not leave the bare marker around to
|
|
3433
|
-
* re-arm future fleets through `disarm` (#316).
|
|
3434
|
-
*/
|
|
3435
|
-
function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
3436
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
3437
|
-
writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
|
|
3438
|
-
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
3560
|
/**
|
|
3442
3561
|
* The session surface the claim-only verdict judges: the tick-cwd-derived
|
|
3443
3562
|
* session directory plus, when the live claim's file lives elsewhere in the
|