omp-conductor 0.19.7 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/arm-challenge.ts +204 -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 +253 -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 +113 -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 +412 -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 +736 -0
- package/src/daemon/settle-pass.ts +589 -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 +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- 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 +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- 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 +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +456 -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,
|
|
@@ -283,19 +285,53 @@ export function disarmTicks(projectName?: string): { path: string; wasArmed: boo
|
|
|
283
285
|
return { path, wasArmed };
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
alreadyArmed: boolean;
|
|
289
|
-
owner: string;
|
|
290
|
-
/** The challenge code that proved arming, present only for `challenge` proof. */
|
|
291
|
-
challenge?: string;
|
|
288
|
+
/** One project whose arm marker was written. */
|
|
289
|
+
export interface ArmedProject {
|
|
292
290
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
291
|
+
* The configured project name, absent only for a legacy unstamped
|
|
292
|
+
* single-project fleet.
|
|
295
293
|
*/
|
|
294
|
+
project?: string;
|
|
295
|
+
path: string;
|
|
296
|
+
alreadyArmed: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Markers written without any reply: the `claim-only` policy proof (#613). */
|
|
300
|
+
export interface ArmMarkersWritten {
|
|
301
|
+
outcome: "armed";
|
|
296
302
|
proof: ArmProof;
|
|
303
|
+
owner: string;
|
|
304
|
+
armed: ArmedProject[];
|
|
297
305
|
}
|
|
298
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A challenge filed and sent, with nothing armed yet.
|
|
309
|
+
*
|
|
310
|
+
* Arming used to block here for up to five minutes on an in-session
|
|
311
|
+
* acknowledgement. With the console owning the operator DM, the reply lands in
|
|
312
|
+
* a session that runs no tick extension, so that wait could never be satisfied
|
|
313
|
+
* — it is now two mechanical steps, and this is the first one's receipt.
|
|
314
|
+
*/
|
|
315
|
+
export interface ArmChallengeSent {
|
|
316
|
+
outcome: "challenge-sent";
|
|
317
|
+
/** Always `challenge`: `claim-only` never sends, so it never reaches here. */
|
|
318
|
+
proof: "challenge";
|
|
319
|
+
/** The chat the challenge went to, recorded with the transaction. */
|
|
320
|
+
owner: string;
|
|
321
|
+
/** The pending transaction the reply must prove. */
|
|
322
|
+
challengeId: string;
|
|
323
|
+
/** Unix ms after which the code stops being a proof. */
|
|
324
|
+
expiresAt: number;
|
|
325
|
+
/** mm:ss the code stays good for — the same clock the message quotes. */
|
|
326
|
+
validFor: string;
|
|
327
|
+
/** Exactly what a matching reply will arm, as recorded with the challenge. */
|
|
328
|
+
targets: ArmTarget[];
|
|
329
|
+
/** The verbatim command that completes the ceremony, copy-pasteable as-is. */
|
|
330
|
+
followUp: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export type ArmResult = ArmMarkersWritten | ArmChallengeSent;
|
|
334
|
+
|
|
299
335
|
export interface ArmDeps {
|
|
300
336
|
sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
|
|
301
337
|
/**
|
|
@@ -307,7 +343,7 @@ export interface ArmDeps {
|
|
|
307
343
|
*/
|
|
308
344
|
claimedSessionFile?: () => string | undefined;
|
|
309
345
|
now?: () => number;
|
|
310
|
-
|
|
346
|
+
/** How long a sent code stays a proof. */
|
|
311
347
|
timeoutMs?: number;
|
|
312
348
|
/**
|
|
313
349
|
* Liveness seams for the claim-only verdict (#613), with omp-telegram's own
|
|
@@ -321,11 +357,10 @@ export interface ArmDeps {
|
|
|
321
357
|
lockPidAlive?: (pid: number) => boolean;
|
|
322
358
|
lockFresh?: (mtimeMs: number) => boolean;
|
|
323
359
|
/**
|
|
324
|
-
* Where the
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* (the CLI, the wizard) pass theirs.
|
|
360
|
+
* Where the ceremony's one-line narration goes. Nothing waits any more, so
|
|
361
|
+
* this is no longer a liveness heartbeat (#861) — it is the send receipt and
|
|
362
|
+
* the follow-up instruction, for a surface that prints as it goes. Absent
|
|
363
|
+
* means no reporting.
|
|
329
364
|
*/
|
|
330
365
|
progress?: (line: string) => void;
|
|
331
366
|
}
|
|
@@ -367,30 +402,28 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
367
402
|
// one pane that can ask. The challenge names which one, or the operator is
|
|
368
403
|
// answering a question they cannot attribute.
|
|
369
404
|
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.
|
|
405
|
+
// The handshake state key must be exactly what the reply step recomputes
|
|
406
|
+
// from this same tick config: TickConfig.project, undefined for a legacy
|
|
407
|
+
// unstamped config. `named` may fall back to the CLI argument for the
|
|
408
|
+
// challenge text and config lookups; the state key must not, or the reply
|
|
409
|
+
// would look for the challenge under a key nothing recorded.
|
|
376
410
|
const stateKey = tick.config.project;
|
|
377
411
|
|
|
378
412
|
// The arming proof is a declared per-project policy (#613). A config that
|
|
379
|
-
// cannot name the project fails safe to `challenge` —
|
|
413
|
+
// cannot name the project fails safe to `challenge` — the authenticated
|
|
380
414
|
// round-trip — so a missing or unreadable config never silently weakens the
|
|
381
415
|
// gate.
|
|
382
416
|
let proof: ArmProof = DEFAULT_ARM_PROOF;
|
|
383
417
|
try {
|
|
384
418
|
proof = resolveArmProof(findProject(loadConfig(), named));
|
|
385
419
|
} catch {
|
|
386
|
-
/* no project config — keep
|
|
420
|
+
/* no project config — keep the challenge behaviour */
|
|
387
421
|
}
|
|
388
422
|
|
|
389
423
|
// The orchestrator's live session file per omp-telegram's claim (#600) —
|
|
390
424
|
// input to the claim-only verdict's session-identity checks below. The
|
|
391
|
-
// challenge proof never reads it: its
|
|
392
|
-
//
|
|
393
|
-
// (#614).
|
|
425
|
+
// challenge proof never reads it: its proof is conductor state, so where (or
|
|
426
|
+
// whether) a transcript lives is no longer part of arming (#614).
|
|
394
427
|
const claimed =
|
|
395
428
|
deps.claimedSessionFile !== undefined
|
|
396
429
|
? deps.claimedSessionFile()
|
|
@@ -409,17 +442,12 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
409
442
|
}
|
|
410
443
|
|
|
411
444
|
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
445
|
|
|
418
446
|
if (proof === "claim-only") {
|
|
419
447
|
// The human-intent gate is declared satisfied by policy, so #612's shared
|
|
420
448
|
// verdict is the whole proof: the same state reads and the same liveness
|
|
421
449
|
// rules the doctor's "telegram-plumbing" finding applies, on the route a
|
|
422
|
-
// challenge would have ridden. No Telegram send, no
|
|
450
|
+
// challenge would have ridden. No Telegram send, no reply step, no
|
|
423
451
|
// pending-challenge record. A failed fact refuses arming by name — never
|
|
424
452
|
// a silent pass from file existence, and never a marker.
|
|
425
453
|
const scan = armVerdictScanDirs(tick.cwd, claimed);
|
|
@@ -442,13 +470,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
442
470
|
`NOT armed; no marker was written`,
|
|
443
471
|
);
|
|
444
472
|
}
|
|
473
|
+
// The gate as the heartbeat reads it, so "replaced previous marker" is not
|
|
474
|
+
// a lie about a fleet the shared marker was arming, and so the write knows
|
|
475
|
+
// whether it is superseding that marker.
|
|
476
|
+
const arm = resolveArmState(path, named);
|
|
445
477
|
writeArmedMarker(path, channel.owner, arm);
|
|
446
|
-
return {
|
|
478
|
+
return {
|
|
479
|
+
outcome: "armed",
|
|
480
|
+
proof,
|
|
481
|
+
owner: channel.owner,
|
|
482
|
+
armed: [{ ...(named === undefined ? {} : { project: named }), path, alreadyArmed: arm.armed }],
|
|
483
|
+
};
|
|
447
484
|
}
|
|
448
485
|
|
|
449
486
|
const send = deps.sendChallenge ?? sendTelegramMessage;
|
|
450
487
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
451
488
|
const code = makeChallengeCode();
|
|
489
|
+
const followUp = armReplyCommand(projectName);
|
|
452
490
|
// Self-describing (#991): with two live challenges in one chat the operator
|
|
453
491
|
// was working out which was which from message order, and nothing said how
|
|
454
492
|
// long a code stayed good — so the safe move was to scroll for the newest,
|
|
@@ -457,15 +495,18 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
457
495
|
`Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
|
|
458
496
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
459
497
|
`Valid for ${armClock(timeoutMs)}. ` +
|
|
460
|
-
`Nothing
|
|
498
|
+
`Nothing is dispatched until that reply is verified.`;
|
|
461
499
|
const sentAt = (deps.now ?? Date.now)();
|
|
462
|
-
//
|
|
463
|
-
// challenge
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// a
|
|
468
|
-
const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs
|
|
500
|
+
// Recorded BEFORE the send, so a reply that beats this process's own return
|
|
501
|
+
// still finds an active challenge — and so a send that fails has a
|
|
502
|
+
// transaction to settle rather than a code loose in a chat (#415). The record
|
|
503
|
+
// carries what the reply will arm: the config could change before the
|
|
504
|
+
// operator answers, and arming anything but what the challenge named would
|
|
505
|
+
// arm a fleet nobody was asked about.
|
|
506
|
+
const challengeId = recordArmChallenge(stateKey, code, sentAt, sentAt + timeoutMs, {
|
|
507
|
+
targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
|
|
508
|
+
owner: channel.owner,
|
|
509
|
+
});
|
|
469
510
|
try {
|
|
470
511
|
await send(token, channel.owner, text, sendTopic);
|
|
471
512
|
} catch (err) {
|
|
@@ -476,50 +517,33 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
|
|
|
476
517
|
`arm: outbound sendMessage failed — NOT armed: ${err instanceof Error ? err.message : String(err)}`,
|
|
477
518
|
);
|
|
478
519
|
}
|
|
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
520
|
deps.progress?.(
|
|
484
521
|
`arm: challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} — ` +
|
|
485
|
-
`
|
|
522
|
+
`valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
|
|
486
523
|
);
|
|
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 };
|
|
524
|
+
return {
|
|
525
|
+
outcome: "challenge-sent",
|
|
526
|
+
proof: "challenge",
|
|
527
|
+
owner: channel.owner,
|
|
528
|
+
challengeId,
|
|
529
|
+
expiresAt: sentAt + timeoutMs,
|
|
530
|
+
validFor: armClock(timeoutMs),
|
|
531
|
+
targets: [{ ...(named === undefined ? {} : { project: named }), armedFile: path }],
|
|
532
|
+
followUp,
|
|
533
|
+
};
|
|
515
534
|
}
|
|
516
535
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
536
|
+
/**
|
|
537
|
+
* The second half of the ceremony, verbatim. Printed by every surface that
|
|
538
|
+
* issues a challenge, and quoted in the refusals, because an agent reading a
|
|
539
|
+
* console pane has to be able to paste it without composing anything: the
|
|
540
|
+
* operator's message is the only variable.
|
|
541
|
+
*/
|
|
542
|
+
function armReplyCommand(projectName?: string): string {
|
|
543
|
+
return (
|
|
544
|
+
`omp-conductor arm --reply "<the operator's reply, verbatim>"` +
|
|
545
|
+
`${projectName === undefined ? "" : ` --project ${projectName}`}`
|
|
546
|
+
);
|
|
523
547
|
}
|
|
524
548
|
|
|
525
549
|
/**
|
|
@@ -527,10 +551,9 @@ export interface FleetArmResult {
|
|
|
527
551
|
*
|
|
528
552
|
* Arming was per project: a two-project fleet meant two sequential handshakes
|
|
529
553
|
* 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.
|
|
554
|
+
* between them. This sends **one** challenge whose single matching reply arms
|
|
555
|
+
* every configured project — one fleet-wide pending record, not a loop that
|
|
556
|
+
* sends N challenges and collects N replies.
|
|
534
557
|
*
|
|
535
558
|
* Every project is validated before anything is sent, so a fleet whose second
|
|
536
559
|
* project has no `armedFile` refuses the ceremony instead of arming the first
|
|
@@ -541,11 +564,14 @@ export interface FleetArmResult {
|
|
|
541
564
|
* authenticated round-trip is strictly stronger than the plumbing verdict that
|
|
542
565
|
* policy would have accepted, so satisfying the weaker gate with the stronger
|
|
543
566
|
* proof cannot weaken it.
|
|
567
|
+
*
|
|
568
|
+
* Like {@link armTicks}, this returns as soon as the challenge is filed and
|
|
569
|
+
* sent: `omp-conductor arm --reply` writes the markers.
|
|
544
570
|
*/
|
|
545
571
|
export async function armFleet(
|
|
546
572
|
projectNames: readonly string[],
|
|
547
573
|
deps: ArmDeps = {},
|
|
548
|
-
): Promise<
|
|
574
|
+
): Promise<ArmChallengeSent> {
|
|
549
575
|
if (projectNames.length === 0) throw new Error("arm: no projects are configured");
|
|
550
576
|
|
|
551
577
|
// Resolve and validate every project first. Nothing is sent and no marker is
|
|
@@ -596,8 +622,8 @@ export async function armFleet(
|
|
|
596
622
|
|
|
597
623
|
// The first project that resolves a live topic carries the ceremony, and the
|
|
598
624
|
// message says so: a fleet-wide question still has to land somewhere an
|
|
599
|
-
// operator is reading, and
|
|
600
|
-
//
|
|
625
|
+
// operator is reading, and the reply step reaches the record from any project
|
|
626
|
+
// because it is fleet-wide rather than topic-scoped.
|
|
601
627
|
let sendTopic: number | undefined;
|
|
602
628
|
for (const target of targets) {
|
|
603
629
|
try {
|
|
@@ -612,14 +638,28 @@ export async function armFleet(
|
|
|
612
638
|
const timeoutMs = deps.timeoutMs ?? ARM_CHALLENGE_TIMEOUT_MS;
|
|
613
639
|
const code = makeChallengeCode();
|
|
614
640
|
const names = targets.map((t) => t.project).join(", ");
|
|
641
|
+
// No `--project`: the reply resolves the fleet record, which is the one this
|
|
642
|
+
// ceremony wrote. Naming a project here would be a narrower command than the
|
|
643
|
+
// challenge the operator answered.
|
|
644
|
+
const followUp = armReplyCommand();
|
|
615
645
|
const text =
|
|
616
646
|
`Fleet arming check — ${String(targets.length)} project(s): ${names}. ` +
|
|
617
647
|
`Reply to this chat with exactly:\n${code}\n` +
|
|
618
648
|
`Valid for ${armClock(timeoutMs)}, and one reply arms all of them. ` +
|
|
619
|
-
`Nothing
|
|
649
|
+
`Nothing is dispatched until that reply is verified.`;
|
|
620
650
|
|
|
621
651
|
const sentAt = (deps.now ?? Date.now)();
|
|
622
|
-
|
|
652
|
+
// The record carries every project the message named, so the reply arms
|
|
653
|
+
// exactly the fleet the operator was asked about — not whatever the config
|
|
654
|
+
// says minutes later.
|
|
655
|
+
const armTargets: ArmTarget[] = targets.map((target) => ({
|
|
656
|
+
project: target.project,
|
|
657
|
+
armedFile: target.armedFile,
|
|
658
|
+
}));
|
|
659
|
+
const challengeId = recordArmChallenge(FLEET_ARM_KEY, code, sentAt, sentAt + timeoutMs, {
|
|
660
|
+
targets: armTargets,
|
|
661
|
+
owner: channel.owner,
|
|
662
|
+
});
|
|
623
663
|
try {
|
|
624
664
|
await send(token, channel.owner, text, sendTopic);
|
|
625
665
|
} catch (err) {
|
|
@@ -630,31 +670,169 @@ export async function armFleet(
|
|
|
630
670
|
}
|
|
631
671
|
deps.progress?.(
|
|
632
672
|
`arm: one challenge sent to ${channel.owner}${sendTopic === undefined ? "" : ` (topic ${sendTopic})`} for ${names} — ` +
|
|
633
|
-
`
|
|
673
|
+
`valid for ${armClock(timeoutMs)}. Nothing is armed yet: run ${followUp}`,
|
|
634
674
|
);
|
|
675
|
+
return {
|
|
676
|
+
outcome: "challenge-sent",
|
|
677
|
+
proof: "challenge",
|
|
678
|
+
owner: channel.owner,
|
|
679
|
+
challengeId,
|
|
680
|
+
expiresAt: sentAt + timeoutMs,
|
|
681
|
+
validFor: armClock(timeoutMs),
|
|
682
|
+
targets: armTargets,
|
|
683
|
+
followUp,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
635
686
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
` getUpdates consumer and rejects the second with HTTP 409.\n` +
|
|
645
|
-
` * did you reply in the chat the challenge names, not another one?\n`,
|
|
646
|
-
);
|
|
647
|
-
}
|
|
687
|
+
/** What a reply that armed nothing was, and what to do about it. */
|
|
688
|
+
export interface ArmReplyRefused {
|
|
689
|
+
outcome: "refused";
|
|
690
|
+
/** Never `matched`: a match is the armed outcome. */
|
|
691
|
+
verdict: Exclude<ArmReplyVerdict, "matched">;
|
|
692
|
+
/** One operator-facing sentence naming the verdict and the next move. */
|
|
693
|
+
message: string;
|
|
694
|
+
}
|
|
648
695
|
|
|
649
|
-
|
|
650
|
-
|
|
696
|
+
export interface ArmReplyAccepted {
|
|
697
|
+
outcome: "armed";
|
|
698
|
+
/** The chat the proved challenge was sent to — the marker's `owner=`. */
|
|
699
|
+
owner: string;
|
|
700
|
+
/** The transaction the reply proved and this call settled. */
|
|
701
|
+
challengeId: string;
|
|
702
|
+
/** Every project the challenge recorded, now armed. */
|
|
703
|
+
armed: ArmedProject[];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export type ArmReplyResult = ArmReplyAccepted | ArmReplyRefused;
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The verification half of the ceremony: classify the operator's verbatim
|
|
710
|
+
* message and, on a match, arm exactly the projects the challenge recorded.
|
|
711
|
+
*
|
|
712
|
+
* This runs in the console session, which is where the operator's reply lands
|
|
713
|
+
* now that it owns the Telegram DM. No session waits for anything: the
|
|
714
|
+
* challenge is durable state, so the two halves are ordinary commands that can
|
|
715
|
+
* run minutes apart in different processes.
|
|
716
|
+
*
|
|
717
|
+
* The security properties are the send half's, unchanged. Only the project's
|
|
718
|
+
* own record and the fleet record are read (no other project's ceremony can be
|
|
719
|
+
* revealed or consumed), an expired record is never a proof, and a marker is
|
|
720
|
+
* written only for a `matched` verdict — every other verdict returns a refusal
|
|
721
|
+
* having written nothing.
|
|
722
|
+
*/
|
|
723
|
+
export function armReply(
|
|
724
|
+
replyText: string,
|
|
725
|
+
projectName?: string,
|
|
726
|
+
deps: Pick<ArmDeps, "now"> = {},
|
|
727
|
+
): ArmReplyResult {
|
|
728
|
+
const tick = resolveTickConfig(projectName);
|
|
729
|
+
if (tick.kind === "invalid") {
|
|
730
|
+
// Every derivation below would come from this file. A config that does not
|
|
731
|
+
// parse cannot be trusted to name a state key or a marker.
|
|
732
|
+
throw new Error(`tick config invalid at ${tick.path}: ${tick.problem}`);
|
|
733
|
+
}
|
|
734
|
+
// An absent config is not fatal here, which is the difference between the two
|
|
735
|
+
// halves: a fleet ceremony's record carries its own targets, and on a
|
|
736
|
+
// multi-project host the search roots for an unnamed project resolve no
|
|
737
|
+
// config at all (the fleet cwds each carry their own). Refusing here would
|
|
738
|
+
// make `arm --reply` unusable for exactly the ceremony that needs it most.
|
|
739
|
+
const config = tick.kind === "ok" ? tick.config : undefined;
|
|
740
|
+
// The same derivation the send half used, from the same file: the state key
|
|
741
|
+
// is TickConfig.project (undefined for a legacy unstamped config), never the
|
|
742
|
+
// CLI argument, or the reply would look under a key nothing recorded.
|
|
743
|
+
const stateKey = config?.project;
|
|
744
|
+
const now = (deps.now ?? Date.now)();
|
|
745
|
+
const resolved = resolveArmReply(stateKey, replyText, now);
|
|
746
|
+
if (resolved.verdict !== "matched") {
|
|
747
|
+
return {
|
|
748
|
+
outcome: "refused",
|
|
749
|
+
verdict: resolved.verdict,
|
|
750
|
+
message: armRefusalText(resolved.verdict, projectName),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const match = resolved.match;
|
|
754
|
+
// Recorded targets are the contract. A record written before targets existed
|
|
755
|
+
// (one release of overlap) can only be read as naming the project it is keyed
|
|
756
|
+
// under — or, for the fleet record, the project this command was given, since
|
|
757
|
+
// re-deriving the fleet from today's config could arm a project the operator
|
|
758
|
+
// was never asked about.
|
|
759
|
+
const targets: ArmTarget[] = match.targets ?? [legacyArmTarget(config, projectName)];
|
|
760
|
+
// The owner the challenge was actually sent to. Only a pre-targets record
|
|
761
|
+
// lacks it, and then the paired channel is the only other honest source.
|
|
762
|
+
const owner = match.owner ?? pairedChannelOwner(config?.accessFile);
|
|
651
763
|
const armed = targets.map((target) => {
|
|
652
764
|
const state = resolveArmState(target.armedFile, target.project);
|
|
653
|
-
writeArmedMarker(target.armedFile,
|
|
654
|
-
return {
|
|
765
|
+
writeArmedMarker(target.armedFile, owner, state);
|
|
766
|
+
return {
|
|
767
|
+
...(target.project === undefined ? {} : { project: target.project }),
|
|
768
|
+
path: target.armedFile,
|
|
769
|
+
alreadyArmed: state.armed,
|
|
770
|
+
};
|
|
655
771
|
});
|
|
656
|
-
|
|
657
|
-
|
|
772
|
+
// Settled by id against the key that matched, so a replayed reply cannot arm
|
|
773
|
+
// a second time and a newer replacement's record is never removed.
|
|
774
|
+
clearArmTransaction(match.key, match.id);
|
|
775
|
+
return { outcome: "armed", owner, challengeId: match.id, armed };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* What a pre-targets pending record arms. The tick config that resolved the
|
|
780
|
+
* state key also names this project's marker, which is exactly what the send
|
|
781
|
+
* half would have recorded. Without such a config there is nothing to fall back
|
|
782
|
+
* to, and guessing a marker path is not an option — a challenge from before the
|
|
783
|
+
* upgrade is simply re-run.
|
|
784
|
+
*/
|
|
785
|
+
function legacyArmTarget(config: TickConfig | undefined, projectName?: string): ArmTarget {
|
|
786
|
+
const named = config?.project ?? projectName;
|
|
787
|
+
if (config?.armedFile === undefined) {
|
|
788
|
+
throw new Error(
|
|
789
|
+
`arm: the pending challenge names no targets and no readable ${TICK_CONFIG_FILE} names an armedFile — ` +
|
|
790
|
+
`nothing to arm; re-run \`omp-conductor arm\` for a challenge that records its own targets`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return { ...(named === undefined ? {} : { project: named }), armedFile: config.armedFile };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** The paired owner, for the one record shape that does not carry its own. */
|
|
797
|
+
function pairedChannelOwner(accessFile: string | undefined): string {
|
|
798
|
+
if (accessFile === undefined) {
|
|
799
|
+
throw new Error(`${TICK_CONFIG_FILE} has no accessFile — the challenge's owner cannot be established`);
|
|
800
|
+
}
|
|
801
|
+
const channel = readPairedChannel(accessFile);
|
|
802
|
+
if (channel.kind === "down") {
|
|
803
|
+
throw new Error(
|
|
804
|
+
`escalation channel is not up (${accessFile}): ${channel.reason} — ` +
|
|
805
|
+
`this challenge predates owner recording, so the marker cannot name whom it armed`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
return channel.owner;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Each non-matching verdict in the operator's words, with the next move. A
|
|
813
|
+
* silent no-op was the old failure mode: the operator could not tell a wrong
|
|
814
|
+
* code from an expired one from a command that never looked.
|
|
815
|
+
*/
|
|
816
|
+
function armRefusalText(verdict: Exclude<ArmReplyVerdict, "matched">, projectName?: string): string {
|
|
817
|
+
const reissue = `omp-conductor arm${projectName === undefined ? "" : ` --project ${projectName}`}`;
|
|
818
|
+
switch (verdict) {
|
|
819
|
+
case "expired":
|
|
820
|
+
return (
|
|
821
|
+
`arm: that code belongs to a challenge whose window has closed — NOT armed, no marker written. ` +
|
|
822
|
+
`An expired code is never a proof: send a fresh challenge with \`${reissue}\` and verify that one.`
|
|
823
|
+
);
|
|
824
|
+
case "unknown":
|
|
825
|
+
return (
|
|
826
|
+
`arm: that message carries an arming code, but no live challenge here matches it — NOT armed, ` +
|
|
827
|
+
`no marker written. Only this project's own challenge and the fleet ceremony are ever consulted. ` +
|
|
828
|
+
`Send a challenge with \`${reissue}\` and reply to that one.`
|
|
829
|
+
);
|
|
830
|
+
case "none":
|
|
831
|
+
return (
|
|
832
|
+
`arm: that message contains no arming code at all — NOT armed, no marker written. ` +
|
|
833
|
+
`Pass the operator's reply verbatim, including the FLEET-… code; if none was sent, run \`${reissue}\` first.`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
658
836
|
}
|
|
659
837
|
|
|
660
838
|
export interface HoldResult {
|
|
@@ -3361,11 +3539,11 @@ function makeChallengeCode(): string {
|
|
|
3361
3539
|
}
|
|
3362
3540
|
|
|
3363
3541
|
/**
|
|
3364
|
-
* mm:ss for
|
|
3542
|
+
* mm:ss for the window a challenge code stays good for. Deliberately not
|
|
3365
3543
|
* `formatDownDuration`, which rounds to whole minutes because it reports
|
|
3366
|
-
* hours-to-days outages: rounded to the minute,
|
|
3367
|
-
*
|
|
3368
|
-
*
|
|
3544
|
+
* hours-to-days outages: rounded to the minute, a code with 30 seconds left
|
|
3545
|
+
* would read "5m", which is the exact ambiguity these lines exist to remove
|
|
3546
|
+
* (#861).
|
|
3369
3547
|
*/
|
|
3370
3548
|
function armClock(ms: number): string {
|
|
3371
3549
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
@@ -3374,58 +3552,6 @@ function armClock(ms: number): string {
|
|
|
3374
3552
|
return minutes === 0 ? `${seconds}s` : `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
3375
3553
|
}
|
|
3376
3554
|
|
|
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
3555
|
/**
|
|
3430
3556
|
* The one armed-marker write both proofs share: same content, same mode, and
|
|
3431
3557
|
* the same restamp of the pre-per-project shared marker the heartbeat still
|