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.
- 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 +359 -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 +2 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +368 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +55 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- 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
|
-
|
|
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
|
-
//
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
//
|
|
3216
|
-
//
|
|
3217
|
-
//
|
|
3218
|
-
//
|
|
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
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
+
}
|