switchroom 0.12.26 → 0.12.27
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/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +357 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +324 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +50 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
|
@@ -261,6 +261,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
|
261
261
|
// effects.
|
|
262
262
|
import { shadowEmit } from './inbound-delivery-machine-shadow.js'
|
|
263
263
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
264
|
+
import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
|
|
264
265
|
import {
|
|
265
266
|
buildVaultGrantApprovedInbound,
|
|
266
267
|
buildVaultGrantDeniedInbound,
|
|
@@ -3204,50 +3205,59 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3204
3205
|
// causing the shadow state to read `bridge_dead` even when the
|
|
3205
3206
|
// real bridge was healthy, because every recall.py connect+disconnect
|
|
3206
3207
|
// would flip the state.
|
|
3207
|
-
|
|
3208
|
-
shadowEmit({ kind: 'bridgeUp', at: Date.now() })
|
|
3209
|
-
|
|
3208
|
+
const bridgeUpEffects = client.agentName != null
|
|
3209
|
+
? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
|
|
3210
|
+
: []
|
|
3210
3211
|
client.send({ type: 'status', status: 'agent_connected' })
|
|
3211
3212
|
|
|
3212
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
3213
|
+
// Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
|
|
3214
|
+
// transition returns `redeliverPersistedPermVerdicts`, `drainBuffer`,
|
|
3215
|
+
// and `logTrace` effects. The dispatcher executes them in order:
|
|
3216
|
+
// the perm-verdict drain unblocks any claude turn suspended inside
|
|
3217
|
+
// an MCP permission call; the inbound drain flushes synthetic
|
|
3218
|
+
// inbounds queued while the bridge was offline (#1150) so the agent
|
|
3219
|
+
// wakes up to missed wake-ups before the boot-card path below runs.
|
|
3220
|
+
// Lossless: `redeliverBufferedInbound` re-buffers per-message misses
|
|
3221
|
+
// and `spool.ack` tombstones confirmed deliveries. Skipped when
|
|
3222
|
+
// agentName is null (pre-handshake / anonymous client — those
|
|
3223
|
+
// bridges never registered an identity and can't have accumulated
|
|
3224
|
+
// buffered inbounds keyed by name).
|
|
3219
3225
|
if (client.agentName != null) {
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3226
|
+
if (isDispatchEnabled()) {
|
|
3227
|
+
dispatchEffects(bridgeUpEffects, {
|
|
3228
|
+
selfAgent: client.agentName,
|
|
3229
|
+
ipcServer,
|
|
3230
|
+
pendingInboundBuffer,
|
|
3231
|
+
inboundSpool: inboundSpool ?? null,
|
|
3232
|
+
pendingPermissionBuffer,
|
|
3233
|
+
client,
|
|
3234
|
+
})
|
|
3235
|
+
} else {
|
|
3236
|
+
// Kill-switch fallback: imperative drain (parity with pre-cutover
|
|
3237
|
+
// behavior). Kept for SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0
|
|
3238
|
+
// rollback safety; deleted in PR 4 once the cutover bakes.
|
|
3239
|
+
const pending = pendingInboundBuffer.drain(client.agentName)
|
|
3240
|
+
for (const msg of pending) {
|
|
3241
|
+
try {
|
|
3242
|
+
client.send(msg)
|
|
3243
|
+
inboundSpool?.ack(msg)
|
|
3244
|
+
} catch (err) {
|
|
3245
|
+
process.stderr.write(
|
|
3246
|
+
`telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
|
|
3247
|
+
`source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
|
|
3248
|
+
)
|
|
3249
|
+
}
|
|
3235
3250
|
}
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
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
|
-
)
|
|
3251
|
+
const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
|
|
3252
|
+
for (const ev of pendingVerdicts) {
|
|
3253
|
+
try {
|
|
3254
|
+
client.send(ev)
|
|
3255
|
+
} catch (err) {
|
|
3256
|
+
process.stderr.write(
|
|
3257
|
+
`telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
|
|
3258
|
+
`request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
|
|
3259
|
+
)
|
|
3260
|
+
}
|
|
3251
3261
|
}
|
|
3252
3262
|
}
|
|
3253
3263
|
}
|
|
@@ -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,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
|
+
}
|