switchroom 0.12.26 → 0.12.28

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 (57) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/skill-validate-pretool.mjs +72 -72
  5. package/dist/cli/switchroom.js +359 -357
  6. package/dist/host-control/main.js +99 -99
  7. package/dist/vault/approvals/kernel-server.js +82 -82
  8. package/dist/vault/broker/server.js +83 -83
  9. package/package.json +2 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  11. package/telegram-plugin/dist/gateway/gateway.js +368 -209
  12. package/telegram-plugin/dist/server.js +160 -160
  13. package/telegram-plugin/gateway/gateway.ts +55 -40
  14. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
  15. package/telegram-plugin/stderr-timestamps.ts +106 -0
  16. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
  17. package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
  18. package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
  19. package/vendor/hindsight-memory/CHANGELOG.md +32 -0
  20. package/vendor/hindsight-memory/LICENSE +21 -0
  21. package/vendor/hindsight-memory/README.md +329 -0
  22. package/vendor/hindsight-memory/hooks/hooks.json +49 -0
  23. package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
  24. package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
  25. package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
  26. package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
  27. package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
  28. package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
  29. package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
  30. package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
  31. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
  32. package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
  33. package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
  34. package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +873 -0
  36. package/vendor/hindsight-memory/scripts/retain.py +286 -0
  37. package/vendor/hindsight-memory/scripts/session_end.py +122 -0
  38. package/vendor/hindsight-memory/scripts/session_start.py +76 -0
  39. package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
  40. package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
  44. package/vendor/hindsight-memory/settings.json +37 -0
  45. package/vendor/hindsight-memory/skills/setup.md +24 -0
  46. package/vendor/hindsight-memory/tests/conftest.py +94 -0
  47. package/vendor/hindsight-memory/tests/test_bank.py +142 -0
  48. package/vendor/hindsight-memory/tests/test_client.py +232 -0
  49. package/vendor/hindsight-memory/tests/test_config.py +128 -0
  50. package/vendor/hindsight-memory/tests/test_content.py +471 -0
  51. package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
  52. package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
  53. package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
  54. package/vendor/hindsight-memory/tests/test_pending.py +152 -0
  55. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
  56. package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
  57. package/vendor/hindsight-memory/tests/test_state.py +125 -0
@@ -23,6 +23,7 @@ import { homedir } from 'os'
23
23
  import { join, extname, sep, basename } from 'path'
24
24
 
25
25
  import { installPluginLogger } from '../plugin-logger.js'
26
+ import { installStderrTimestamps } from '../stderr-timestamps.js'
26
27
  import { decideDmCommandGate } from '../dm-command-gate.js'
27
28
  import { redactAuthCodeMessage } from '../auth-code-redact.js'
28
29
  import {
@@ -261,6 +262,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
261
262
  // effects.
262
263
  import { shadowEmit } from './inbound-delivery-machine-shadow.js'
263
264
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
265
+ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
264
266
  import {
265
267
  buildVaultGrantApprovedInbound,
266
268
  buildVaultGrantDeniedInbound,
@@ -379,6 +381,10 @@ import { formatIdleFooter } from '../idle-footer.js'
379
381
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
380
382
 
381
383
  // ─── Stderr logging ───────────────────────────────────────────────────────
384
+ // Install the line-stamper FIRST so it wraps closest to the original
385
+ // stderr.write. plugin-logger's file mirror then sees the timestamped text.
386
+ // Kill switch: SWITCHROOM_LOG_TIMESTAMPS=0 disables.
387
+ installStderrTimestamps()
382
388
  installPluginLogger()
383
389
 
384
390
  // ─── Telemetry ────────────────────────────────────────────────────────────
@@ -3204,50 +3210,59 @@ const ipcServer: IpcServer = createIpcServer({
3204
3210
  // causing the shadow state to read `bridge_dead` even when the
3205
3211
  // real bridge was healthy, because every recall.py connect+disconnect
3206
3212
  // would flip the state.
3207
- if (client.agentName != null) {
3208
- shadowEmit({ kind: 'bridgeUp', at: Date.now() })
3209
- }
3213
+ const bridgeUpEffects = client.agentName != null
3214
+ ? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
3215
+ : []
3210
3216
  client.send({ type: 'status', status: 'agent_connected' })
3211
3217
 
3212
- // #1150: drain any synthetic inbounds queued for this agent while
3213
- // the bridge was offline. Done BEFORE the boot-card path below so
3214
- // the agent wakes up to its missed wake-ups first, even if the
3215
- // boot card edit happens to be slow. Skip drain when agentName is
3216
- // null (pre-handshake / anonymous client) those clients are
3217
- // bridges that never registered an identity and can't have
3218
- // accumulated buffered inbounds keyed by name.
3218
+ // Phase 2b PR 3a bridgeUp cutover. The state machine's `bridgeUp`
3219
+ // transition returns `redeliverPersistedPermVerdicts`, `drainBuffer`,
3220
+ // and `logTrace` effects. The dispatcher executes them in order:
3221
+ // the perm-verdict drain unblocks any claude turn suspended inside
3222
+ // an MCP permission call; the inbound drain flushes synthetic
3223
+ // inbounds queued while the bridge was offline (#1150) so the agent
3224
+ // wakes up to missed wake-ups before the boot-card path below runs.
3225
+ // Lossless: `redeliverBufferedInbound` re-buffers per-message misses
3226
+ // and `spool.ack` tombstones confirmed deliveries. Skipped when
3227
+ // agentName is null (pre-handshake / anonymous client — those
3228
+ // bridges never registered an identity and can't have accumulated
3229
+ // buffered inbounds keyed by name).
3219
3230
  if (client.agentName != null) {
3220
- const pending = pendingInboundBuffer.drain(client.agentName)
3221
- for (const msg of pending) {
3222
- try {
3223
- client.send(msg)
3224
- // Confirmed delivery to the just-registered live bridge →
3225
- // tombstone the durable spool entry so it isn't boot-replayed
3226
- // again. A throw below leaves it spooled (un-acked) so the
3227
- // idle-drain / escalation path still recovers it — strictly
3228
- // safer than the old log-and-drop.
3229
- inboundSpool?.ack(msg)
3230
- } catch (err) {
3231
- process.stderr.write(
3232
- `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
3233
- `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
3234
- )
3231
+ if (isDispatchEnabled()) {
3232
+ dispatchEffects(bridgeUpEffects, {
3233
+ selfAgent: client.agentName,
3234
+ ipcServer,
3235
+ pendingInboundBuffer,
3236
+ inboundSpool: inboundSpool ?? null,
3237
+ pendingPermissionBuffer,
3238
+ client,
3239
+ })
3240
+ } else {
3241
+ // Kill-switch fallback: imperative drain (parity with pre-cutover
3242
+ // behavior). Kept for SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0
3243
+ // rollback safety; deleted in PR 4 once the cutover bakes.
3244
+ const pending = pendingInboundBuffer.drain(client.agentName)
3245
+ for (const msg of pending) {
3246
+ try {
3247
+ client.send(msg)
3248
+ inboundSpool?.ack(msg)
3249
+ } catch (err) {
3250
+ process.stderr.write(
3251
+ `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
3252
+ `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
3253
+ )
3254
+ }
3235
3255
  }
3236
- }
3237
- // PR-3: drain permission verdicts missed while the bridge was
3238
- // offline. A claude turn suspended inside the MCP permission call
3239
- // is unblocked the moment the reconnecting bridge relays the
3240
- // verdict; without this the verdict (incl. the TTL auto-deny) was
3241
- // lost and the turn stayed silent forever.
3242
- const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
3243
- for (const ev of pendingVerdicts) {
3244
- try {
3245
- client.send(ev)
3246
- } catch (err) {
3247
- process.stderr.write(
3248
- `telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
3249
- `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
3250
- )
3256
+ const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
3257
+ for (const ev of pendingVerdicts) {
3258
+ try {
3259
+ client.send(ev)
3260
+ } catch (err) {
3261
+ process.stderr.write(
3262
+ `telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
3263
+ `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
3264
+ )
3265
+ }
3251
3266
  }
3252
3267
  }
3253
3268
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * InboundDeliveryStateMachine — DISPATCH (Phase 2b PR 3a, bridgeUp cutover).
3
+ *
4
+ * Per RFC `docs/rfcs/inbound-delivery-state-machine.md`, the state
5
+ * machine is pure: `transition(state, event) → { state', effects[] }`.
6
+ * The gateway's job is to (a) emit events at the right moments and
7
+ * (b) execute the returned effects against real I/O. This module owns
8
+ * step (b) for the cutover.
9
+ *
10
+ * Scope of THIS PR — bridgeUp only:
11
+ * - drainBuffer → executed
12
+ * - redeliverPersistedPermVerdicts → executed
13
+ * - logTrace → executed
14
+ *
15
+ * Other effects (deliverToBridge, bufferInbound, persistInbound,
16
+ * setTurnStarted, clearTurnStarted, noteOutbound, firePoke,
17
+ * deliverPermVerdict, persistPermVerdict) still flow through their
18
+ * existing imperative paths in `gateway.ts`. The dispatcher logs them
19
+ * as `not-yet-cutover` so a future PR can wire them without grep-and-
20
+ * pray. NEVER silently no-op: the trace is the gate.
21
+ *
22
+ * Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
23
+ * dispatcher execution and the gateway falls back to imperative-only.
24
+ * Default is ON — this PR is the cutover.
25
+ */
26
+
27
+ import type {
28
+ Effect,
29
+ InboundMessage as MachineInboundMessage,
30
+ } from './inbound-delivery-machine.js'
31
+ import type { IpcServer, IpcClient } from './ipc-server.js'
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+ import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
34
+ import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
35
+ import type { InboundSpool } from './inbound-spool.js'
36
+ import type { PendingPermissionBuffer } from './pending-permission-decisions.js'
37
+
38
+ export interface DispatchCtx {
39
+ readonly selfAgent: string
40
+ readonly ipcServer: IpcServer
41
+ readonly pendingInboundBuffer: PendingInboundBuffer
42
+ readonly inboundSpool: InboundSpool | null
43
+ readonly pendingPermissionBuffer: PendingPermissionBuffer
44
+ /** Optional: when set, prefer sending direct to this client (bridgeUp path). */
45
+ readonly client?: IpcClient
46
+ /** Optional log sink — default stderr. Test hook. */
47
+ readonly log?: (line: string) => void
48
+ }
49
+
50
+ const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
51
+
52
+ export function isDispatchEnabled(): boolean {
53
+ return enabled
54
+ }
55
+
56
+ /**
57
+ * Execute the effects returned by `transition()`. Pure imperative
58
+ * driver: side-effects only, no machine state held here.
59
+ *
60
+ * Effects are dispatched in the order returned by the machine. The
61
+ * machine guarantees a sensible order (e.g., `setTurnStarted` before
62
+ * `deliverToBridge` so a downstream observer sees the turn marker
63
+ * already up).
64
+ */
65
+ export function dispatchEffects(effects: readonly Effect[], ctx: DispatchCtx): void {
66
+ if (!enabled) return
67
+ for (const effect of effects) {
68
+ dispatchOne(effect, ctx)
69
+ }
70
+ }
71
+
72
+ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
73
+ const log = ctx.log ?? ((line: string) => process.stderr.write(line))
74
+ switch (effect.kind) {
75
+ case 'drainBuffer': {
76
+ // The bridgeUp drain: flush any synthetic inbounds queued for
77
+ // this agent while the bridge was offline. Mirrors what
78
+ // onClientRegistered did inline pre-cutover (gateway.ts:3219-3236).
79
+ // Lossless: redeliverBufferedInbound re-buffers any per-message
80
+ // miss and tombstones the durable spool entry on confirmed
81
+ // delivery.
82
+ const send = (msg: InboundMessage): boolean => {
83
+ // Prefer the just-registered client when present (bridgeUp
84
+ // path) — that's a direct send. Fall back to ipcServer
85
+ // lookup for non-client-bound drains (turn-complete flush
86
+ // from a future PR).
87
+ if (ctx.client) {
88
+ try {
89
+ ctx.client.send(msg)
90
+ return true
91
+ } catch (err) {
92
+ log(
93
+ `telegram gateway: dispatch drainBuffer client.send threw agent=${ctx.selfAgent} ` +
94
+ `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
95
+ )
96
+ return false
97
+ }
98
+ }
99
+ return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
100
+ }
101
+ const result = redeliverBufferedInbound(
102
+ ctx.pendingInboundBuffer,
103
+ ctx.selfAgent,
104
+ send,
105
+ ctx.inboundSpool ?? undefined,
106
+ )
107
+ if (result.drained > 0) {
108
+ log(
109
+ `telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` +
110
+ `drained=${result.drained} redelivered=${result.redelivered} ` +
111
+ `rebuffered=${result.rebuffered}\n`,
112
+ )
113
+ }
114
+ return
115
+ }
116
+
117
+ case 'redeliverPersistedPermVerdicts': {
118
+ // Drain permission verdicts missed while the bridge was offline.
119
+ // A claude turn suspended inside the MCP permission call is
120
+ // unblocked the moment the reconnecting bridge relays the
121
+ // verdict. Mirrors the pre-cutover loop at gateway.ts:3242-3252.
122
+ const pending = ctx.pendingPermissionBuffer.drain(ctx.selfAgent)
123
+ let delivered = 0
124
+ let failed = 0
125
+ for (const ev of pending) {
126
+ try {
127
+ if (ctx.client) {
128
+ ctx.client.send(ev)
129
+ } else {
130
+ ctx.ipcServer.sendToAgent(ctx.selfAgent, ev)
131
+ }
132
+ delivered++
133
+ } catch (err) {
134
+ failed++
135
+ log(
136
+ `telegram gateway: dispatch redeliverPerm send threw agent=${ctx.selfAgent} ` +
137
+ `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
138
+ )
139
+ }
140
+ }
141
+ if (pending.length > 0) {
142
+ log(
143
+ `telegram gateway: dispatch redeliverPerm agent=${ctx.selfAgent} ` +
144
+ `delivered=${delivered} failed=${failed}\n`,
145
+ )
146
+ }
147
+ return
148
+ }
149
+
150
+ case 'logTrace': {
151
+ const keyPart = effect.key ? ` key=${effect.key}` : ''
152
+ const metaPart = effect.metadata
153
+ ? ' ' + Object.entries(effect.metadata).map(([k, v]) => `${k}=${String(v)}`).join(' ')
154
+ : ''
155
+ log(`gw-trace dispatch stage=${effect.stage}${keyPart}${metaPart}\n`)
156
+ return
157
+ }
158
+
159
+ // The cases below are KNOWN effect kinds that this PR does NOT
160
+ // cut over. The imperative paths still run for them; the
161
+ // dispatcher logs the event so future cutover PRs can grep for
162
+ // exactly the call sites to migrate.
163
+ case 'deliverToBridge':
164
+ case 'bufferInbound':
165
+ case 'persistInbound':
166
+ case 'setTurnStarted':
167
+ case 'clearTurnStarted':
168
+ case 'noteOutbound':
169
+ case 'firePoke':
170
+ case 'deliverPermVerdict':
171
+ case 'persistPermVerdict': {
172
+ log(`gw-trace dispatch not-yet-cutover effect=${effect.kind}\n`)
173
+ return
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Test hook: invoke the per-effect dispatcher directly so unit tests
180
+ * can drive individual effects against mocks without constructing a
181
+ * full effects array.
182
+ */
183
+ export function __dispatchOneForTests(effect: Effect, ctx: DispatchCtx): void {
184
+ dispatchOne(effect, ctx)
185
+ }
186
+
187
+ /** Type re-export — convenience for test fixtures. */
188
+ export type { MachineInboundMessage }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-line timestamp wrapper for `process.stderr.write`.
3
+ *
4
+ * The gateway's stderr is captured to `/var/log/switchroom/gateway-supervisor.log`
5
+ * by `start.sh`'s `_switchroom_supervise` redirect. The capture has NO
6
+ * line-level timestamps, which makes it impossible to measure the gap between
7
+ * events (e.g., `bridge registered` → first `dispatch stage=bridge_recover` →
8
+ * first `tg-post method=sendMessage`). Without those gaps the cold-start TTFO
9
+ * RFC's optimization claims (PR #1589) are unverifiable.
10
+ *
11
+ * This module installs a one-time wrapper on `process.stderr.write` that
12
+ * prepends an ISO-8601 timestamp (`[YYYY-MM-DDTHH:MM:SS.mmmZ]`) at the start
13
+ * of each logical line. Line-buffered: partial writes that don't end in `\n`
14
+ * are buffered until they do. Newlines mid-chunk split the chunk into
15
+ * multiple timestamped lines.
16
+ *
17
+ * Layered separately from `plugin-logger.ts`'s file mirror so each can be
18
+ * toggled independently. Order at install time: this wrapper runs FIRST
19
+ * (closest to the original write), then plugin-logger's file mirror sees
20
+ * the timestamped text.
21
+ *
22
+ * Kill switch: `SWITCHROOM_LOG_TIMESTAMPS=0` disables. Default ON.
23
+ */
24
+
25
+ let installed = false
26
+ let originalWrite: typeof process.stderr.write | null = null
27
+ let partialBuffer = ''
28
+
29
+ function isoTimestamp(): string {
30
+ return new Date().toISOString()
31
+ }
32
+
33
+ /**
34
+ * Wrap `process.stderr.write` to prepend an ISO timestamp at each line
35
+ * boundary. Idempotent — second call is a no-op.
36
+ *
37
+ * Returns true when the wrapper was installed (or was already), false when
38
+ * the kill-switch env var disabled it.
39
+ */
40
+ export function installStderrTimestamps(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ if (env.SWITCHROOM_LOG_TIMESTAMPS === '0') return false
42
+ if (installed) return true
43
+
44
+ const origin = process.stderr.write.bind(process.stderr)
45
+ originalWrite = origin as typeof process.stderr.write
46
+
47
+ const wrapped = function write(
48
+ chunk: string | Uint8Array,
49
+ encodingOrCb?: BufferEncoding | ((err?: Error) => void),
50
+ cb?: (err?: Error) => void,
51
+ ): boolean {
52
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
53
+ const stamped = stampLines(text)
54
+ return (origin as (c: unknown, e?: unknown, cb?: unknown) => boolean)(
55
+ stamped,
56
+ encodingOrCb,
57
+ cb,
58
+ )
59
+ } as typeof process.stderr.write
60
+
61
+ process.stderr.write = wrapped
62
+ installed = true
63
+ return true
64
+ }
65
+
66
+ /**
67
+ * Internal: split `text` into lines, prepend `[ISO] ` to each complete
68
+ * line, leave any trailing partial line buffered for the next call.
69
+ *
70
+ * Exported for tests only.
71
+ */
72
+ export function stampLines(text: string, now: () => string = isoTimestamp): string {
73
+ if (text === '') return ''
74
+
75
+ let out = ''
76
+ let i = 0
77
+ while (i < text.length) {
78
+ const nl = text.indexOf('\n', i)
79
+ if (nl === -1) {
80
+ // No more newlines in this chunk — buffer the rest.
81
+ partialBuffer += text.slice(i)
82
+ break
83
+ }
84
+ // We have a complete line: anything in partialBuffer + slice up to \n.
85
+ const line = partialBuffer + text.slice(i, nl + 1)
86
+ partialBuffer = ''
87
+ out += `[${now()}] ${line}`
88
+ i = nl + 1
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** Test hook: reset module state. */
94
+ export function __resetForTests(): void {
95
+ if (installed && originalWrite) {
96
+ process.stderr.write = originalWrite
97
+ }
98
+ installed = false
99
+ originalWrite = null
100
+ partialBuffer = ''
101
+ }
102
+
103
+ /** Test hook: read the current partial buffer (debug-only). */
104
+ export function __getPartialBufferForTests(): string {
105
+ return partialBuffer
106
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Unit tests for `inbound-delivery-machine-dispatch.ts`.
3
+ *
4
+ * Per RFC PR 3 cutover: the dispatcher executes effects against real
5
+ * primitives. These tests pin the contract per effect kind — which
6
+ * primitive gets called, with what args, exactly once. The gate that
7
+ * catches a double-drain regression at the imperative call sites.
8
+ *
9
+ * Scope of THIS file matches the PR's scope: bridgeUp's three effects
10
+ * (drainBuffer, redeliverPersistedPermVerdicts, logTrace) are wired
11
+ * and tested end-to-end. Other effect kinds are asserted to log a
12
+ * `not-yet-cutover` trace without throwing — those are the future
13
+ * cutover seam.
14
+ */
15
+
16
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
17
+ import {
18
+ __dispatchOneForTests,
19
+ dispatchEffects,
20
+ isDispatchEnabled,
21
+ } from '../gateway/inbound-delivery-machine-dispatch'
22
+ import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
23
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
24
+ import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
25
+ import type { Effect } from '../gateway/inbound-delivery-machine'
26
+
27
+ function makeCtx(overrides?: Partial<DispatchCtx>): {
28
+ ctx: DispatchCtx
29
+ logs: string[]
30
+ sendToAgent: ReturnType<typeof vi.fn>
31
+ clientSend: ReturnType<typeof vi.fn>
32
+ inbound: ReturnType<typeof createPendingInboundBuffer>
33
+ perm: ReturnType<typeof createPendingPermissionBuffer>
34
+ } {
35
+ const logs: string[] = []
36
+ const sendToAgent = vi.fn(() => true)
37
+ const clientSend = vi.fn()
38
+ const inbound = createPendingInboundBuffer()
39
+ const perm = createPendingPermissionBuffer()
40
+ const ctx: DispatchCtx = {
41
+ selfAgent: 'test-agent',
42
+ ipcServer: { sendToAgent } as never,
43
+ pendingInboundBuffer: inbound,
44
+ inboundSpool: null,
45
+ pendingPermissionBuffer: perm,
46
+ client: { send: clientSend, agentName: 'test-agent', id: 'c1' } as never,
47
+ log: (line: string) => logs.push(line),
48
+ ...overrides,
49
+ }
50
+ return { ctx, logs, sendToAgent, clientSend, inbound, perm }
51
+ }
52
+
53
+ describe('isDispatchEnabled', () => {
54
+ it('defaults to true when the kill-switch env-var is unset', () => {
55
+ expect(isDispatchEnabled()).toBe(true)
56
+ })
57
+ })
58
+
59
+ describe('dispatchEffects — drainBuffer', () => {
60
+ it('redelivers buffered inbound to the registered client', () => {
61
+ const { ctx, clientSend, inbound } = makeCtx()
62
+ const msg1 = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hello', meta: { source: 'telegram' } }
63
+ const msg2 = { type: 'inbound' as const, id: 'm2', chat_id: '1', text: 'world', meta: { source: 'cron' } }
64
+ inbound.push('test-agent', msg1 as never)
65
+ inbound.push('test-agent', msg2 as never)
66
+ expect(inbound.depth('test-agent')).toBe(2)
67
+
68
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
69
+
70
+ expect(clientSend).toHaveBeenCalledTimes(2)
71
+ expect(clientSend).toHaveBeenNthCalledWith(1, msg1)
72
+ expect(clientSend).toHaveBeenNthCalledWith(2, msg2)
73
+ expect(inbound.depth('test-agent')).toBe(0)
74
+ })
75
+
76
+ it('no-ops on empty buffer (no log, no send)', () => {
77
+ const { ctx, clientSend, logs, inbound } = makeCtx()
78
+ expect(inbound.depth('test-agent')).toBe(0)
79
+
80
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
81
+
82
+ expect(clientSend).not.toHaveBeenCalled()
83
+ expect(logs.filter((l) => l.includes('drainBuffer'))).toHaveLength(0)
84
+ })
85
+
86
+ it('falls back to ipcServer.sendToAgent when no client is set', () => {
87
+ const { ctx, sendToAgent, clientSend, inbound } = makeCtx({ client: undefined })
88
+ const msg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'cron' } }
89
+ inbound.push('test-agent', msg as never)
90
+
91
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
92
+
93
+ expect(clientSend).not.toHaveBeenCalled()
94
+ expect(sendToAgent).toHaveBeenCalledTimes(1)
95
+ expect(sendToAgent).toHaveBeenCalledWith('test-agent', msg)
96
+ })
97
+ })
98
+
99
+ describe('dispatchEffects — redeliverPersistedPermVerdicts', () => {
100
+ it('drains pendingPermissionBuffer to the client', () => {
101
+ const { ctx, clientSend, perm } = makeCtx()
102
+ const v1 = {
103
+ type: 'permission_decision' as const,
104
+ requestId: 'r1',
105
+ behavior: 'allow' as const,
106
+ updatedInput: {},
107
+ timestamp: 0,
108
+ }
109
+ const v2 = {
110
+ type: 'permission_decision' as const,
111
+ requestId: 'r2',
112
+ behavior: 'deny' as const,
113
+ updatedInput: {},
114
+ timestamp: 0,
115
+ }
116
+ perm.push('test-agent', v1 as never)
117
+ perm.push('test-agent', v2 as never)
118
+
119
+ dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
120
+
121
+ expect(clientSend).toHaveBeenCalledTimes(2)
122
+ expect(clientSend).toHaveBeenNthCalledWith(1, v1)
123
+ expect(clientSend).toHaveBeenNthCalledWith(2, v2)
124
+ })
125
+
126
+ it('no-ops on empty perm buffer', () => {
127
+ const { ctx, clientSend, logs } = makeCtx()
128
+ dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
129
+ expect(clientSend).not.toHaveBeenCalled()
130
+ expect(logs.filter((l) => l.includes('redeliverPerm'))).toHaveLength(0)
131
+ })
132
+ })
133
+
134
+ describe('dispatchEffects — logTrace', () => {
135
+ it('writes a single grep-friendly line', () => {
136
+ const { ctx, logs } = makeCtx()
137
+ dispatchEffects(
138
+ [{ kind: 'logTrace', stage: 'bridge_recover', metadata: { foo: 'bar' } }],
139
+ ctx,
140
+ )
141
+ expect(logs).toHaveLength(1)
142
+ expect(logs[0]).toContain('gw-trace dispatch stage=bridge_recover')
143
+ expect(logs[0]).toContain('foo=bar')
144
+ })
145
+ })
146
+
147
+ describe('dispatchEffects — bridgeUp composite (all three effects in order)', () => {
148
+ it('drains perm verdicts THEN inbound buffer THEN logs', () => {
149
+ const { ctx, clientSend, inbound, perm, logs } = makeCtx()
150
+ const verdict = {
151
+ type: 'permission_decision' as const,
152
+ requestId: 'rA',
153
+ behavior: 'allow' as const,
154
+ updatedInput: {},
155
+ timestamp: 0,
156
+ }
157
+ const inMsg = { type: 'inbound' as const, id: 'mB', chat_id: '1', text: 't', meta: { source: 'cron' } }
158
+ perm.push('test-agent', verdict as never)
159
+ inbound.push('test-agent', inMsg as never)
160
+
161
+ // Effects in the order the machine returns them for bridgeUp.
162
+ dispatchEffects(
163
+ [
164
+ { kind: 'redeliverPersistedPermVerdicts' },
165
+ { kind: 'drainBuffer' },
166
+ { kind: 'logTrace', stage: 'bridge_recover' },
167
+ ],
168
+ ctx,
169
+ )
170
+
171
+ expect(clientSend).toHaveBeenCalledTimes(2)
172
+ expect(clientSend).toHaveBeenNthCalledWith(1, verdict)
173
+ expect(clientSend).toHaveBeenNthCalledWith(2, inMsg)
174
+ expect(logs.some((l) => l.includes('stage=bridge_recover'))).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe('dispatchEffects — kill switch', () => {
179
+ // The kill switch is checked at module-load time so we can't toggle
180
+ // it inside the test process easily. We validate the contract that
181
+ // the helper exists and respects the env-var — see isDispatchEnabled.
182
+ it('exposes the kill-switch check', () => {
183
+ expect(typeof isDispatchEnabled()).toBe('boolean')
184
+ })
185
+ })
186
+
187
+ describe('dispatchOne — not-yet-cutover effects log without throwing', () => {
188
+ const notYet: Effect['kind'][] = [
189
+ 'deliverToBridge',
190
+ 'bufferInbound',
191
+ 'persistInbound',
192
+ 'setTurnStarted',
193
+ 'clearTurnStarted',
194
+ 'noteOutbound',
195
+ 'firePoke',
196
+ 'deliverPermVerdict',
197
+ 'persistPermVerdict',
198
+ ]
199
+
200
+ for (const kind of notYet) {
201
+ it(`'${kind}' logs not-yet-cutover and does NOT touch primitives`, () => {
202
+ const { ctx, sendToAgent, clientSend, logs } = makeCtx()
203
+ // Construct a minimal valid effect for each kind. Most carry a
204
+ // payload that the dispatcher ignores in this PR.
205
+ const effect = synthesizeNotYetCutoverEffect(kind)
206
+ expect(() => __dispatchOneForTests(effect, ctx)).not.toThrow()
207
+ expect(sendToAgent).not.toHaveBeenCalled()
208
+ expect(clientSend).not.toHaveBeenCalled()
209
+ expect(logs.some((l) => l.includes(`not-yet-cutover effect=${kind}`))).toBe(true)
210
+ })
211
+ }
212
+ })
213
+
214
+ function synthesizeNotYetCutoverEffect(kind: Effect['kind']): Effect {
215
+ const k = 'c1:_' as never
216
+ const msg = { msgId: 1, isSteering: false, payload: null } as never
217
+ const verdict = { requestId: 'r', behavior: 'allow' as const, payload: null } as never
218
+ switch (kind) {
219
+ case 'deliverToBridge':
220
+ return { kind, key: k, msg }
221
+ case 'bufferInbound':
222
+ return { kind, key: k, msg }
223
+ case 'persistInbound':
224
+ return { kind, key: k, msg }
225
+ case 'setTurnStarted':
226
+ return { kind, key: k, at: 0 }
227
+ case 'clearTurnStarted':
228
+ return { kind, key: k }
229
+ case 'noteOutbound':
230
+ return { kind, key: k, at: 0 }
231
+ case 'firePoke':
232
+ return { kind, key: k, level: 'fallback' }
233
+ case 'deliverPermVerdict':
234
+ return { kind, verdict }
235
+ case 'persistPermVerdict':
236
+ return { kind, verdict }
237
+ default:
238
+ throw new Error(`unreachable: ${kind}`)
239
+ }
240
+ }