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.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- 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
|
+
})
|