switchroom 0.19.19 → 0.19.22

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.
Files changed (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
@@ -294,6 +294,7 @@ import {
294
294
  retryWithThreadFallback,
295
295
  isFloodWaitActiveError,
296
296
  } from '../retry-api-call.js'
297
+ import { installEditFloodFuse } from '../edit-flood-fuse.js'
297
298
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
298
299
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
299
300
  import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
@@ -635,6 +636,8 @@ import { formatUpdateStatusLine } from './update-status-line.js'
635
636
  import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
636
637
  import type { AgentAudit } from '../welcome-text.js'
637
638
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
639
+ import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
640
+ import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
638
641
  import {
639
642
  createDmPinSweeper,
640
643
  collectDmChatIdsFromStores,
@@ -1438,6 +1441,9 @@ async function rawEditMessageChecklist(args: {
1438
1441
 
1439
1442
  const chatLock = createChatLock()
1440
1443
  // P0b (#2996): wrapped in initGatewayBot() once `bot` is constructed at boot.
1444
+ // The `!` is an ASSERTION, not a guarantee — a module-eval-time reader gets
1445
+ // `undefined` and tsc will not warn (#3664). Anything touching the Bot API from
1446
+ // module scope must run behind `bootPinSweepGate` / assert readiness first.
1441
1447
  let lockedBot!: Bot<Context>
1442
1448
  let botUsername = ''
1443
1449
 
@@ -8660,21 +8666,14 @@ function persistBannerRow(row: PersistedStatusPin | null): void {
8660
8666
  )
8661
8667
  }
8662
8668
 
8663
- // The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
8664
- // lazily so this helper can be declared alongside the state it owns.
8669
+ // The Bot API surface the pin driver needs. `lockedBot` is assigned late (in
8670
+ // initGatewayBot) so it is read lazily; createStatusPinApi also asserts it
8671
+ // exists and converts a send-gate SHED into a throw — see status-pin-api.ts.
8665
8672
  function statusPinApi(): PinBotApi {
8666
- return {
8667
- pinChatMessage: (chat_id, message_id, opts) =>
8668
- robustApiCall(
8669
- () => lockedBot.api.pinChatMessage(chat_id, message_id, opts),
8670
- { chat_id: String(chat_id), verb: 'status-pin.pin' },
8671
- ),
8672
- unpinChatMessage: (chat_id, message_id) =>
8673
- robustApiCall(
8674
- () => lockedBot.api.unpinChatMessage(chat_id, message_id),
8675
- { chat_id: String(chat_id), verb: 'status-pin.unpin' },
8676
- ),
8677
- }
8673
+ return createStatusPinApi(
8674
+ () => lockedBot as unknown as PinCapableBot | undefined,
8675
+ robustApiCall as RobustApiSeam,
8676
+ )
8678
8677
  }
8679
8678
 
8680
8679
  /**
@@ -9291,42 +9290,46 @@ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
9291
9290
  })
9292
9291
 
9293
9292
  /**
9294
- * Boot-time pin cleanup + DM stale-pin sweep, sequenced under the startup
9295
- * mutex. Collects the DM chat IDs with a prior-session pin record BEFORE the
9296
- * store reapers empty the stores, runs the three existing boot reapers, marks
9297
- * the DM sweep eligible (this gateway now owns the shared state), then
9298
- * unpin-alls each recorded DM chat. Fire-and-forget from the caller never
9299
- * blocks boot, never rejects unhandled.
9293
+ * Boot-time pin cleanup + DM stale-pin sweep. Collects the DM chat IDs with a
9294
+ * prior-session pin record BEFORE the store reapers empty the stores, runs the
9295
+ * three boot reapers, marks the DM sweep eligible, then unpin-alls each
9296
+ * recorded DM chat. Ordering and per-step isolation live in
9297
+ * `runBootPinSweepSteps` (boot-sweep-gate.ts) a throwing reaper must not
9298
+ * strand `enableDmSweep`. Dispatched only via `bootPinSweepGate` below (mutex
9299
+ * won AND `lockedBot` constructed); fire-and-forget — never blocks boot.
9300
9300
  */
9301
- async function runBootPinCleanupAndDmSweep(): Promise<void> {
9302
- let dmChatIds: string[] = []
9303
- try {
9304
- dmChatIds = collectDmChatIdsFromStores({
9305
- statusPins:
9306
- statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
9307
- ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9301
+ function runBootPinCleanupAndDmSweep(): Promise<void> {
9302
+ return runBootPinSweepSteps({
9303
+ scanDmChatIds: () =>
9304
+ collectDmChatIdsFromStores({
9305
+ statusPins:
9306
+ statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
9307
+ ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9308
+ : [],
9309
+ activityCards: activityCardPersistEnabled
9310
+ ? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
9308
9311
  : [],
9309
- activityCards: activityCardPersistEnabled
9310
- ? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
9311
- : [],
9312
- queuedCards: queuedCardPersistEnabled
9313
- ? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
9314
- : [],
9315
- })
9316
- } catch (err) {
9317
- process.stderr.write(
9318
- `telegram gateway: dm-pin-sweep: store scan failed: ${(err as Error).message}\n`,
9319
- )
9320
- }
9321
- await statusPinBootCleanup()
9322
- await activityCardBootReaper()
9323
- await queuedCardBootReaper()
9324
- // This gateway now owns the shared per-agent pin state — enable the DM
9325
- // unpin-all path (both the boot sweep below and lazy first-inbound sweeps).
9326
- dmPinSweepEligible = true
9327
- for (const id of dmChatIds) await dmPinSweeper.sweep(id)
9312
+ queuedCards: queuedCardPersistEnabled
9313
+ ? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
9314
+ : [],
9315
+ }),
9316
+ statusPinCleanup: statusPinBootCleanup,
9317
+ activityCardReaper: activityCardBootReaper,
9318
+ queuedCardReaper: queuedCardBootReaper,
9319
+ // This gateway owns the shared pin state — enable the DM unpin-all path
9320
+ // (this sweep AND lazy first-inbound sweeps).
9321
+ enableDmSweep: () => {
9322
+ dmPinSweepEligible = true
9323
+ },
9324
+ sweepDm: (id) => dmPinSweeper.sweep(id),
9325
+ log: (line) => process.stderr.write(line),
9326
+ })
9328
9327
  }
9329
9328
 
9329
+ // #3664: needs BOTH the mutex (arm) and a constructed `lockedBot` (botReady,
9330
+ // end of initGatewayBot) before it may run — see boot-sweep-gate.ts.
9331
+ const bootPinSweepGate = createBootSweepGate({ run: runBootPinCleanupAndDmSweep, onError: (err) => process.stderr.write(`telegram gateway: boot pin cleanup / DM sweep failed: ${(err as Error).message}\n`) })
9332
+
9330
9333
  // Activity feed. The gateway streams a live "what it's doing" tool-activity
9331
9334
  // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
9332
9335
  // call (flush-independent, so it stays real-time on fast/clustered-tool
@@ -9486,14 +9489,11 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
9486
9489
  const carrierAgentDir = resolveAgentDirFromEnv()
9487
9490
  if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9488
9491
  }
9489
- // We WON the startup mutex — this gateway is the sole live owner of the
9490
- // shared per-agent status-pin store, so it's now safe to clean up orphaned
9491
- // pins from a prior (dead) session. Gated here (not at import time) so a
9492
- // LOSING double-boot never unpins the live holder's legitimate pins.
9493
- // Fire-and-forget: cleanup is best-effort and must not block boot.
9494
- // #3026: sequenced so the DM stale-pin sweep runs after the reapers and
9495
- // only once this gateway owns the shared state.
9496
- void runBootPinCleanupAndDmSweep()
9492
+ // We WON the startup mutex — the sole live owner of the shared per-agent
9493
+ // pin store may clear a dead session's orphans (a LOSING double-boot must
9494
+ // not, hence arming here, not at import). #3664: arming is HALF the
9495
+ // precondition the gate holds the sweep until `lockedBot` exists.
9496
+ bootPinSweepGate.arm()
9497
9497
  } catch (err) {
9498
9498
  process.stderr.write(
9499
9499
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -9516,8 +9516,8 @@ if (isGatewayMain) { // #2996 P0c: gated in place; guarded await never runs on
9516
9516
  const carrierAgentDir = resolveAgentDirFromEnv()
9517
9517
  if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9518
9518
  }
9519
- // #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
9520
- void runBootPinCleanupAndDmSweep()
9519
+ // #3026: same sequenced cleanup + DM sweep, same #3664 bot-ready gate.
9520
+ bootPinSweepGate.arm()
9521
9521
  } catch (writeErr) {
9522
9522
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
9523
9523
  }
@@ -22959,6 +22959,13 @@ async function initGatewayBot(): Promise<void> {
22959
22959
 
22960
22960
  bot = new Bot(TOKEN)
22961
22961
  installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
22962
+ // #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
22963
+ // outbound call can bypass (grammY has no route to the network that skips the
22964
+ // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
22965
+ installEditFloodFuse(bot, {
22966
+ enabled: process.env.SWITCHROOM_EDIT_FUSE !== '0',
22967
+ onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key}\n`),
22968
+ })
22962
22969
 
22963
22970
  // Diagnostic update tap (#3300): one compact line per received update, logged
22964
22971
  // BEFORE any specific handler runs, so a routing-layer drop is diagnosable
@@ -23081,6 +23088,9 @@ async function initGatewayBot(): Promise<void> {
23081
23088
 
23082
23089
  // Install the grammY registration surface (P0a) — once, before the runner.
23083
23090
  registerGatewayHandlers(bot, {})
23091
+
23092
+ // #3664: `lockedBot` exists now — release the boot pin sweep iff armed.
23093
+ bootPinSweepGate.botReady()
23084
23094
  }
23085
23095
 
23086
23096
  // One-shot startup guard. The outer for-loop below re-enters its try block
@@ -169,6 +169,9 @@ export interface InboundInterceptorDeps {
169
169
  ReturnType<typeof createCallbackQueryHandlers>,
170
170
  | 'executeDeferredSecretSave'
171
171
  | 'performVaultAccessApproval'
172
+ // #3627: owns the per-passphrase-entry attempt counter + re-prompt /
173
+ // lockout decision after the broker refuses a mint as a mismatch.
174
+ | 'resolveAccessApprovalPassphraseMismatch'
172
175
  | 'parseGrantDuration'
173
176
  | 'grantWizardConfirm'
174
177
  >
@@ -750,10 +753,13 @@ export async function interceptVault(
750
753
  // unlocking. We captured the next message as the passphrase,
751
754
  // cache it, delete the chat copy, and resume the approve
752
755
  // flow for EVERY queued stage (#1051 — without the queue, a
753
- // concurrent second tap orphaned the first stage). Wrong
756
+ // concurrent second tap orphaned the first stage). A wrong
754
757
  // passphrase surfaces via the broker's
755
- // DENIED:passphrase-mismatch path and edits each card to the
756
- // mint_grant-failed message (see performVaultAccessApproval).
758
+ // DENIED:passphrase-mismatch path; #3627 keeps those stages
759
+ // ALIVE and re-prompts with the attempts remaining (counted per
760
+ // passphrase ENTRY, since this one entry drains the whole batch)
761
+ // until MAX_VAULT_PASSPHRASE_ATTEMPTS, then falls back to the
762
+ // terminal card edit.
757
763
  const passphrase = p.text.trim()
758
764
  if (!passphrase) {
759
765
  await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Ask the agent to re-issue the request card.', { html: true })
@@ -767,6 +773,11 @@ export async function interceptVault(
767
773
  // grows monotonically (item 1 mints grant for [keyA]; item 2
768
774
  // lists, finds [keyA], unions with keyB → mints [keyA, keyB]).
769
775
  // Parallel processing would race on the list-and-merge.
776
+ // #3627: stages the broker refused as a passphrase MISMATCH. They
777
+ // are still staged and their cards still say "waiting" — collected
778
+ // here so one re-prompt covers the whole batch.
779
+ const mismatched: typeof pendingVault.items = []
780
+ let mismatchMsg = ''
770
781
  for (const item of pendingVault.items) {
771
782
  const stagedAccess = deps.pendingVaultRequestAccesses.get(item.stageId)
772
783
  if (!stagedAccess) {
@@ -783,7 +794,19 @@ export async function interceptVault(
783
794
  .catch(() => {})
784
795
  continue
785
796
  }
786
- await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
797
+ const outcome = await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
798
+ if (outcome?.kind === 'passphrase-mismatch') {
799
+ mismatched.push(item)
800
+ mismatchMsg = outcome.msg
801
+ }
802
+ }
803
+ if (mismatched.length > 0) {
804
+ await deps.callbackQueryHandlers().resolveAccessApprovalPassphraseMismatch(p.ctx, {
805
+ chat_id: p.chat_id,
806
+ failed: mismatched,
807
+ priorAttempts: pendingVault.attempts ?? 0,
808
+ brokerMsg: mismatchMsg,
809
+ })
787
810
  }
788
811
  } else if (pendingVault.kind === 'grant-wizard' && pendingVault.awaitingCustomDuration) {
789
812
  // Issue #227: custom duration text reply for grant wizard
@@ -49,6 +49,7 @@
49
49
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
50
50
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
51
51
  import { richMessage } from '../rich-send.js'
52
+ import { isSendGateShed } from '../send-gate.js'
52
53
  import { appendShownBlock } from '../shown-ledger.js'
53
54
  import {
54
55
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
@@ -446,10 +447,41 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
446
447
  )
447
448
  } else {
448
449
  const id = turn.activityMessageId
449
- await robustApiCall(
450
+ // #3620: the live activity card is THE highest-volume repeated
451
+ // editMessageText on one message id in the whole gateway, and until
452
+ // this fix it passed NO `messageId` / `editPayload` to the send
453
+ // gate. `sendGate.gate` only routes to its edit path when BOTH are
454
+ // present (send-gate.ts `gate()`), so every card edit fell through
455
+ // to the plain-send path: no per-message edit floor, no
456
+ // last-write-wins coalescing, no no-op payload skip, and — because
457
+ // an untagged non-edit send defaults to `critical`
458
+ // (UNTAGGED_SEND_CLASS) — never shed and never budget-capped. The
459
+ // card was therefore free to edit one message at the per-chat
460
+ // bucket rate (1/s = 60/min) for as long as a turn ran, which is
461
+ // what earned the 2026-07-25 62-minute flood ban.
462
+ //
463
+ // Tagging it COSMETIC + keying it engages all four protections,
464
+ // including the long-horizon per-message edit budget that is
465
+ // scoped to cosmetic edits.
466
+ const editRes = await robustApiCall(
450
467
  () => bot.api.editMessageText(chat, id, richMessage(html), {}),
451
- { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
468
+ {
469
+ chat_id: chat,
470
+ ...(thread != null ? { threadId: thread } : {}),
471
+ verb: 'activity-summary.edit',
472
+ priorityClass: 'cosmetic',
473
+ messageId: id,
474
+ editPayload: html,
475
+ },
452
476
  )
477
+ // Shed honesty: a shed edit did NOT land (the gate resolves the
478
+ // SEND_GATE_SHED sentinel, not `undefined`). Leaving
479
+ // `activityLastSentRender` behind keeps the newest render PENDING so
480
+ // the next drain (a fresh tool label, or the feed heartbeat)
481
+ // repaints it, and `break`ing stops the drain hot-looping against a
482
+ // closed gate. A no-op drop still resolves `undefined` — that
483
+ // payload IS on screen, so it advances normally below.
484
+ if (isSendGateShed(editRes)) break
453
485
  }
454
486
  turn.activityLastSentRender = target
455
487
  } catch (err) {
@@ -853,7 +885,21 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
853
885
  try {
854
886
  await robustApiCall(
855
887
  () => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
856
- { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
888
+ {
889
+ chat_id: chat,
890
+ ...(thread != null ? { threadId: thread } : {}),
891
+ verb: 'activity-summary.finalize',
892
+ // Keyed for the same reason as the drain edit: this edits the SAME
893
+ // message id the drain has been repainting, so it must share that
894
+ // message's edit floor / coalescer / budget rather than slipping
895
+ // past the gate as an untagged `critical` send. `useful` (not
896
+ // `cosmetic`) because it paints the card's terminal state — it may
897
+ // be deferred, but it should not be shed at the first sign of
898
+ // pressure.
899
+ priorityClass: 'useful',
900
+ messageId: id,
901
+ editPayload: finalHtml,
902
+ },
857
903
  )
858
904
  } catch (err) {
859
905
  // Same transport-class discipline as the delete path: the card
@@ -0,0 +1,145 @@
1
+ /**
2
+ * status-pin-api.ts — the Bot API surface the status-pin driver calls, bound to
3
+ * the gateway's retry policy + send gate (#3664).
4
+ *
5
+ * Extracted out of `gateway.ts` (which is under a line ratchet,
6
+ * `scripts/check-gateway-line-ratchet.mjs`) so the two invariants below can be
7
+ * enforced in code and proven in isolation, instead of resting on defaults that
8
+ * live in other modules.
9
+ *
10
+ * INVARIANT 1 — the bot must exist (`assertBotReady`).
11
+ * `lockedBot` in gateway.ts is declared `let lockedBot!: Bot<Context>`. The
12
+ * definite-assignment `!` is an ASSERTION, not a guarantee: it told `tsc` to
13
+ * stop checking, which is exactly why the boot orphan sweep dereferencing an
14
+ * unset `lockedBot` at module-eval time compiled clean and shipped. Every unpin
15
+ * it issued failed with the opaque
16
+ * `undefined is not an object (evaluating 'lockedBot.api')`, which read like a
17
+ * Telegram fault for a month. The ordering FIX is the two-condition gate in
18
+ * `boot-sweep-gate.ts`; this is the BACKSTOP, so any future pre-ready caller
19
+ * gets a named, greppable `STATUS_PIN_BOT_NOT_READY` instead.
20
+ *
21
+ * INVARIANT 2 — a shed send must not look like a landed one (`assertLanded`).
22
+ * See the docblock on `assertLanded`.
23
+ */
24
+
25
+ import type { PinBotApi } from '../status-pin-driver.js'
26
+ import { SEND_GATE_SHED } from '../send-gate.js'
27
+
28
+ /** Thrown when the pin API is used before `lockedBot` has been assigned. */
29
+ export class BotNotReadyError extends Error {
30
+ constructor(what: string) {
31
+ super(
32
+ `STATUS_PIN_BOT_NOT_READY: ${what} used before initGatewayBot() assigned ` +
33
+ `lockedBot — the boot sweep must run behind the boot-sweep gate`,
34
+ )
35
+ this.name = 'BotNotReadyError'
36
+ }
37
+ }
38
+
39
+ /** Narrow `bot` to non-null or throw {@link BotNotReadyError}. */
40
+ export function assertBotReady<T>(bot: T | undefined | null, what: string): T {
41
+ if (bot == null) throw new BotNotReadyError(what)
42
+ return bot
43
+ }
44
+
45
+ /** Thrown when the outbound send gate SHED a pin/unpin — it never reached Telegram. */
46
+ export class SendGateShedError extends Error {
47
+ constructor(verb: string) {
48
+ super(`STATUS_PIN_SEND_SHED: ${verb} was shed by the send gate and never reached Telegram`)
49
+ this.name = 'SendGateShedError'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Convert a send-gate SHED into a throw.
55
+ *
56
+ * The pin state machine reads "the call did not throw" as "the pin/unpin
57
+ * LANDED": `reconcilePin` claims the message on a resolved pin, and treats a
58
+ * resolved unpin as confirmed — dropping the in-memory claim AND (via the null
59
+ * branch of `reconcileAndPersistStatusPin`) the durable `status-pins.json` row.
60
+ * A send that was DROPPED but resolved success-shaped therefore reopens #3664
61
+ * Defect B through a second door: the still-pinned message loses its last
62
+ * record and no reaper or boot sweep can ever find it again.
63
+ *
64
+ * `robustApiCall` does not throw on a shed — it RESOLVES the gate's
65
+ * `SEND_GATE_SHED` sentinel (send-gate.ts). Today a status-pin call
66
+ * cannot be shed, because it is an untagged SEND and untagged sends admit as
67
+ * `UNTAGGED_SEND_CLASS`, which is `'critical'` and is never shed.
68
+ * But that is a DEFAULT IN ANOTHER MODULE: tagging `status-pin.unpin` with a
69
+ * `priorityClass`, or changing that default, would silently reopen the defect
70
+ * with no test failing. This turns the default into an enforced invariant.
71
+ *
72
+ * Sentinel ONLY. A plain `undefined` is deliberately NOT a failure: the gate
73
+ * also resolves `undefined` for a benign no-op drop (identical payload already
74
+ * on screen) and the retry policy resolves `undefined` for swallowed benign
75
+ * 400s. Conflating those with a shed is the exact ambiguity `SEND_GATE_SHED`
76
+ * was introduced to remove (see its docblock in send-gate.ts).
77
+ *
78
+ * Known residual: a `useful`-classed send whose queue TTL EXPIRES also resolves
79
+ * `undefined` (`send-gate.ts`, `outcome.result === 'expired'`) and so is not
80
+ * caught here. That is unreachable for status pins — they are untagged, hence
81
+ * `critical`, which is never TTL-dropped — and closing it would mean treating
82
+ * every benign `undefined` as a failure, which is strictly worse. If a status
83
+ * pin is ever deliberately tagged `useful`, this needs a distinguishable
84
+ * expiry sentinel too.
85
+ */
86
+ export function assertLanded(result: unknown, verb: string): unknown {
87
+ if (result === SEND_GATE_SHED) throw new SendGateShedError(verb)
88
+ return result
89
+ }
90
+
91
+ /** Minimal shape of the wrapped gateway bot this module needs. */
92
+ export interface PinCapableBot {
93
+ api: {
94
+ pinChatMessage: (
95
+ chatId: string | number,
96
+ messageId: number,
97
+ opts?: Record<string, unknown>,
98
+ ) => Promise<unknown>
99
+ unpinChatMessage: (chatId: string | number, messageId: number) => Promise<unknown>
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The gateway's `robustApiCall` seam (retry policy + send gate), erased to the
105
+ * shape this module needs so `gateway.ts` can pass it with a single cast.
106
+ */
107
+ export type RobustApiSeam = (
108
+ fn: () => Promise<unknown>,
109
+ opts: Record<string, unknown>,
110
+ ) => Promise<unknown>
111
+
112
+ /**
113
+ * Build the pin API.
114
+ *
115
+ * `getBot` is read LAZILY on every call — `lockedBot` is assigned late, inside
116
+ * `initGatewayBot()` — and asserted non-null (INVARIANT 1). `robust` is the
117
+ * gateway's `robustApiCall`, so pins/unpins ride the send gate and retry policy
118
+ * exactly as before this extraction, with the shed sentinel converted to a
119
+ * throw on the way out (INVARIANT 2).
120
+ */
121
+ export function createStatusPinApi(
122
+ getBot: () => PinCapableBot | undefined,
123
+ robust: RobustApiSeam,
124
+ ): PinBotApi {
125
+ const call = (verb: string, fn: (bot: PinCapableBot) => Promise<unknown>, chatId: string) =>
126
+ robust(() => fn(assertBotReady(getBot(), verb)), { chat_id: chatId, verb }).then((r) =>
127
+ assertLanded(r, verb),
128
+ )
129
+ return {
130
+ pinChatMessage: (chat_id, message_id, opts) =>
131
+ call(
132
+ 'status-pin.pin',
133
+ // allow-raw-bot-api: `call` runs this inside the injected `robust` seam (robustApiCall).
134
+ (bot) => bot.api.pinChatMessage(chat_id, message_id, opts),
135
+ String(chat_id),
136
+ ),
137
+ unpinChatMessage: (chat_id, message_id) =>
138
+ call(
139
+ 'status-pin.unpin',
140
+ // allow-raw-bot-api: as above — wrapped by the injected `robust` seam.
141
+ (bot) => bot.api.unpinChatMessage(chat_id, message_id),
142
+ String(chat_id),
143
+ ),
144
+ }
145
+ }