switchroom 0.18.8 → 0.18.9

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 (36) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +2 -2
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
  6. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  7. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  8. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  9. package/telegram-plugin/gateway/gateway.ts +527 -2880
  10. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  11. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  13. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  14. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  15. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  16. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  17. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  18. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  20. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  21. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  22. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  23. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  24. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  26. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  27. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  29. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  30. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  31. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  32. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  33. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  34. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  35. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  36. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
@@ -0,0 +1,106 @@
1
+ /**
2
+ * In-memory storage for the LONG-TAIL of gateway.ts pending-state Maps —
3
+ * the auth/vault-wizard, config-edit correlation, and transient capture Maps
4
+ * that PR #3008 deferred when it moved the four agent-initiated approval-card
5
+ * families behind `approval-card-stores.ts`. Phase 3 step 2 of the gateway
6
+ * decomposition (see #2996): STORAGE ONLY.
7
+ *
8
+ * Two shapes, mirroring the two shapes the long-tail Maps actually have:
9
+ *
10
+ * - `createSweepableStore<T>(isExpired)` — a Map-compatible store whose
11
+ * `sweep(now)` deletes every entry the caller's `isExpired(value, now)`
12
+ * predicate reports as past its TTL. The predicate is supplied by the
13
+ * gateway and CLOSES OVER the family's TTL constant, so the TTL and its
14
+ * comparison DIRECTION stay verbatim in gateway.ts — different families use
15
+ * `now - staged_at > TTL`, `now - startedAt > TTL`, `now - createdAt > TTL`,
16
+ * or an absolute `now > expiresAt`, and each is preserved byte-identically.
17
+ * `sweep` is a plain delete-past-TTL loop (no wake / side effects); it
18
+ * replaces the identical open-coded reaper / standalone-sweep loops. The
19
+ * delete-during-iteration is safe (JS Map iterators tolerate deleting the
20
+ * current key) — exactly as the original loops relied on.
21
+ *
22
+ * - `createPlainStore<T>()` — a Map-compatible store with NO sweep, for the
23
+ * long-tail Maps whose lifetime is bounded by per-entry timers or an
24
+ * LRU cap rather than a TTL sweep (`pendingAskUser`, `agentButtonMeta`).
25
+ *
26
+ * Why a Map-compatible surface (get/set/delete/has/size/keys/values/entries/
27
+ * forEach/iteration) rather than a bespoke API: every existing gateway call
28
+ * site used the raw Map directly. Preserving the exact Map method surface —
29
+ * and keeping the variable name unchanged — keeps those call sites
30
+ * BYTE-IDENTICAL. The store moves WHERE the Map is constructed and (for the
31
+ * sweepable shape) WHERE its sweep lives, nothing about how the gateway reads
32
+ * or writes it.
33
+ *
34
+ * The backing Map is the sole storage and backs every iterator, so mutation-
35
+ * during-iteration order/semantics are identical to the raw Map the gateway
36
+ * used before.
37
+ */
38
+
39
+ export interface PlainStore<T> {
40
+ get(key: string): T | undefined
41
+ set(key: string, value: T): void
42
+ delete(key: string): boolean
43
+ has(key: string): boolean
44
+ clear(): void
45
+ readonly size: number
46
+ keys(): IterableIterator<string>
47
+ values(): IterableIterator<T>
48
+ entries(): IterableIterator<[string, T]>
49
+ forEach(cb: (value: T, key: string, map: Map<string, T>) => void): void
50
+ [Symbol.iterator](): IterableIterator<[string, T]>
51
+ }
52
+
53
+ export interface SweepableStore<T> extends PlainStore<T> {
54
+ /**
55
+ * Delete every entry past its TTL per the injected `isExpired` predicate.
56
+ * Plain delete-past-TTL, no wake — byte-identical to the open-coded reaper /
57
+ * standalone sweep loop it replaces.
58
+ */
59
+ sweep(now: number): void
60
+ }
61
+
62
+ // Build the shared Map surface onto `target` (a getter for `size`, so the
63
+ // live Map size is read on every access — an object spread would freeze it).
64
+ function attachMapSurface<T, S extends object>(target: S, map: Map<string, T>): S & PlainStore<T> {
65
+ return Object.defineProperties(target, {
66
+ get: { value: (key: string) => map.get(key), enumerable: true },
67
+ set: { value: (key: string, value: T) => void map.set(key, value), enumerable: true },
68
+ delete: { value: (key: string) => map.delete(key), enumerable: true },
69
+ has: { value: (key: string) => map.has(key), enumerable: true },
70
+ clear: { value: () => map.clear(), enumerable: true },
71
+ size: { get: () => map.size, enumerable: true },
72
+ keys: { value: () => map.keys(), enumerable: true },
73
+ values: { value: () => map.values(), enumerable: true },
74
+ entries: { value: () => map.entries(), enumerable: true },
75
+ forEach: {
76
+ value: (cb: (value: T, key: string, m: Map<string, T>) => void) => map.forEach(cb),
77
+ enumerable: true,
78
+ },
79
+ [Symbol.iterator]: { value: () => map[Symbol.iterator](), enumerable: true },
80
+ }) as S & PlainStore<T>
81
+ }
82
+
83
+ /**
84
+ * Create a Map-backed store with no automatic expiry. For long-tail Maps
85
+ * whose entries are removed by per-entry timers or an LRU cap, not a TTL sweep.
86
+ */
87
+ export function createPlainStore<T>(): PlainStore<T> {
88
+ return attachMapSurface({}, new Map<string, T>())
89
+ }
90
+
91
+ /**
92
+ * Create a Map-backed, self-sweeping store for one long-tail pending-state
93
+ * family. `isExpired` closes over the family's TTL constant and encodes its
94
+ * exact comparison direction; `sweep(now)` deletes every entry it flags.
95
+ */
96
+ export function createSweepableStore<T>(
97
+ isExpired: (value: T, now: number) => boolean,
98
+ ): SweepableStore<T> {
99
+ const map = new Map<string, T>()
100
+ const sweep = (now: number): void => {
101
+ for (const [k, v] of map) {
102
+ if (isExpired(v, now)) map.delete(k)
103
+ }
104
+ }
105
+ return attachMapSurface({ sweep }, map)
106
+ }
@@ -0,0 +1,30 @@
1
+ import type { Bot } from 'grammy'
2
+ import {
3
+ TELEGRAM_BASE_COMMANDS,
4
+ TELEGRAM_SWITCHROOM_COMMANDS,
5
+ } from '../welcome-text.js'
6
+
7
+ /**
8
+ * Register the bot's slash-command menu with Telegram (`setMyCommands`).
9
+ *
10
+ * Extracted verbatim from gateway.ts (#2996 Phase 5 leaf move). The `bot`
11
+ * singleton is injected rather than imported so this stays a pure leaf with
12
+ * no back-reference into the gateway module.
13
+ *
14
+ * Slash-menu is deliberately trimmed from the full command catalogue.
15
+ * See telegram-plugin/welcome-text.ts TELEGRAM_MENU_COMMANDS for the
16
+ * rationale (mobile UX focus; ops primitives stay typable but out of
17
+ * the autocomplete clutter). /commands surfaces the full list.
18
+ */
19
+ export async function registerSwitchroomBotCommands(bot: Bot): Promise<void> {
20
+ await bot.api.setMyCommands(
21
+ [...TELEGRAM_BASE_COMMANDS, ...TELEGRAM_SWITCHROOM_COMMANDS],
22
+ { scope: { type: 'all_private_chats' } },
23
+ )
24
+ // Group chats don't support /start pairing, so only the switchroom
25
+ // commands are registered there.
26
+ await bot.api.setMyCommands(
27
+ TELEGRAM_SWITCHROOM_COMMANDS,
28
+ { scope: { type: 'all_group_chats' } },
29
+ )
30
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Race/ordering pins for the in-memory approval-card stores
3
+ * (gateway/approval-card-stores.ts) extracted in #2996 Phase 3 step 1.
4
+ *
5
+ * These lock the exact orderings the gateway's reaper + callback handlers +
6
+ * boot restore depend on, BEFORE the storage moved behind the store module:
7
+ *
8
+ * 1. verdict-during-sweep is single-shot — an operator tap (`.delete`) that
9
+ * lands while the sweep is iterating fires the expiry synthetic AT MOST
10
+ * once for a given card; a second sweep tick never double-wakes it.
11
+ * 2. verdict-during-restore is desync-free — a `.set` (restore) then a
12
+ * `.delete` (tap) operate on one Map instance, so a tap can't resurrect a
13
+ * restored entry nor a restore resurrect a tapped one.
14
+ * 3. sweep cadence/fault-isolation is byte-identical to the old inline
15
+ * `sweepExpiredEntries(...)`: only past-TTL entries expire, and one
16
+ * throwing expiry never skips the remaining entries.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest'
20
+ import {
21
+ createSweepableCardStore,
22
+ type SweepableCardStore,
23
+ } from '../gateway/approval-card-stores.js'
24
+
25
+ interface Card {
26
+ staged_at: number
27
+ agent: string
28
+ }
29
+
30
+ const TTL = 1000
31
+
32
+ function makeStore(
33
+ expire: (stageId: string, v: Card, now: number) => void,
34
+ log: (msg: string) => void = () => {},
35
+ ): SweepableCardStore<Card> {
36
+ return createSweepableCardStore<Card>({
37
+ isExpired: (v, now) => now - v.staged_at > TTL,
38
+ expire: () => expire,
39
+ log: () => log,
40
+ })
41
+ }
42
+
43
+ describe('approval-card-stores: sweep cadence', () => {
44
+ it('expires only entries past TTL', () => {
45
+ const expired: string[] = []
46
+ const store = makeStore((id) => {
47
+ expired.push(id)
48
+ store.delete(id) // mirror the gateway's expire.remove()
49
+ })
50
+ store.set('fresh', { staged_at: 5000, agent: 'a' })
51
+ store.set('stale', { staged_at: 1000, agent: 'b' })
52
+ store.sweep(5000) // fresh: 0ms old, stale: 4000ms old > TTL
53
+ expect(expired).toEqual(['stale'])
54
+ expect(store.has('fresh')).toBe(true)
55
+ expect(store.has('stale')).toBe(false)
56
+ })
57
+
58
+ it('is single-shot across two reaper ticks (verdict-during-sweep safe)', () => {
59
+ const wakes: string[] = []
60
+ const store = makeStore((id) => {
61
+ // The pure expire core removes BEFORE the fallible wake; model that here.
62
+ store.delete(id)
63
+ wakes.push(id)
64
+ })
65
+ store.set('c1', { staged_at: 0, agent: 'a' })
66
+ store.sweep(5000)
67
+ store.sweep(5000) // second tick — entry already gone
68
+ expect(wakes).toEqual(['c1']) // exactly one wake, never two
69
+ })
70
+
71
+ it('a throwing expiry never skips the remaining entries', () => {
72
+ const seen: string[] = []
73
+ const store = makeStore((id) => {
74
+ seen.push(id)
75
+ if (id === 'boom') throw new Error('dead socket')
76
+ store.delete(id)
77
+ })
78
+ store.set('boom', { staged_at: 0, agent: 'a' })
79
+ store.set('ok', { staged_at: 0, agent: 'b' })
80
+ expect(() => store.sweep(5000)).not.toThrow()
81
+ expect(seen).toContain('boom')
82
+ expect(seen).toContain('ok')
83
+ expect(store.has('ok')).toBe(false) // 'ok' still expired despite 'boom' throwing
84
+ })
85
+ })
86
+
87
+ describe('approval-card-stores: verdict-during-restore desync-free', () => {
88
+ it('a tap after restore removes the restored entry (no resurrection)', () => {
89
+ const store = makeStore(() => {})
90
+ store.set('r1', { staged_at: 0, agent: 'a' }) // restore populates
91
+ expect(store.get('r1')).toBeDefined()
92
+ const removed = store.delete('r1') // operator tap resolves it
93
+ expect(removed).toBe(true)
94
+ expect(store.has('r1')).toBe(false)
95
+ })
96
+
97
+ it('a sweep after a tap on the same instance is a no-op for that card', () => {
98
+ const wakes: string[] = []
99
+ const store = makeStore((id) => {
100
+ store.delete(id)
101
+ wakes.push(id)
102
+ })
103
+ store.set('r2', { staged_at: 0, agent: 'a' })
104
+ store.delete('r2') // tap resolves before the reaper runs
105
+ store.sweep(5000)
106
+ expect(wakes).toEqual([]) // already resolved — no timeout wake
107
+ })
108
+ })
109
+
110
+ describe('approval-card-stores: Map surface parity', () => {
111
+ it('supports get/set/delete/has/size/iteration used by call sites', () => {
112
+ const store = makeStore(() => {})
113
+ store.set('a', { staged_at: 1, agent: 'x' })
114
+ store.set('b', { staged_at: 2, agent: 'y' })
115
+ expect(store.size).toBe(2)
116
+ expect(store.get('a')?.agent).toBe('x')
117
+ const seen: string[] = []
118
+ for (const [id] of store) seen.push(id)
119
+ expect(seen.sort()).toEqual(['a', 'b'])
120
+ expect([...store.values()].map((v) => v.agent).sort()).toEqual(['x', 'y'])
121
+ expect(store.delete('a')).toBe(true)
122
+ expect(store.has('a')).toBe(false)
123
+ })
124
+ })