omp-conductor 0.16.2 → 0.17.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/README.md +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/escalate.ts
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* those strings end up in daemon logs and, on the fallback path, in a public
|
|
21
21
|
* issue comment.
|
|
22
22
|
*/
|
|
23
|
-
import { readFileSync } from "node:fs";
|
|
23
|
+
import { readFileSync, statSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
|
-
import { join } from "node:path";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
26
|
|
|
27
27
|
import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
|
|
28
28
|
import { heldNoticeId } from "./notices.ts";
|
|
@@ -434,6 +434,12 @@ export interface ClaimedTopic {
|
|
|
434
434
|
threadId: number;
|
|
435
435
|
/** The bridge's own title for the claim. */
|
|
436
436
|
name: string;
|
|
437
|
+
/**
|
|
438
|
+
* The claiming pane's process id, when the bridge captured one. omp-telegram
|
|
439
|
+
* treats a claim as live only while this pid is alive; a claim that captured
|
|
440
|
+
* no pid is indistinguishable from a dead one for liveness purposes.
|
|
441
|
+
*/
|
|
442
|
+
pid?: number;
|
|
437
443
|
/** The herdr space the claiming pane sits in, when the bridge captured one. */
|
|
438
444
|
workspaceLabel?: string;
|
|
439
445
|
/**
|
|
@@ -458,7 +464,7 @@ export interface ClaimedTopic {
|
|
|
458
464
|
* The operator's pin still wins whenever it is live. Only a pin that is
|
|
459
465
|
* *provably* absent from the current claims is replaced, and only by the claim
|
|
460
466
|
* this project can be identified with — so a deliberately separate alerts topic
|
|
461
|
-
* is never hijacked by the pane's own thread.
|
|
467
|
+
* is never hijacked by the pane's own thread. Unavailable bridge state changes
|
|
462
468
|
* nothing, and #318's stale-topic retry remains the last line of defence.
|
|
463
469
|
*
|
|
464
470
|
* Identity is read from the herdr space first, and only then from the claim's
|
|
@@ -474,8 +480,9 @@ export interface ClaimedTopic {
|
|
|
474
480
|
export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
|
|
475
481
|
const pinned = project.escalation.telegramTopicId;
|
|
476
482
|
if (pinned === undefined) return undefined;
|
|
477
|
-
const
|
|
478
|
-
if (claims.length === 0) return pinned;
|
|
483
|
+
const result = claimedTelegramTopics();
|
|
484
|
+
if (result.kind !== "ok" || result.claims.length === 0) return pinned;
|
|
485
|
+
const claims = result.claims;
|
|
479
486
|
if (claims.some((claim) => claim.threadId === pinned)) return pinned;
|
|
480
487
|
const match = claimForProject(claims, project.name);
|
|
481
488
|
if (match === undefined) return pinned;
|
|
@@ -488,15 +495,64 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
|
|
|
488
495
|
}
|
|
489
496
|
|
|
490
497
|
/**
|
|
491
|
-
* The one claim answering to
|
|
492
|
-
*
|
|
498
|
+
* The one claim answering to this project — or the reason there is none.
|
|
499
|
+
*
|
|
500
|
+
* `kind: "match"` is the unique claim a substitution can follow. `ambiguous`
|
|
501
|
+
* is the multi-answer case: several claims wearing the project's space (or,
|
|
502
|
+
* when no claim wears a space at all, several wearing its title) means the
|
|
503
|
+
* bridge cannot say which pane belongs to this project. A caller that must
|
|
504
|
+
* not certify a guess — doctor's arm probe, which would have to pick a
|
|
505
|
+
* session file — needs this spelled out, not collapsed into "none".
|
|
506
|
+
*/
|
|
507
|
+
export type ProjectClaim =
|
|
508
|
+
| { readonly kind: "match"; readonly claim: ClaimedTopic }
|
|
509
|
+
| { readonly kind: "none" }
|
|
510
|
+
| { readonly kind: "ambiguous"; readonly claimants: readonly ClaimedTopic[] };
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* How the live claims answer to a project, identity spellings in order:
|
|
514
|
+
* unique herdr space, then unique title.
|
|
515
|
+
*
|
|
516
|
+
* Ambiguity is not a coin toss: paging the wrong project's topic is worse
|
|
517
|
+
* than the flat-chat degrade #318 already handles, and scanning the wrong
|
|
518
|
+
* pane's session misses the reply entirely. The title is a fallback for
|
|
519
|
+
* bridges that never captured a space, so it runs only when *no* claim
|
|
520
|
+
* carries the project's space — several claims wearing the space is an
|
|
521
|
+
* ambiguity the title cannot resolve, because the titled claim may be a
|
|
522
|
+
* sibling pane, not this project (#626).
|
|
523
|
+
*/
|
|
524
|
+
export function resolveProjectClaim(
|
|
525
|
+
claims: readonly ClaimedTopic[],
|
|
526
|
+
projectName: string,
|
|
527
|
+
): ProjectClaim {
|
|
528
|
+
const bySpace = claims.filter((claim) => claim.workspaceLabel === projectName);
|
|
529
|
+
if (bySpace.length > 0) {
|
|
530
|
+
return bySpace.length === 1
|
|
531
|
+
? { kind: "match", claim: bySpace[0]! }
|
|
532
|
+
: { kind: "ambiguous", claimants: bySpace };
|
|
533
|
+
}
|
|
534
|
+
const byTitle = claims.filter((claim) => claim.name === projectName);
|
|
535
|
+
if (byTitle.length === 0) return { kind: "none" };
|
|
536
|
+
return byTitle.length === 1
|
|
537
|
+
? { kind: "match", claim: byTitle[0]! }
|
|
538
|
+
: { kind: "ambiguous", claimants: byTitle };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The unique live claim answering to a project, if exactly one does.
|
|
543
|
+
*
|
|
544
|
+
* `undefined` covers both "no claim answers" and "several answer" — {@link
|
|
545
|
+
* resolveProjectClaim} is the one that tells them apart. Callers that may
|
|
546
|
+
* only fall back (a substitution keeps the pin, a session dir falls back to
|
|
547
|
+
* the tick cwd) read that as "nothing to follow"; callers that must not
|
|
548
|
+
* certify a guess need the richer outcome.
|
|
493
549
|
*/
|
|
494
550
|
export function claimForProject(
|
|
495
551
|
claims: readonly ClaimedTopic[],
|
|
496
552
|
projectName: string,
|
|
497
553
|
): ClaimedTopic | undefined {
|
|
498
|
-
const
|
|
499
|
-
return
|
|
554
|
+
const match = resolveProjectClaim(claims, projectName);
|
|
555
|
+
return match.kind === "match" ? match.claim : undefined;
|
|
500
556
|
}
|
|
501
557
|
|
|
502
558
|
/**
|
|
@@ -513,79 +569,130 @@ export function claimForProject(
|
|
|
513
569
|
* directory, which is the honest answer for a host that predates claims.
|
|
514
570
|
*/
|
|
515
571
|
export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
|
|
516
|
-
const
|
|
517
|
-
if (claims.length === 0) return undefined;
|
|
518
|
-
return claimForProject(claims, project.name)?.sessionFile;
|
|
572
|
+
const result = claimedTelegramTopics();
|
|
573
|
+
if (result.kind !== "ok" || result.claims.length === 0) return undefined;
|
|
574
|
+
return claimForProject(result.claims, project.name)?.sessionFile;
|
|
519
575
|
}
|
|
520
576
|
|
|
521
577
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
578
|
+
* omp-telegram's canonical thread-id key spelling: a plain positive decimal
|
|
579
|
+
* integer ("4001"). `Number(id)` alone would coerce `""`, whitespace, `"0"`,
|
|
580
|
+
* `"01"`, `"1e2"` or `"0x10"` into integers the bridge never wrote — and a
|
|
581
|
+
* thread id the bridge never wrote is a malformed registry, not a readable
|
|
582
|
+
* topic (#626).
|
|
527
583
|
*/
|
|
528
|
-
function
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
)
|
|
532
|
-
const hits = claims.filter(predicate);
|
|
533
|
-
return hits.length === 1 ? hits[0] : undefined;
|
|
584
|
+
function parseClaimedTopicId(id: string): number | undefined {
|
|
585
|
+
if (!/^[1-9][0-9]*$/.test(id)) return undefined;
|
|
586
|
+
const parsed = Number(id);
|
|
587
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
534
588
|
}
|
|
535
589
|
|
|
590
|
+
/**
|
|
591
|
+
* What omp-telegram's live claim registry says, or why it could not be read.
|
|
592
|
+
*
|
|
593
|
+
* `kind: "unavailable"` carries the failure instead of collapsing it to an
|
|
594
|
+
* empty list, so a caller that must tell "no claims" from "cannot tell" — the
|
|
595
|
+
* #626 doctor check — can stay fail-closed. `kind: "missing"` is the one
|
|
596
|
+
* absence that is not an operator error: a bridge that has never run a forum
|
|
597
|
+
* pane has no file at all, exactly as a missing token is not one. Callers that
|
|
598
|
+
* must not certify a route keep both non-`ok` kinds diagnostic; the setup
|
|
599
|
+
* wizard is allowed to keep the missing-file silence (#318) while surfacing a
|
|
600
|
+
* present-but-broken registry.
|
|
601
|
+
*/
|
|
602
|
+
export type ClaimedTopicsResult =
|
|
603
|
+
| { kind: "ok"; claims: readonly ClaimedTopic[] }
|
|
604
|
+
| { kind: "missing" }
|
|
605
|
+
| { kind: "unavailable"; problem: string };
|
|
606
|
+
|
|
536
607
|
/**
|
|
537
608
|
* omp-telegram's live topic claims, from
|
|
538
609
|
* `{ threads: { "<threadId>": { name, workspaceLabel } } }`. Borrowed exactly as
|
|
539
|
-
* the token is
|
|
540
|
-
*
|
|
610
|
+
* the token is. A registry that cannot be read or does not parse as the bridge
|
|
611
|
+
* shape is `unavailable` with a human reason, never a silent empty list — the
|
|
612
|
+
* empty list is reserved for a registry that really carries no claims. A single
|
|
613
|
+
* malformed claim member makes the whole registry `unavailable`: a partial list
|
|
614
|
+
* would let doctor certify a route it never verified (#626). A registry that
|
|
615
|
+
* does not exist is `missing`, not an empty list: the bridge may simply never
|
|
616
|
+
* have claimed, and the caller decides whether that is silence or signal.
|
|
541
617
|
*
|
|
542
618
|
* `stateDir` is passed by the setup wizard, which has already probed for the
|
|
543
619
|
* bridge; send-time callers let it resolve the same way the token does.
|
|
544
620
|
*/
|
|
545
|
-
export function claimedTelegramTopics(stateDir?: string):
|
|
621
|
+
export function claimedTelegramTopics(stateDir?: string): ClaimedTopicsResult {
|
|
546
622
|
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
547
623
|
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
624
|
+
const registry = join(dir, "threads.json");
|
|
548
625
|
let raw: unknown;
|
|
549
626
|
try {
|
|
550
|
-
raw = JSON.parse(readFileSync(
|
|
551
|
-
} catch {
|
|
552
|
-
return
|
|
627
|
+
raw = JSON.parse(readFileSync(registry, "utf8"));
|
|
628
|
+
} catch (err) {
|
|
629
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
630
|
+
return {
|
|
631
|
+
kind: "unavailable",
|
|
632
|
+
problem: `cannot read ${registry}: ${err instanceof Error ? err.message : String(err)}`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) {
|
|
636
|
+
return {
|
|
637
|
+
kind: "unavailable",
|
|
638
|
+
problem: `${registry} is not a { threads: { "<threadId>": { name, workspaceLabel } } } registry`,
|
|
639
|
+
};
|
|
553
640
|
}
|
|
554
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
|
|
555
641
|
const threads = raw.threads;
|
|
556
|
-
if (typeof threads !== "object" || threads === null || Array.isArray(threads))
|
|
642
|
+
if (typeof threads !== "object" || threads === null || Array.isArray(threads)) {
|
|
643
|
+
return {
|
|
644
|
+
kind: "unavailable",
|
|
645
|
+
problem: `${registry}: "threads" is not a map of claims`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
557
648
|
const out: ClaimedTopic[] = [];
|
|
558
649
|
for (const [id, entry] of Object.entries(threads)) {
|
|
559
|
-
const threadId =
|
|
560
|
-
if (
|
|
650
|
+
const threadId = parseClaimedTopicId(id);
|
|
651
|
+
if (threadId === undefined) {
|
|
652
|
+
return {
|
|
653
|
+
kind: "unavailable",
|
|
654
|
+
problem: `${registry}: claim "${id}" is not a thread id`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
658
|
+
return {
|
|
659
|
+
kind: "unavailable",
|
|
660
|
+
problem: `${registry}: claim "${id}" is not a { name, workspaceLabel } claim`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
// A claim member is only as good as its strings: a non-string field means
|
|
664
|
+
// the registry is not the shape the bridge writes, and coercing it would
|
|
665
|
+
// let doctor certify a route it never verified (#626). Same for the pid —
|
|
666
|
+
// the bridge records a number or nothing, so any other spelling fails the
|
|
667
|
+
// registry instead of being dressed up as a live owner. Empty strings are
|
|
668
|
+
// the bridge's own spelling for an absent field, so they stay readable.
|
|
669
|
+
if ("name" in entry && typeof entry.name !== "string") {
|
|
670
|
+
return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string name` };
|
|
671
|
+
}
|
|
672
|
+
if ("workspaceLabel" in entry && typeof entry.workspaceLabel !== "string") {
|
|
673
|
+
return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string workspaceLabel` };
|
|
674
|
+
}
|
|
675
|
+
if ("sessionFile" in entry && typeof entry.sessionFile !== "string") {
|
|
676
|
+
return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string sessionFile` };
|
|
677
|
+
}
|
|
678
|
+
if ("pid" in entry && (typeof entry.pid !== "number" || !Number.isFinite(entry.pid))) {
|
|
679
|
+
return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-numeric pid` };
|
|
680
|
+
}
|
|
561
681
|
let name = id;
|
|
562
682
|
let workspaceLabel: string | undefined;
|
|
563
683
|
let sessionFile: string | undefined;
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const candidate = entry.sessionFile;
|
|
575
|
-
if (typeof candidate === "string" && candidate.trim() !== "") sessionFile = candidate.trim();
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
out.push(
|
|
579
|
-
workspaceLabel === undefined && sessionFile === undefined
|
|
580
|
-
? { threadId, name }
|
|
581
|
-
: workspaceLabel === undefined
|
|
582
|
-
? { threadId, name, sessionFile }
|
|
583
|
-
: sessionFile === undefined
|
|
584
|
-
? { threadId, name, workspaceLabel }
|
|
585
|
-
: { threadId, name, workspaceLabel, sessionFile },
|
|
586
|
-
);
|
|
684
|
+
let pid: number | undefined;
|
|
685
|
+
if ("name" in entry && entry.name.trim() !== "") name = entry.name.trim();
|
|
686
|
+
if ("workspaceLabel" in entry && entry.workspaceLabel.trim() !== "") workspaceLabel = entry.workspaceLabel.trim();
|
|
687
|
+
if ("sessionFile" in entry && entry.sessionFile.trim() !== "") sessionFile = entry.sessionFile.trim();
|
|
688
|
+
if ("pid" in entry) pid = entry.pid;
|
|
689
|
+
const claim: ClaimedTopic = { threadId, name };
|
|
690
|
+
if (workspaceLabel !== undefined) claim.workspaceLabel = workspaceLabel;
|
|
691
|
+
if (sessionFile !== undefined) claim.sessionFile = sessionFile;
|
|
692
|
+
if (pid !== undefined) claim.pid = pid;
|
|
693
|
+
out.push(claim);
|
|
587
694
|
}
|
|
588
|
-
return out;
|
|
695
|
+
return { kind: "ok", claims: out };
|
|
589
696
|
}
|
|
590
697
|
|
|
591
698
|
/**
|
|
@@ -611,6 +718,402 @@ export function telegramTopicsTidy(stateDirPath?: string): boolean {
|
|
|
611
718
|
}
|
|
612
719
|
}
|
|
613
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Whether a pid is a live process as `topics.ts` judges a claim
|
|
723
|
+
* (`src/topics.ts:isAlive`): `process.kill(pid, 0)` throws `ESRCH` for a gone
|
|
724
|
+
* process, and **any** other error — including `EPERM`, which means the
|
|
725
|
+
* process exists but is owned by another uid — is dead here. The bridge
|
|
726
|
+
* declines a claim it cannot prove is alive, so conductor must too; a claim
|
|
727
|
+
* the bridge would treat as dead is never dressed up as a live recipient.
|
|
728
|
+
*
|
|
729
|
+
* `kill` is injectable so a test can deterministically throw `EPERM` without
|
|
730
|
+
* owning another uid's process. Distinct from {@link lockPidAlive}, which
|
|
731
|
+
* applies `api.ts`'s opposite rule to the poll lock.
|
|
732
|
+
*/
|
|
733
|
+
export function pidAlive(
|
|
734
|
+
pid: number,
|
|
735
|
+
kill: (target: number, signal: number) => void = (target, signal) => {
|
|
736
|
+
process.kill(target, signal);
|
|
737
|
+
},
|
|
738
|
+
): boolean {
|
|
739
|
+
try {
|
|
740
|
+
kill(pid, 0);
|
|
741
|
+
return true;
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Whether a bot.lock owner pid is a live process as the bridge's `api.ts`
|
|
749
|
+
* judges it (`src/api.ts:pidAlive`): `EPERM` means the process exists but is
|
|
750
|
+
* owned by another uid, which is *live* — the bridge reads its own lock with
|
|
751
|
+
* this rule (`acquireLock`), so a lock owner is judged differently from a
|
|
752
|
+
* topic claim or the DM owner (see {@link pidAlive}).
|
|
753
|
+
*/
|
|
754
|
+
export function lockPidAlive(
|
|
755
|
+
pid: number,
|
|
756
|
+
kill: (target: number, signal: number) => void = (target, signal) => {
|
|
757
|
+
process.kill(target, signal);
|
|
758
|
+
},
|
|
759
|
+
): boolean {
|
|
760
|
+
try {
|
|
761
|
+
kill(pid, 0);
|
|
762
|
+
return true;
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The omp-telegram bridge's single-poller lock cadence, ported from
|
|
770
|
+
* `src/api.ts` (`LOCK_HEARTBEAT_MS`): the elected poller refreshes `bot.lock`'s
|
|
771
|
+
* mtime on this interval while it polls.
|
|
772
|
+
*/
|
|
773
|
+
export const TELEGRAM_LOCK_HEARTBEAT_MS = 15_000;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* A `bot.lock` whose mtime is younger than this is live regardless of its
|
|
777
|
+
* recorded pid, ported from `src/api.ts` (`LOCK_FRESH_MS`). This is the
|
|
778
|
+
* contract that makes daemon-pid liveness insufficient: on a `getMe` or poller
|
|
779
|
+
* failure the daemon releases `bot.lock`, stops its heartbeat and sleeps while
|
|
780
|
+
* its own pid and `daemon.json` remain live — so only the lock (or its absence)
|
|
781
|
+
* says whether anything is actually polling.
|
|
782
|
+
*/
|
|
783
|
+
export const TELEGRAM_LOCK_FRESH_MS = 45_000;
|
|
784
|
+
|
|
785
|
+
/** The owner record omp-telegram writes into `bot.lock`. */
|
|
786
|
+
export interface TelegramLockOwner {
|
|
787
|
+
pid: number;
|
|
788
|
+
startedAt: number;
|
|
789
|
+
nonce?: string;
|
|
790
|
+
name?: string;
|
|
791
|
+
sessionId?: string;
|
|
792
|
+
sessionFile?: string;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Outcome of reading the bridge's `bot.lock`, told apart so absent or
|
|
797
|
+
* malformed state is a named failure, never a silent "no poller" (#612).
|
|
798
|
+
*/
|
|
799
|
+
export type TelegramPollState =
|
|
800
|
+
| { kind: "ok"; owner: TelegramLockOwner; mtimeMs: number }
|
|
801
|
+
| { kind: "unreadable" };
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Parse a `bot.lock` owner record, mirroring omp-telegram's own
|
|
805
|
+
* `parseLockOwner` (`src/api.ts`) exactly: the first line is the owning pid,
|
|
806
|
+
* read with `Number.parseInt(firstLine, 10)` — a positive decimal only, so
|
|
807
|
+
* `0x10` parses to 0 and is no owner rather than becoming 16, and PID 1 is a
|
|
808
|
+
* valid owner (the bridge excludes only `pid <= 0`). A legacy bare-pid lock
|
|
809
|
+
* is still an owner (`{ pid, startedAt: 0 }`); a v2 record whose JSON object
|
|
810
|
+
* repeats the first-line pid is returned whole — carrying the poller's
|
|
811
|
+
* session identity — and a malformed v2 record falls back to the first-line
|
|
812
|
+
* pid exactly as the bridge does.
|
|
813
|
+
*/
|
|
814
|
+
function parseTelegramLockOwner(content: string): TelegramLockOwner | undefined {
|
|
815
|
+
const trimmed = content.trim();
|
|
816
|
+
if (trimmed === "") return undefined;
|
|
817
|
+
const newline = trimmed.indexOf("\n");
|
|
818
|
+
const firstLine = newline === -1 ? trimmed : trimmed.slice(0, newline);
|
|
819
|
+
const pid = Number.parseInt(firstLine, 10) || 0;
|
|
820
|
+
if (pid <= 0) return undefined;
|
|
821
|
+
if (newline !== -1) {
|
|
822
|
+
try {
|
|
823
|
+
const owner: unknown = JSON.parse(trimmed.slice(newline + 1));
|
|
824
|
+
if (typeof owner === "object" && owner !== null && !Array.isArray(owner)) {
|
|
825
|
+
const record = owner as { readonly pid?: unknown };
|
|
826
|
+
if (record.pid === pid) return owner as TelegramLockOwner;
|
|
827
|
+
}
|
|
828
|
+
} catch {
|
|
829
|
+
// Malformed v2 record — fall back to the first-line pid, as the bridge does.
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return { pid, startedAt: 0 };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* The omp-telegram bridge's poll ownership, from the same state dir as
|
|
837
|
+
* `threads.json` (`bot.lock`). `unreadable` when the file is absent,
|
|
838
|
+
* unreadable or not a valid owner record — a bridge that is not polling holds
|
|
839
|
+
* no lock, and a malformed record is a named failure, never a pass. The
|
|
840
|
+
* `mtimeMs` is the file's own mtime, so the caller can apply the bridge's
|
|
841
|
+
* freshness rule (a heartbeat younger than {@link TELEGRAM_LOCK_FRESH_MS} is
|
|
842
|
+
* live even when the recorded pid is gone).
|
|
843
|
+
*/
|
|
844
|
+
export function readTelegramPollState(stateDir?: string): TelegramPollState {
|
|
845
|
+
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
846
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
847
|
+
const lockPath = join(dir, "bot.lock");
|
|
848
|
+
let mtimeMs: number;
|
|
849
|
+
let raw: string;
|
|
850
|
+
try {
|
|
851
|
+
mtimeMs = statSync(lockPath).mtimeMs;
|
|
852
|
+
raw = readFileSync(lockPath, "utf8");
|
|
853
|
+
} catch {
|
|
854
|
+
return { kind: "unreadable" };
|
|
855
|
+
}
|
|
856
|
+
const owner = parseTelegramLockOwner(raw);
|
|
857
|
+
if (owner === undefined) return { kind: "unreadable" };
|
|
858
|
+
return { kind: "ok", owner, mtimeMs };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* The live untopiced-DM recipient omp-telegram pins (`dm-owner.json`), as much
|
|
863
|
+
* as conductor reads. The bridge routes an *unthreaded* private reply to
|
|
864
|
+
* whatever session owns this file — never to a project-matched
|
|
865
|
+
* `threads.json` claim, which is the forum route's owner (`src/bridge.ts`).
|
|
866
|
+
*/
|
|
867
|
+
export interface TelegramDmOwner {
|
|
868
|
+
pid: number;
|
|
869
|
+
name?: string;
|
|
870
|
+
sessionId?: string;
|
|
871
|
+
sessionFile?: string;
|
|
872
|
+
workspaceLabel?: string;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Outcome of reading the bridge's `dm-owner.json`. Mirrors omp-telegram's own
|
|
877
|
+
* `loadDmOwner` (`src/topics.ts`): the record is a live `ThreadEntry`, so a
|
|
878
|
+
* `pid` that is not a finite number — an absent file, or a malformed record —
|
|
879
|
+
* is no owner at all. Absent and unreadable are deliberately one state: for
|
|
880
|
+
* the flat route both mean the private reply has no pinned recipient, which
|
|
881
|
+
* is a named failure, never a silent pass.
|
|
882
|
+
*/
|
|
883
|
+
export type TelegramDmOwnerState =
|
|
884
|
+
| { kind: "ok"; owner: TelegramDmOwner }
|
|
885
|
+
| { kind: "unreadable" };
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The bridge's untopiced-DM recipient, from the same state dir as
|
|
889
|
+
* `threads.json` (`dm-owner.json`).
|
|
890
|
+
*/
|
|
891
|
+
export function readTelegramDmOwner(stateDir?: string): TelegramDmOwnerState {
|
|
892
|
+
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
893
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
894
|
+
let raw: string;
|
|
895
|
+
try {
|
|
896
|
+
raw = readFileSync(join(dir, "dm-owner.json"), "utf8");
|
|
897
|
+
} catch {
|
|
898
|
+
return { kind: "unreadable" };
|
|
899
|
+
}
|
|
900
|
+
let parsed: unknown;
|
|
901
|
+
try {
|
|
902
|
+
parsed = JSON.parse(raw);
|
|
903
|
+
} catch {
|
|
904
|
+
return { kind: "unreadable" };
|
|
905
|
+
}
|
|
906
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
907
|
+
return { kind: "unreadable" };
|
|
908
|
+
}
|
|
909
|
+
const record = parsed as { readonly [key: string]: unknown };
|
|
910
|
+
const pid = record["pid"];
|
|
911
|
+
if (typeof pid !== "number" || !Number.isFinite(pid)) return { kind: "unreadable" };
|
|
912
|
+
const owner: TelegramDmOwner = { pid };
|
|
913
|
+
for (const key of ["name", "sessionId", "sessionFile", "workspaceLabel"] as const) {
|
|
914
|
+
const value = record[key];
|
|
915
|
+
if (typeof value === "string" && value.trim() !== "") owner[key] = value.trim();
|
|
916
|
+
}
|
|
917
|
+
return { kind: "ok", owner };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The paired inbound channel as the tick defines it, probed exactly the way
|
|
922
|
+
* `armTicks` probes it (the access file must parse, the bridge must be
|
|
923
|
+
* enabled and `allowFrom` must hold exactly one paired owner). Anything else
|
|
924
|
+
* — a missing or unreadable file, a disabled bridge, a missing or foreign
|
|
925
|
+
* pair — is down with the arm's own reason.
|
|
926
|
+
*/
|
|
927
|
+
export type TelegramChannelState =
|
|
928
|
+
| { kind: "up"; owner: string }
|
|
929
|
+
| { kind: "down"; reason: string };
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The paired inbound channel verdict, byte-identical to the one `armTicks`
|
|
933
|
+
* reads through its own `readPairedChannel` (fleet.ts keeps that one private;
|
|
934
|
+
* this port lives with the other bridge-state readers so `doctor` and the arm
|
|
935
|
+
* challenge can never disagree): the access file parses, the bridge is
|
|
936
|
+
* enabled and `allowFrom` holds exactly one paired owner id. Every failure
|
|
937
|
+
* reason is the arm's own, so a doctor finding and the arm's refusal name the
|
|
938
|
+
* same fact.
|
|
939
|
+
*/
|
|
940
|
+
export function readTelegramChannel(path: string): TelegramChannelState {
|
|
941
|
+
let parsed: unknown;
|
|
942
|
+
try {
|
|
943
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
944
|
+
} catch {
|
|
945
|
+
return { kind: "down", reason: "unreadable or missing access.json" };
|
|
946
|
+
}
|
|
947
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
948
|
+
return { kind: "down", reason: "access.json is not an object" };
|
|
949
|
+
}
|
|
950
|
+
const access = parsed as { readonly [key: string]: unknown };
|
|
951
|
+
if (access["enabled"] !== true) return { kind: "down", reason: "bridge disabled (enabled !== true)" };
|
|
952
|
+
const allowFrom = access["allowFrom"];
|
|
953
|
+
if (!Array.isArray(allowFrom) || allowFrom.length !== 1) {
|
|
954
|
+
return { kind: "down", reason: "allowFrom must hold exactly one paired owner id" };
|
|
955
|
+
}
|
|
956
|
+
const owner = allowFrom[0];
|
|
957
|
+
if (typeof owner !== "string" && typeof owner !== "number") {
|
|
958
|
+
return { kind: "down", reason: "paired owner id is not a string or number" };
|
|
959
|
+
}
|
|
960
|
+
return { kind: "up", owner: String(owner) };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** A fact a live-plumbing verdict verified; a pass names which kinds it proved. */
|
|
964
|
+
export type TelegramPlumbingProof = "claim" | "dm-owner" | "poller";
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The bounded reasons a live-plumbing verdict fails, each naming the fact that
|
|
968
|
+
* failed. Forum-route failures name the topic claim the challenge would send
|
|
969
|
+
* into; flat-route failures name the reply's recipient. An unreadable or
|
|
970
|
+
* malformed state is always its own named failure — never folded into "no
|
|
971
|
+
* claim"/"no owner", which would read as a pass.
|
|
972
|
+
*/
|
|
973
|
+
export type TelegramPlumbingFailureReason =
|
|
974
|
+
| "channel-down"
|
|
975
|
+
| "registry-unreadable"
|
|
976
|
+
| "no-topic-claim"
|
|
977
|
+
| "claim-dead"
|
|
978
|
+
| "claim-session-outside"
|
|
979
|
+
| "no-dm-owner"
|
|
980
|
+
| "dm-owner-dead"
|
|
981
|
+
| "dm-owner-unrelated"
|
|
982
|
+
| "no-poller-state"
|
|
983
|
+
| "poller-dead";
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* The liveness-checked verdict on omp-telegram plumbing, reusable by `arm`,
|
|
987
|
+
* `doctor` and recovery. A pass names the proof kinds only — never a chat or
|
|
988
|
+
* topic identifier, which are exactly the values that leak from log lines.
|
|
989
|
+
*/
|
|
990
|
+
export type TelegramPlumbingVerdict =
|
|
991
|
+
| { ok: true; proof: readonly TelegramPlumbingProof[] }
|
|
992
|
+
| { ok: false; reason: TelegramPlumbingFailureReason };
|
|
993
|
+
|
|
994
|
+
/** Pure-code wiring so a test injects live/dead pids and freshness without touching the host. */
|
|
995
|
+
export interface TelegramPlumbingProbe {
|
|
996
|
+
/**
|
|
997
|
+
* The typed claim-registry read. Consulted only on the forum route — a flat
|
|
998
|
+
* challenge never reads a claim. Any non-`ok` kind is a named failure.
|
|
999
|
+
*/
|
|
1000
|
+
registry: ClaimedTopicsResult;
|
|
1001
|
+
/** The typed `bot.lock` poll-ownership read — absent/malformed is a named failure. */
|
|
1002
|
+
poll: TelegramPollState;
|
|
1003
|
+
/**
|
|
1004
|
+
* The typed `dm-owner.json` read — the untopiced-DM recipient a flat
|
|
1005
|
+
* challenge's reply routes through. Consulted only on the flat route.
|
|
1006
|
+
*/
|
|
1007
|
+
dmOwner: TelegramDmOwnerState;
|
|
1008
|
+
/**
|
|
1009
|
+
* The paired inbound channel state, exactly as `armTicks` reads it. Down is
|
|
1010
|
+
* a named failure on both routes: the arm challenge sends to the paired
|
|
1011
|
+
* owner, so no channel means no challenge can go out at all.
|
|
1012
|
+
*/
|
|
1013
|
+
channel: TelegramChannelState;
|
|
1014
|
+
/**
|
|
1015
|
+
* Whether a recorded claim or dm-owner pid is a live process, with `topics.ts`
|
|
1016
|
+
* semantics (`src/topics.ts:isAlive`): `EPERM` is dead. Distinct from
|
|
1017
|
+
* {@link lockPidAlive} — the bridge itself uses two rules.
|
|
1018
|
+
*/
|
|
1019
|
+
alive: (pid: number) => boolean;
|
|
1020
|
+
/**
|
|
1021
|
+
* Whether a recorded bot.lock owner pid is a live process, with `api.ts`
|
|
1022
|
+
* semantics (`src/api.ts:pidAlive`): `EPERM` means the process exists but is
|
|
1023
|
+
* owned by another uid, so it is *live*. The bridge reads its own lock with
|
|
1024
|
+
* this rule (`acquireLock`), so a lock owner is judged differently from a
|
|
1025
|
+
* topic claim or the DM owner.
|
|
1026
|
+
*/
|
|
1027
|
+
lockAlive: (pid: number) => boolean;
|
|
1028
|
+
/**
|
|
1029
|
+
* Whether a lock-file mtime is younger than {@link TELEGRAM_LOCK_FRESH_MS} —
|
|
1030
|
+
* the bridge treats a fresh heartbeat as live even when the recorded pid is
|
|
1031
|
+
* gone (its own `acquireLock` liveness rule).
|
|
1032
|
+
*/
|
|
1033
|
+
fresh: (mtimeMs: number) => boolean;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The session surface the arm waiter actually scans for the challenge reply:
|
|
1038
|
+
* {@link armTicks} polls every `.jsonl` transcript *directly inside* these
|
|
1039
|
+
* directories — the tick-cwd-derived session directory plus, when the
|
|
1040
|
+
* project's claim names a session file elsewhere, that file's directory. The
|
|
1041
|
+
* recipient of the reply (a claim's, the DM owner's, or the local poll
|
|
1042
|
+
* owner's session file) must land in this set, or the reply can never satisfy
|
|
1043
|
+
* the challenge even though inbound works.
|
|
1044
|
+
*/
|
|
1045
|
+
export type TelegramPlumbingScan = { dirs: readonly string[] };
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Whether Telegram plumbing is live *now*, without the 300-second challenge and
|
|
1049
|
+
* without any network call, on the route {@link armTicks} actually sends on.
|
|
1050
|
+
*
|
|
1051
|
+
* The paired inbound channel must be up on both routes. Forum route (a
|
|
1052
|
+
* send-time topic was resolved): the claim for that *exact* topic must be
|
|
1053
|
+
* live — never an unrelated project-name match, which is what lets a live
|
|
1054
|
+
* sibling claim false-pass a stale pin — and its session identity must land
|
|
1055
|
+
* inside the scan surface or the reply can never be seen. Flat route (no
|
|
1056
|
+
* topic): the challenge is unthreaded and the reply routes through the DM
|
|
1057
|
+
* owner, so the pinned owner must be live and inside the scan surface; with
|
|
1058
|
+
* no pinned owner (`/telegram own clear`) the bridge delivers locally in the
|
|
1059
|
+
* polling session, so the lock's v2 owner record must carry a session
|
|
1060
|
+
* identity inside the scan surface — a daemon-only or unrelated owner is not a
|
|
1061
|
+
* recipient. On both routes the bridge's poll ownership must be live too:
|
|
1062
|
+
* `bot.lock` holds a valid owner whose pid is alive, or whose heartbeat is
|
|
1063
|
+
* fresh. Any unreadable or malformed state is a deterministic typed failure
|
|
1064
|
+
* naming the failed fact — never a pass. Poll liveness deliberately follows
|
|
1065
|
+
* the lock, not `daemon.json`: on a fatal the daemon releases `bot.lock` and
|
|
1066
|
+
* stops its heartbeat while its own pid stays live (#612 ecosystem
|
|
1067
|
+
* correction).
|
|
1068
|
+
*/
|
|
1069
|
+
export function telegramPlumbingVerdict(
|
|
1070
|
+
sendTopic: number | undefined,
|
|
1071
|
+
scan: TelegramPlumbingScan,
|
|
1072
|
+
probe: TelegramPlumbingProbe,
|
|
1073
|
+
): TelegramPlumbingVerdict {
|
|
1074
|
+
const { registry, poll, dmOwner, channel, alive, lockAlive, fresh } = probe;
|
|
1075
|
+
if (channel.kind === "down") return { ok: false, reason: "channel-down" };
|
|
1076
|
+
let recipient: TelegramPlumbingProof;
|
|
1077
|
+
if (sendTopic !== undefined) {
|
|
1078
|
+
if (registry.kind !== "ok") return { ok: false, reason: "registry-unreadable" };
|
|
1079
|
+
const claim = registry.claims.find((c) => c.threadId === sendTopic);
|
|
1080
|
+
if (claim === undefined) return { ok: false, reason: "no-topic-claim" };
|
|
1081
|
+
if (claim.pid === undefined || !alive(claim.pid)) return { ok: false, reason: "claim-dead" };
|
|
1082
|
+
if (claim.sessionFile !== undefined && !sessionWithinScan(claim.sessionFile, scan.dirs)) {
|
|
1083
|
+
return { ok: false, reason: "claim-session-outside" };
|
|
1084
|
+
}
|
|
1085
|
+
recipient = "claim";
|
|
1086
|
+
} else {
|
|
1087
|
+
if (dmOwner.kind === "ok") {
|
|
1088
|
+
if (dmOwner.owner.pid === undefined || !alive(dmOwner.owner.pid)) return { ok: false, reason: "dm-owner-dead" };
|
|
1089
|
+
if (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs)) {
|
|
1090
|
+
return { ok: false, reason: "dm-owner-unrelated" };
|
|
1091
|
+
}
|
|
1092
|
+
recipient = "dm-owner";
|
|
1093
|
+
} else {
|
|
1094
|
+
// No pinned DM owner — after `/telegram own clear` the bridge delivers
|
|
1095
|
+
// local replies in the polling session itself, so the lock's v2 owner
|
|
1096
|
+
// record must name that session. A daemon-only or unrelated owner is no
|
|
1097
|
+
// recipient: the reply would land in a transcript arm never scans.
|
|
1098
|
+
if (poll.kind !== "ok") return { ok: false, reason: "no-dm-owner" };
|
|
1099
|
+
if (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs)) {
|
|
1100
|
+
return { ok: false, reason: "no-dm-owner" };
|
|
1101
|
+
}
|
|
1102
|
+
recipient = "poller";
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (poll.kind !== "ok") return { ok: false, reason: "no-poller-state" };
|
|
1106
|
+
if (!lockAlive(poll.owner.pid) && !fresh(poll.mtimeMs)) return { ok: false, reason: "poller-dead" };
|
|
1107
|
+
// When the poller's own session is the flat recipient, the live lock is the
|
|
1108
|
+
// whole proof — the recipient and the poll are one fact, not two kinds.
|
|
1109
|
+
return { ok: true, proof: recipient === "poller" ? ["poller"] : [recipient, "poller"] };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Whether a session file sits directly inside one of the arm scan directories. */
|
|
1113
|
+
function sessionWithinScan(sessionFile: string, dirs: readonly string[]): boolean {
|
|
1114
|
+
return dirs.includes(dirname(sessionFile));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
614
1117
|
/**
|
|
615
1118
|
* The one Telegram send in this package. Exported so the report outbox (#123)
|
|
616
1119
|
* reuses it rather than forking it: the response handling below is load-bearing
|