switchroom 0.16.29 → 0.16.38
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 +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60588 -59754
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { createPermissionCardStore } from '../gateway/permission-card-store.js'
|
|
6
|
+
|
|
7
|
+
function makeTmpDir() {
|
|
8
|
+
return mkdtempSync(join(tmpdir(), 'perm-card-store-test-'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('createPermissionCardStore', () => {
|
|
12
|
+
let dir: string
|
|
13
|
+
|
|
14
|
+
beforeEach(() => { dir = makeTmpDir() })
|
|
15
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
16
|
+
|
|
17
|
+
it('starts empty', () => {
|
|
18
|
+
const store = createPermissionCardStore(dir)
|
|
19
|
+
expect(store.loadAll()).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('add and loadAll returns the entry', () => {
|
|
23
|
+
const store = createPermissionCardStore(dir)
|
|
24
|
+
const entry = {
|
|
25
|
+
requestId: 'aabcd',
|
|
26
|
+
chatId: '12345',
|
|
27
|
+
messageId: 999,
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
toolName: 'mcp__shell__run',
|
|
30
|
+
cardText: 'Run shell command',
|
|
31
|
+
}
|
|
32
|
+
store.add(entry)
|
|
33
|
+
expect(store.loadAll()).toEqual([entry])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('add multiple entries for same requestId (different messageIds)', () => {
|
|
37
|
+
const store = createPermissionCardStore(dir)
|
|
38
|
+
store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
39
|
+
store.add({ requestId: 'aabcd', chatId: '222', messageId: 2, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
40
|
+
const all = store.loadAll()
|
|
41
|
+
expect(all.length).toBe(2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not duplicate same requestId+messageId', () => {
|
|
45
|
+
const store = createPermissionCardStore(dir)
|
|
46
|
+
const entry = { requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' }
|
|
47
|
+
store.add(entry)
|
|
48
|
+
store.add(entry)
|
|
49
|
+
expect(store.loadAll().length).toBe(1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('remove clears all entries for a requestId', () => {
|
|
53
|
+
const store = createPermissionCardStore(dir)
|
|
54
|
+
store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
55
|
+
store.add({ requestId: 'aabcd', chatId: '222', messageId: 2, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
56
|
+
store.add({ requestId: 'xyzab', chatId: '333', messageId: 3, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
57
|
+
store.remove('aabcd')
|
|
58
|
+
const all = store.loadAll()
|
|
59
|
+
expect(all.length).toBe(1)
|
|
60
|
+
expect(all[0].requestId).toBe('xyzab')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('remove is idempotent when entry not present', () => {
|
|
64
|
+
const store = createPermissionCardStore(dir)
|
|
65
|
+
expect(() => store.remove('nonexistent')).not.toThrow()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('clear empties the store', () => {
|
|
69
|
+
const store = createPermissionCardStore(dir)
|
|
70
|
+
store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
|
|
71
|
+
store.clear()
|
|
72
|
+
expect(store.loadAll()).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('survives re-instantiation (data persists across instances)', () => {
|
|
76
|
+
createPermissionCardStore(dir).add({
|
|
77
|
+
requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c',
|
|
78
|
+
})
|
|
79
|
+
const all = createPermissionCardStore(dir).loadAll()
|
|
80
|
+
expect(all.length).toBe(1)
|
|
81
|
+
expect(all[0].requestId).toBe('aabcd')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
timeoutDenyMessage,
|
|
14
14
|
duplicateDenyMessage,
|
|
15
15
|
isRecentTimeoutDuplicate,
|
|
16
|
+
approvalTtlMs,
|
|
16
17
|
PERMISSION_TTL_MS,
|
|
18
|
+
PERMISSION_TTL_DEFAULT_MS,
|
|
17
19
|
HOSTD_PERMISSION_TTL_MS,
|
|
18
20
|
ttlForTool,
|
|
19
21
|
buildTimedOutCardEdits,
|
|
@@ -22,6 +24,35 @@ import {
|
|
|
22
24
|
TIMED_OUT_FOOTER,
|
|
23
25
|
} from '../gateway/permission-timeout.js'
|
|
24
26
|
|
|
27
|
+
describe('approvalTtlMs (config-driven approval-card lifetime)', () => {
|
|
28
|
+
it('defaults to 60 minutes when unset', () => {
|
|
29
|
+
expect(approvalTtlMs({})).toBe(60 * 60_000)
|
|
30
|
+
expect(PERMISSION_TTL_DEFAULT_MS).toBe(60 * 60_000)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('honours a configured value (threaded as SWITCHROOM_TG_APPROVAL_TIMEOUT_MS ms)', () => {
|
|
34
|
+
expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '1800000' })).toBe(1_800_000)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('falls back to the 60-min default on blank / garbage / non-positive', () => {
|
|
38
|
+
const d = 60 * 60_000
|
|
39
|
+
expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '' })).toBe(d)
|
|
40
|
+
expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: 'abc' })).toBe(d)
|
|
41
|
+
expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '0' })).toBe(d)
|
|
42
|
+
expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '-5' })).toBe(d)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('the default permission-card lifetime is at least 60 min', () => {
|
|
46
|
+
// With no env override at module load, PERMISSION_TTL_MS is the default.
|
|
47
|
+
expect(PERMISSION_TTL_MS).toBeGreaterThanOrEqual(60 * 60_000)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('hostd verbs never get a shorter window than the default card', () => {
|
|
51
|
+
expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(PERMISSION_TTL_MS)
|
|
52
|
+
expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(30 * 60_000)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
25
56
|
describe('permissionSignature', () => {
|
|
26
57
|
it('is stable for the same tool + input', () => {
|
|
27
58
|
expect(permissionSignature('mcp__meta_ads__set_budget', '{"id":"1","budget":1400}'))
|
|
@@ -96,7 +127,7 @@ describe('isRecentTimeoutDuplicate', () => {
|
|
|
96
127
|
// ─── Bug 2 — per-tool TTL, timed-out card hygiene, stale-tap honesty ────────
|
|
97
128
|
|
|
98
129
|
describe('ttlForTool (Bug 2 fix #2)', () => {
|
|
99
|
-
it('gives hostd gated verbs a 30-min window', () => {
|
|
130
|
+
it('gives hostd gated verbs at least a 30-min window (>= the default card)', () => {
|
|
100
131
|
for (const tool of [
|
|
101
132
|
'mcp__hostd__rollout',
|
|
102
133
|
'mcp__hostd__update_apply',
|
|
@@ -105,20 +136,20 @@ describe('ttlForTool (Bug 2 fix #2)', () => {
|
|
|
105
136
|
'mcp__hostd__config_propose_edit',
|
|
106
137
|
]) {
|
|
107
138
|
expect(ttlForTool(tool)).toBe(HOSTD_PERMISSION_TTL_MS)
|
|
108
|
-
expect(ttlForTool(tool)).
|
|
139
|
+
expect(ttlForTool(tool)).toBeGreaterThanOrEqual(30 * 60_000)
|
|
109
140
|
}
|
|
110
141
|
})
|
|
111
142
|
|
|
112
|
-
it('
|
|
143
|
+
it('applies the config-driven default (60 min) to non-hostd tools', () => {
|
|
113
144
|
expect(ttlForTool('Bash')).toBe(PERMISSION_TTL_MS)
|
|
114
|
-
expect(ttlForTool('Bash')).toBe(
|
|
145
|
+
expect(ttlForTool('Bash')).toBe(60 * 60_000)
|
|
115
146
|
expect(ttlForTool('mcp__perplexity__search')).toBe(PERMISSION_TTL_MS)
|
|
116
147
|
expect(ttlForTool('mcp__agent-config__schedule_add')).toBe(PERMISSION_TTL_MS)
|
|
117
148
|
expect(ttlForTool(undefined)).toBe(PERMISSION_TTL_MS)
|
|
118
149
|
})
|
|
119
150
|
|
|
120
|
-
it('hostd TTL is
|
|
121
|
-
expect(HOSTD_PERMISSION_TTL_MS).
|
|
151
|
+
it('hostd TTL is never shorter than the default (the 60-min raise subsumes the old 30-min floor)', () => {
|
|
152
|
+
expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(PERMISSION_TTL_MS)
|
|
122
153
|
})
|
|
123
154
|
})
|
|
124
155
|
|
|
@@ -65,12 +65,31 @@ const dispatchCallsites = LINES.flatMap((line, i) =>
|
|
|
65
65
|
const isSilentNoCardVerdict = (idx: number): boolean =>
|
|
66
66
|
LINES.slice(Math.max(0, idx - 3), idx + 1).some((l) => /no-card-verdict/.test(l))
|
|
67
67
|
|
|
68
|
+
// A CARD-FOLDED-RESUME path (the "Allow once" tap, card-ux fix 3) does NOT
|
|
69
|
+
// post a separate `postPermissionResumeMessage()` — the agent-voiced "got it,
|
|
70
|
+
// continuing: <work>" line is folded INTO the same card edit (below the ✅
|
|
71
|
+
// label) via `finalizeCallback`, collapsing the old split second message. The
|
|
72
|
+
// turn still resumes (`dispatchPermissionVerdict` + `resumeReactionAfterVerdict`
|
|
73
|
+
// both fire) and the operator still sees the continuation — it just rides the
|
|
74
|
+
// tapped card instead of fanning out to every card target. Such callsites carry
|
|
75
|
+
// the `card-folded-resume` sentinel within the RESUME_WINDOW below the dispatch
|
|
76
|
+
// and are exempt ONLY from the post-message pairing (still required to flip the
|
|
77
|
+
// glyph). The invariant the guard protects (a verdict that un-parks a card must
|
|
78
|
+
// visibly resume it) still holds — the resume is in the card body.
|
|
79
|
+
const isCardFoldedResume = (idx: number): boolean =>
|
|
80
|
+
LINES.slice(idx, idx + POST_WINDOW + 1).some((l) => /card-folded-resume/.test(l))
|
|
81
|
+
|
|
68
82
|
// How far below the dispatch the resume call is allowed to live. The
|
|
69
83
|
// widest real gap today is ~9 lines (the slash-command path); 15 gives
|
|
70
84
|
// refactor headroom without letting an unrelated resume "cover" a
|
|
71
85
|
// dispatch from a different block.
|
|
72
86
|
const RESUME_WINDOW = 15
|
|
73
87
|
|
|
88
|
+
// postPermissionResumeMessage rides ~1–2 lines after resumeReactionAfterVerdict
|
|
89
|
+
// on every path, so it can sit a touch further from the dispatch than the
|
|
90
|
+
// resume call — give it a little more headroom.
|
|
91
|
+
const POST_WINDOW = 20
|
|
92
|
+
|
|
74
93
|
describe('permission verdict → resume reaction wiring', () => {
|
|
75
94
|
it('there is at least one verdict-dispatch path to guard', () => {
|
|
76
95
|
expect(dispatchCallsites.length).toBeGreaterThan(0)
|
|
@@ -102,15 +121,13 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
102
121
|
)
|
|
103
122
|
})
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
// on every path, so it can sit a touch further from the dispatch than the
|
|
107
|
-
// resume call — give it a little more headroom.
|
|
108
|
-
const POST_WINDOW = 20
|
|
109
|
-
|
|
110
|
-
it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
|
|
124
|
+
it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage() or folds it into the card', () => {
|
|
111
125
|
const unpaired: number[] = []
|
|
112
126
|
for (const idx of dispatchCallsites) {
|
|
113
127
|
if (isSilentNoCardVerdict(idx)) continue
|
|
128
|
+
// Exempt: card-ux fix 3 folds the resume line into the card edit
|
|
129
|
+
// instead of a separate message (still visibly resumes the operator).
|
|
130
|
+
if (isCardFoldedResume(idx)) continue
|
|
114
131
|
const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
|
|
115
132
|
if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
|
|
116
133
|
unpaired.push(idx + 1)
|
|
@@ -92,11 +92,18 @@ describe('formatQuotaBlock', () => {
|
|
|
92
92
|
now,
|
|
93
93
|
)
|
|
94
94
|
expect(block).toContain('**Claude plan quota**')
|
|
95
|
-
expect(block).toContain('**5h window** 29
|
|
96
|
-
expect(block).toContain('**7d window** 33
|
|
95
|
+
expect(block).toContain('**5h window** `29%` · `resets in 2h 30m`')
|
|
96
|
+
expect(block).toContain('**7d window** `33%` · `resets in 3d`')
|
|
97
97
|
expect(block).toContain('Binding window: five hour')
|
|
98
98
|
// overage=allowed should not be surfaced
|
|
99
99
|
expect(block).not.toContain('Overage:')
|
|
100
|
+
// Both windows: the **bold label** stays bold AND the percentage +
|
|
101
|
+
// reset ETA are each wrapped in a `code` span. Locks the nit-fix that
|
|
102
|
+
// the value moved into backticks without dropping the label markup.
|
|
103
|
+
const fiveLine = block.split('\n').find((l) => l.includes('5h window'))!
|
|
104
|
+
const sevenLine = block.split('\n').find((l) => l.includes('7d window'))!
|
|
105
|
+
expect(fiveLine).toMatch(/^\*\*5h window\*\*\s+`\d+%` · `resets in .+?`$/)
|
|
106
|
+
expect(sevenLine).toMatch(/^\*\*7d window\*\*\s+`\d+%` · `resets in .+?`$/)
|
|
100
107
|
})
|
|
101
108
|
|
|
102
109
|
it('surfaces overage status when not allowed', () => {
|
|
@@ -249,11 +249,11 @@ describe("evaluateQuotaWatchAccount — message content", () => {
|
|
|
249
249
|
expect(d.message).toContain("30%");
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
it("throttling message
|
|
253
|
-
// The label is interpolated
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
252
|
+
it("throttling message renders the account label literally in a code span (#2695)", () => {
|
|
253
|
+
// The label is interpolated INSIDE a `code span`, where content is literal
|
|
254
|
+
// and backslash escaping is WRONG (#2695 regression: escapeMarkdown here
|
|
255
|
+
// emitted a visible `ev\_il@…`). The correct rendering is via codeSpanSafe,
|
|
256
|
+
// so an underscore label appears verbatim with no stray backslash.
|
|
257
257
|
const d = evaluateQuotaWatchAccount({
|
|
258
258
|
agentName: "lawgpt",
|
|
259
259
|
snap: makeSnap("ev_il@example.com", makeQuota(85, 40)),
|
|
@@ -262,8 +262,8 @@ describe("evaluateQuotaWatchAccount — message content", () => {
|
|
|
262
262
|
});
|
|
263
263
|
expect(d.kind).toBe("notify");
|
|
264
264
|
if (d.kind !== "notify") return;
|
|
265
|
-
|
|
266
|
-
expect(d.message).toContain("ev\\_il@example.com");
|
|
265
|
+
expect(d.message).toContain("`ev_il@example.com`");
|
|
266
|
+
expect(d.message).not.toContain("ev\\_il@example.com");
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
it("throttling message for active account mentions /auth use", () => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2726 — structural wiring guard for the rollout status IPC verbs. The
|
|
3
|
+
* gateway handlers live in the createIpcServer({...}) IIFE block, which can't
|
|
4
|
+
* be imported in a unit test (same constraint as the send_outbound /
|
|
5
|
+
* inject_inbound wiring tests), so the load-bearing invariants are pinned by
|
|
6
|
+
* reading the source:
|
|
7
|
+
* 1. ipc-server routes rollout_status_post → onRolloutStatusPost and
|
|
8
|
+
* rollout_status_edit → onRolloutStatusEdit, with validator arms.
|
|
9
|
+
* 2. the POST handler fences agent (agentName === self), posts to the
|
|
10
|
+
* OPERATOR chat (allowFrom[0]) as an ORDINARY message (no pin, no
|
|
11
|
+
* inline_keyboard card), and replies with the message_id.
|
|
12
|
+
* 3. the EDIT handler edits in place via the wrapped retry path
|
|
13
|
+
* (swallowingApiCall) — fire-and-forget, never a raw bot.api call.
|
|
14
|
+
* 4. NEITHER handler pins a message — this is in-chat narration, not the
|
|
15
|
+
* retired pinned card (chat-is-the-single-source-of-truth).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
|
|
21
|
+
const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
|
|
22
|
+
|
|
23
|
+
describe("rollout_status_* — ipc-server routing", () => {
|
|
24
|
+
it("validates + dispatches rollout_status_post", () => {
|
|
25
|
+
expect(ipcServer).toMatch(/case "rollout_status_post": \{/); // validator arm
|
|
26
|
+
expect(ipcServer).toMatch(/onRolloutStatusPost\(client, msg as RolloutStatusPostMessage\)/);
|
|
27
|
+
});
|
|
28
|
+
it("validates + dispatches rollout_status_edit", () => {
|
|
29
|
+
expect(ipcServer).toMatch(/case "rollout_status_edit": \{/);
|
|
30
|
+
expect(ipcServer).toMatch(/onRolloutStatusEdit\(client, msg as RolloutStatusEditMessage\)/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("onRolloutStatusPost — gateway handler invariants", () => {
|
|
35
|
+
const start = gw.indexOf("async onRolloutStatusPost(client: IpcClient, msg: RolloutStatusPostMessage)");
|
|
36
|
+
const next = gw.indexOf("onRolloutStatusEdit(", start);
|
|
37
|
+
const handler = gw.slice(start, next > start ? next : start + 2600);
|
|
38
|
+
|
|
39
|
+
it("the handler exists", () => {
|
|
40
|
+
expect(start).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
it("fences the agent (agentName must match this gateway's own)", () => {
|
|
43
|
+
expect(handler).toMatch(/msg\.agentName !== self/);
|
|
44
|
+
});
|
|
45
|
+
it("posts to the OPERATOR chat (allowFrom[0])", () => {
|
|
46
|
+
expect(handler).toMatch(/loadAccess\(\)\.allowFrom\[0\]/);
|
|
47
|
+
});
|
|
48
|
+
it("posts through the wrapped retry path, NOT a raw bot.api", () => {
|
|
49
|
+
expect(handler).toMatch(/robustApiCall\(/);
|
|
50
|
+
expect(handler).toMatch(/bot\.api\.sendRichMessage\(operator/);
|
|
51
|
+
});
|
|
52
|
+
it("replies with the message_id so hostd can EDIT it later", () => {
|
|
53
|
+
expect(handler).toMatch(/rollout_status_posted/);
|
|
54
|
+
expect(handler).toMatch(/messageId/);
|
|
55
|
+
});
|
|
56
|
+
it("does NOT pin the message and renders NO inline-keyboard card (narration, not the retired card)", () => {
|
|
57
|
+
expect(handler).not.toMatch(/pinChatMessage/);
|
|
58
|
+
expect(handler).not.toMatch(/reply_markup/);
|
|
59
|
+
expect(handler).not.toMatch(/InlineKeyboard/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("onRolloutStatusEdit — gateway handler invariants", () => {
|
|
64
|
+
const start = gw.indexOf("onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage)");
|
|
65
|
+
const next = gw.indexOf("onInjectInbound(", start);
|
|
66
|
+
const handler = gw.slice(start, next > start ? next : start + 2000);
|
|
67
|
+
|
|
68
|
+
it("the handler exists", () => {
|
|
69
|
+
expect(start).toBeGreaterThan(0);
|
|
70
|
+
});
|
|
71
|
+
it("fences the agent", () => {
|
|
72
|
+
expect(handler).toMatch(/msg\.agentName !== self/);
|
|
73
|
+
});
|
|
74
|
+
it("edits in place via the wrapped retry path (fire-and-forget)", () => {
|
|
75
|
+
expect(handler).toMatch(/swallowingApiCall\(/);
|
|
76
|
+
expect(handler).toMatch(/bot\.api\.editMessageText\(operator, msg\.messageId/);
|
|
77
|
+
});
|
|
78
|
+
it("does NOT pin (edits an ordinary message)", () => {
|
|
79
|
+
expect(handler).not.toMatch(/pinChatMessage/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-pin boot-recovery — the HEADLINE crash-recovery path, exercised against
|
|
3
|
+
* the REAL store + reconcile wrapper the gateway wires up (not just the pure
|
|
4
|
+
* decision fn). It reproduces the full sequence the gateway performs:
|
|
5
|
+
*
|
|
6
|
+
* reconcile a pin → persist (persist-before-pin) → simulate a crash (SKIP the
|
|
7
|
+
* clean SIGTERM sweep `unpinAllStatusPins`) → boot a "new gateway" (fresh
|
|
8
|
+
* Maps, same on-disk store) → runStatusPinBootCleanup → assert the orphan pin
|
|
9
|
+
* is unpinned AND the store is emptied.
|
|
10
|
+
*
|
|
11
|
+
* We cannot import gateway.ts directly (its module top-level runs the whole boot
|
|
12
|
+
* IIFE incl. the startup mutex + bot.start). Instead we model the gateway's
|
|
13
|
+
* status-pin subsystem faithfully with the SAME functions gateway.ts calls
|
|
14
|
+
* (reconcileAndPersistStatusPin, reconcilePin, runStatusPinBootCleanup) over an
|
|
15
|
+
* in-memory fs + a fake Telegram whose pin set we can inspect — so a regression
|
|
16
|
+
* in the ordering / cleanup contract reds here.
|
|
17
|
+
*
|
|
18
|
+
* Two variants:
|
|
19
|
+
* (A) confirmed pin → crash before the clean sweep → recovered next boot.
|
|
20
|
+
* (B) pending pin (crash INSIDE the persist-after-pin window, Fix 1) →
|
|
21
|
+
* recovered next boot.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import {
|
|
27
|
+
loadStatusPins,
|
|
28
|
+
persistStatusPins,
|
|
29
|
+
reconcileAndPersistStatusPin,
|
|
30
|
+
runStatusPinBootCleanup,
|
|
31
|
+
type PersistedStatusPin,
|
|
32
|
+
type StatusPinStoreFsSeam,
|
|
33
|
+
} from "../gateway/status-pin-store.js";
|
|
34
|
+
import { reconcilePin } from "../status-pin-driver.js";
|
|
35
|
+
import type { PinState, DesiredPin } from "../status-pin.js";
|
|
36
|
+
import { decidePinAction } from "../status-pin.js";
|
|
37
|
+
|
|
38
|
+
const PATH = "/state/agent/telegram/status-pins.json";
|
|
39
|
+
|
|
40
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
41
|
+
const files = new Map<string, string>(Object.entries(seed));
|
|
42
|
+
const fs: StatusPinStoreFsSeam = {
|
|
43
|
+
readFileSync: (p) => {
|
|
44
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`);
|
|
45
|
+
return files.get(p)!;
|
|
46
|
+
},
|
|
47
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
48
|
+
renameSync: (a, b) => {
|
|
49
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`);
|
|
50
|
+
files.set(b, files.get(a)!);
|
|
51
|
+
files.delete(a);
|
|
52
|
+
},
|
|
53
|
+
existsSync: (p) => files.has(p),
|
|
54
|
+
};
|
|
55
|
+
return { fs, files };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A fake Telegram that records the current pinned message set per chat, so we
|
|
59
|
+
* can assert an orphan is left pinned after a crash and unpinned after boot. */
|
|
60
|
+
function fakeTelegram() {
|
|
61
|
+
const pinned = new Set<string>(); // `${chatId}:${messageId}`
|
|
62
|
+
return {
|
|
63
|
+
pinned,
|
|
64
|
+
api: {
|
|
65
|
+
pinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
66
|
+
pinned.add(`${chat_id}:${message_id}`);
|
|
67
|
+
},
|
|
68
|
+
unpinChatMessage: async (chat_id: string | number, message_id: number) => {
|
|
69
|
+
pinned.delete(`${chat_id}:${message_id}`);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A faithful stand-in for the gateway's status-pin subsystem — the SAME wiring
|
|
77
|
+
* as gateway.ts's `reconcileStatusPin` / `unpinAllStatusPins` / boot cleanup,
|
|
78
|
+
* with injectable fs + api so a single test can span a "crash" and a fresh boot.
|
|
79
|
+
*/
|
|
80
|
+
function makeGateway(fs: StatusPinStoreFsSeam, tg: ReturnType<typeof fakeTelegram>) {
|
|
81
|
+
const statusPinState = new Map<string, PinState>();
|
|
82
|
+
const statusPinChatIds = new Map<string, string>();
|
|
83
|
+
|
|
84
|
+
const snapshotExcept = (exceptKey: string): PersistedStatusPin[] => {
|
|
85
|
+
const out: PersistedStatusPin[] = [];
|
|
86
|
+
for (const [pinKey, st] of statusPinState) {
|
|
87
|
+
if (pinKey === exceptKey) continue;
|
|
88
|
+
const chatId = statusPinChatIds.get(pinKey);
|
|
89
|
+
if (chatId == null) continue;
|
|
90
|
+
out.push({ pinKey, chatId, messageId: st.messageId });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function reconcileStatusPin(pinKey: string, chatId: string, desired: DesiredPin) {
|
|
96
|
+
const prev = statusPinState.get(pinKey) ?? null;
|
|
97
|
+
const action = decidePinAction(prev, desired);
|
|
98
|
+
const op =
|
|
99
|
+
action.kind === "pin"
|
|
100
|
+
? ({ kind: "pin", messageId: action.messageId } as const)
|
|
101
|
+
: ({ kind: "clear" } as const);
|
|
102
|
+
const next = await reconcileAndPersistStatusPin({
|
|
103
|
+
path: PATH,
|
|
104
|
+
fs,
|
|
105
|
+
pinKey,
|
|
106
|
+
chatId,
|
|
107
|
+
op,
|
|
108
|
+
snapshotOthers: () => snapshotExcept(pinKey),
|
|
109
|
+
applyPin: () =>
|
|
110
|
+
reconcilePin({ api: tg.api, chatId, prevState: prev, desired }),
|
|
111
|
+
log: () => {},
|
|
112
|
+
});
|
|
113
|
+
if (next == null) {
|
|
114
|
+
statusPinState.delete(pinKey);
|
|
115
|
+
statusPinChatIds.delete(pinKey);
|
|
116
|
+
} else {
|
|
117
|
+
statusPinState.set(pinKey, next);
|
|
118
|
+
statusPinChatIds.set(pinKey, chatId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function bootCleanup() {
|
|
123
|
+
return runStatusPinBootCleanup({
|
|
124
|
+
path: PATH,
|
|
125
|
+
fs,
|
|
126
|
+
unpin: (chatId, messageId) => tg.api.unpinChatMessage(chatId, messageId),
|
|
127
|
+
log: () => {},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { statusPinState, reconcileStatusPin, bootCleanup };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("status-pin boot recovery (gateway wiring)", () => {
|
|
135
|
+
it("(A) confirmed pin → crash skips the clean sweep → next boot unpins the orphan + empties the store", async () => {
|
|
136
|
+
const { fs } = memFs();
|
|
137
|
+
const tg = fakeTelegram();
|
|
138
|
+
|
|
139
|
+
// ── Session 1: reconcile a pin. persist-before-pin runs; pin lands. ──
|
|
140
|
+
const gw1 = makeGateway(fs, tg);
|
|
141
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
142
|
+
|
|
143
|
+
// Pin is live in Telegram and recorded on disk (confirmed).
|
|
144
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
145
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
146
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
// ── CRASH: we deliberately DO NOT run the clean SIGTERM sweep
|
|
150
|
+
// (unpinAllStatusPins). The pin stays live; gw1's Maps vanish. ──
|
|
151
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
152
|
+
|
|
153
|
+
// ── Session 2: brand-new gateway, empty Maps, SAME on-disk store. ──
|
|
154
|
+
const gw2 = makeGateway(fs, tg);
|
|
155
|
+
const res = await gw2.bootCleanup();
|
|
156
|
+
|
|
157
|
+
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
158
|
+
expect(tg.pinned.has("-100123:715")).toBe(false); // orphan unpinned
|
|
159
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("(B) pending pin (crash inside the persist-after-pin window) → recovered next boot", async () => {
|
|
163
|
+
const { fs } = memFs();
|
|
164
|
+
const tg = fakeTelegram();
|
|
165
|
+
|
|
166
|
+
// Simulate the exact Fix-1 crash window: the pin API lands, then the
|
|
167
|
+
// process is SIGKILLed before the confirming rewrite. We model that by
|
|
168
|
+
// reconcileAndPersistStatusPin whose applyPin pins then throws.
|
|
169
|
+
await expect(
|
|
170
|
+
reconcileAndPersistStatusPin({
|
|
171
|
+
path: PATH,
|
|
172
|
+
fs,
|
|
173
|
+
pinKey: "fg:c:3",
|
|
174
|
+
chatId: "-100123",
|
|
175
|
+
op: { kind: "pin", messageId: 715 },
|
|
176
|
+
snapshotOthers: () => [],
|
|
177
|
+
applyPin: async () => {
|
|
178
|
+
await tg.api.pinChatMessage("-100123", 715); // pin lands in Telegram
|
|
179
|
+
throw new Error("SIGKILL before confirm rewrite");
|
|
180
|
+
},
|
|
181
|
+
log: () => {},
|
|
182
|
+
}),
|
|
183
|
+
).rejects.toThrow("SIGKILL");
|
|
184
|
+
|
|
185
|
+
// Orphan is live in Telegram; a PENDING record is on disk.
|
|
186
|
+
expect(tg.pinned.has("-100123:715")).toBe(true);
|
|
187
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
188
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
// Fresh boot recovers it from the pending record.
|
|
192
|
+
const gw2 = makeGateway(fs, tg);
|
|
193
|
+
const res = await gw2.bootCleanup();
|
|
194
|
+
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
195
|
+
expect(tg.pinned.has("-100123:715")).toBe(false);
|
|
196
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("clean shutdown (sweep DID run) leaves nothing for boot cleanup to do", async () => {
|
|
200
|
+
// Contrast: when the SIGTERM sweep runs (unpin each key), the store is
|
|
201
|
+
// emptied and the pin removed — boot cleanup is a no-op. This guards the
|
|
202
|
+
// ordering the other way: normal shutdown must NOT rely on boot cleanup.
|
|
203
|
+
const { fs } = memFs();
|
|
204
|
+
const tg = fakeTelegram();
|
|
205
|
+
const gw1 = makeGateway(fs, tg);
|
|
206
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: true, messageId: 715 });
|
|
207
|
+
// Clean sweep: unpin the key.
|
|
208
|
+
await gw1.reconcileStatusPin("fg:c:3", "-100123", { pinned: false });
|
|
209
|
+
expect(tg.pinned.size).toBe(0);
|
|
210
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
211
|
+
|
|
212
|
+
const gw2 = makeGateway(fs, tg);
|
|
213
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Mutex-gating (P2). The boot cleanup issues real unpins against a SHARED
|
|
219
|
+
* per-agent store, so a LOSING double-boot must NOT run it — else it would strip
|
|
220
|
+
* a still-alive sibling gateway's legitimate pins. gateway.ts gates the call
|
|
221
|
+
* inside the won-lock branch (only the boot that wins acquireStartupLock calls
|
|
222
|
+
* statusPinBootCleanup; the loser process.exit(1)s first).
|
|
223
|
+
*
|
|
224
|
+
* A full held-lock integration test is impractical (gateway.ts's boot IIFE runs
|
|
225
|
+
* the mutex + bot.start at import). So we assert STRUCTURALLY that the cleanup
|
|
226
|
+
* call sits inside the won-lock branch and never at the top-level import scope,
|
|
227
|
+
* and that the loser (blocked) branch exits before it.
|
|
228
|
+
*/
|
|
229
|
+
describe("status-pin boot cleanup is mutex-gated (structural)", () => {
|
|
230
|
+
const gatewaySrc = readFileSync(
|
|
231
|
+
fileURLToPath(new URL("../gateway/gateway.ts", import.meta.url)),
|
|
232
|
+
"utf8",
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
it("does NOT invoke statusPinBootCleanup at module import scope", () => {
|
|
236
|
+
// The invocation must be gated on winning the mutex, never fired eagerly at
|
|
237
|
+
// import. A bare top-level `statusPinBootCleanup()` (not inside the lock
|
|
238
|
+
// block) would let a losing double-boot strip a live sibling's pins.
|
|
239
|
+
// The definition + the deliberate DO-NOT-invoke note are allowed; assert
|
|
240
|
+
// there is a note explaining the gating, and that every actual call is
|
|
241
|
+
// preceded (in source order) by the acquireStartupLock outcome check.
|
|
242
|
+
expect(gatewaySrc).toContain("acquireStartupLock");
|
|
243
|
+
// The comment documenting WHY it isn't invoked at import time.
|
|
244
|
+
expect(gatewaySrc).toMatch(
|
|
245
|
+
/NOT invoked here at import time|gated on winning the startup mutex/,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("every statusPinBootCleanup() call site follows the won-lock check", () => {
|
|
250
|
+
// Find each *invocation* (with parens), excluding the declaration.
|
|
251
|
+
const lines = gatewaySrc.split("\n");
|
|
252
|
+
const lockIdx = lines.findIndex((l) => l.includes("acquireStartupLock({"));
|
|
253
|
+
expect(lockIdx).toBeGreaterThan(-1);
|
|
254
|
+
|
|
255
|
+
const declIdx = lines.findIndex((l) =>
|
|
256
|
+
/async function statusPinBootCleanup/.test(l),
|
|
257
|
+
);
|
|
258
|
+
expect(declIdx).toBeGreaterThan(-1);
|
|
259
|
+
|
|
260
|
+
const callIdxs = lines
|
|
261
|
+
.map((l, i) => ({ l, i }))
|
|
262
|
+
.filter(
|
|
263
|
+
({ l, i }) =>
|
|
264
|
+
i !== declIdx &&
|
|
265
|
+
/statusPinBootCleanup\(\)/.test(l) &&
|
|
266
|
+
!l.trimStart().startsWith("//") &&
|
|
267
|
+
!l.includes("statusPinBootCleanup() is deliberately NOT"),
|
|
268
|
+
)
|
|
269
|
+
.map(({ i }) => i);
|
|
270
|
+
|
|
271
|
+
// There must be at least one real call, and every real call must come
|
|
272
|
+
// AFTER the acquireStartupLock outcome is available (i.e. inside the boot
|
|
273
|
+
// block that only the winner reaches).
|
|
274
|
+
expect(callIdxs.length).toBeGreaterThan(0);
|
|
275
|
+
for (const idx of callIdxs) {
|
|
276
|
+
expect(idx).toBeGreaterThan(lockIdx);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("the blocked (losing) boot exits before reaching cleanup", () => {
|
|
281
|
+
// In the mutex block, `outcome.status === 'blocked'` must lead to
|
|
282
|
+
// process.exit(1) BEFORE any statusPinBootCleanup() call in that block —
|
|
283
|
+
// so a loser never touches the shared store.
|
|
284
|
+
const blockedIdx = gatewaySrc.indexOf("outcome.status === 'blocked'");
|
|
285
|
+
expect(blockedIdx).toBeGreaterThan(-1);
|
|
286
|
+
const afterBlocked = gatewaySrc.slice(blockedIdx);
|
|
287
|
+
const exitIdx = afterBlocked.indexOf("process.exit(1)");
|
|
288
|
+
const cleanupIdx = afterBlocked.indexOf("void statusPinBootCleanup()");
|
|
289
|
+
expect(exitIdx).toBeGreaterThan(-1);
|
|
290
|
+
expect(cleanupIdx).toBeGreaterThan(-1);
|
|
291
|
+
// The exit for the blocked branch appears before the winner's cleanup call.
|
|
292
|
+
expect(exitIdx).toBeLessThan(cleanupIdx);
|
|
293
|
+
});
|
|
294
|
+
});
|