switchroom 0.16.28 → 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 +60648 -59800
- 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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural test for the `bot.on('message:pinned_message')` handler that
|
|
3
|
+
* suppresses the "pinned a message" service message Telegram inserts when
|
|
4
|
+
* OUR silent status-pin fires.
|
|
5
|
+
*
|
|
6
|
+
* Why structural: like every other gateway handler, this closure is wired
|
|
7
|
+
* inline against the live `bot` instance and is not exported — a functional
|
|
8
|
+
* invocation would require booting the full grammy runtime against a mocked
|
|
9
|
+
* Bot API. The gateway suite settled on file-level grep assertions for
|
|
10
|
+
* exactly this reason (see `inbound-message-types.test.ts`). The regression
|
|
11
|
+
* we care about: a future hand dropping the handler, dropping the ownership
|
|
12
|
+
* guard (which would let us delete manual/operator pins), or swapping the
|
|
13
|
+
* house deletion wrapper for a raw `bot.api.deleteMessage` (allowlist drift).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import {
|
|
19
|
+
pinnedMessageIsOurs,
|
|
20
|
+
type TrackedStatusPin,
|
|
21
|
+
} from '../gateway/status-pin-store.js'
|
|
22
|
+
|
|
23
|
+
const SRC = readFileSync(
|
|
24
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
25
|
+
'utf8',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the body of the `bot.on('message:pinned_message', …)` handler:
|
|
30
|
+
* from the `bot.on(` line to the matching outer-scope closing brace.
|
|
31
|
+
*/
|
|
32
|
+
function pinnedMessageHandler(): string {
|
|
33
|
+
const needle = `bot.on('message:pinned_message'`
|
|
34
|
+
const start = SRC.indexOf(needle)
|
|
35
|
+
expect(start, `handler ${needle} not found`).toBeGreaterThan(0)
|
|
36
|
+
const firstBrace = SRC.indexOf('{', start)
|
|
37
|
+
let depth = 0
|
|
38
|
+
for (let i = firstBrace; i < SRC.length; i++) {
|
|
39
|
+
const c = SRC[i]
|
|
40
|
+
if (c === '{') depth++
|
|
41
|
+
else if (c === '}') {
|
|
42
|
+
depth--
|
|
43
|
+
if (depth === 0) return SRC.slice(start, i + 1)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new Error('could not find end of pinned_message handler')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('status-pin service-message suppression', () => {
|
|
50
|
+
it("registers a bot.on('message:pinned_message') handler", () => {
|
|
51
|
+
expect(SRC).toContain(`bot.on('message:pinned_message'`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const body = pinnedMessageHandler()
|
|
55
|
+
|
|
56
|
+
it('guards on ownership via the chat-scoped pinnedMessageIsOurs helper', () => {
|
|
57
|
+
// Must consult our tracked pins — never blindly delete a pin service
|
|
58
|
+
// message (which would nuke manual/operator pins too) — AND it must be
|
|
59
|
+
// chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
|
|
60
|
+
// messageId-only match.
|
|
61
|
+
expect(body).toContain('statusPinState')
|
|
62
|
+
expect(body).toMatch(/pinned_message\?\.message_id/)
|
|
63
|
+
expect(body).toContain('pinnedMessageIsOurs(')
|
|
64
|
+
// The chatId computed from the update must be fed to the guard.
|
|
65
|
+
expect(body).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
|
|
66
|
+
// And the tracked entries must carry their chat association.
|
|
67
|
+
expect(body).toContain('statusPinChatIds.get(pinKey)')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('bails out when the pinned message is not one of ours', () => {
|
|
71
|
+
// A `return` guard for the non-owned case must exist.
|
|
72
|
+
expect(body).toMatch(/if \(!isOurs\(\)\) return/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('deletes the service message through the robust wrapper (not a raw api call)', () => {
|
|
76
|
+
expect(body).toContain('robustApiCall(')
|
|
77
|
+
expect(body).toContain('deleteMessage')
|
|
78
|
+
// The message deleted is the service message itself, not the pinned msg.
|
|
79
|
+
expect(body).toContain('serviceMsgId')
|
|
80
|
+
// House verb tag so operators can trace it.
|
|
81
|
+
expect(body).toContain("verb: 'status-pin.delete-service-message'")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('tolerates the reconcile-store race with a short retry', () => {
|
|
85
|
+
// The service update can arrive before reconcileStatusPin stores the
|
|
86
|
+
// PinState; a single delayed re-check covers that window.
|
|
87
|
+
expect(body).toContain('setTimeout')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* BEHAVIORAL coverage of the ownership decision (BUG 1). The handler decides
|
|
93
|
+
* whether to delete a `pinned_message` service message by calling the real
|
|
94
|
+
* `pinnedMessageIsOurs(tracked, chatId, pinnedId)`. This models the exact
|
|
95
|
+
* gateway decision and asserts the delete fires ONLY for a same-chat match —
|
|
96
|
+
* the structural grep above would pass even with the messageId-only bug live.
|
|
97
|
+
*/
|
|
98
|
+
describe('service-message deletion decision (chat-scoped)', () => {
|
|
99
|
+
// Two tracked status pins in DIFFERENT chats. Chat A's pin id (715) collides
|
|
100
|
+
// with an incoming pin update in chat B.
|
|
101
|
+
const tracked: TrackedStatusPin[] = [
|
|
102
|
+
{ chatId: '-100AAA', messageId: 715 },
|
|
103
|
+
{ chatId: '-100BBB', messageId: 42 },
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
// Mirror the handler: delete iff pinnedMessageIsOurs(...) is true.
|
|
107
|
+
const wouldDelete = (chatId: string, pinnedId: number) =>
|
|
108
|
+
pinnedMessageIsOurs(tracked, chatId, pinnedId)
|
|
109
|
+
|
|
110
|
+
it('does NOT delete chat B service message when its pin id collides with chat A', () => {
|
|
111
|
+
// Chat B, pinned id 715 → belongs to chat A, not B. Must NOT delete
|
|
112
|
+
// (this is the operator-manual-pin-notice safety case).
|
|
113
|
+
expect(wouldDelete('-100BBB', 715)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('DOES delete on a genuine same-chat match', () => {
|
|
117
|
+
expect(wouldDelete('-100AAA', 715)).toBe(true)
|
|
118
|
+
expect(wouldDelete('-100BBB', 42)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does NOT delete a foreign pin id in a tracked chat', () => {
|
|
122
|
+
expect(wouldDelete('-100AAA', 9999)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* SUPERGROUP / forum-topic coverage. The handler reads only ctx.chat.id +
|
|
128
|
+
* ctx.msg.message_id and deletes by (chat_id, message_id) — topic-agnostic,
|
|
129
|
+
* with no private-chat assumption. In a forum supergroup the pinned status
|
|
130
|
+
* message carries a message_thread_id, but:
|
|
131
|
+
* - deletion needs no thread arg (chat_id + message_id is sufficient), and
|
|
132
|
+
* - message_id is unique per chat even across topics, so a single chat-scoped
|
|
133
|
+
* match is correct regardless of which topic the pin lives in.
|
|
134
|
+
* These tests model a pinned_message update whose chat is a supergroup (and a
|
|
135
|
+
* forum variant carrying message_thread_id) and assert the same chat-scoped
|
|
136
|
+
* decision holds.
|
|
137
|
+
*/
|
|
138
|
+
describe('service-message deletion in supergroups / forum topics', () => {
|
|
139
|
+
// A status pin tracked in a supergroup chat, plus a collision partner in a
|
|
140
|
+
// different supergroup.
|
|
141
|
+
const tracked: TrackedStatusPin[] = [
|
|
142
|
+
{ chatId: '-1001111', messageId: 500 }, // supergroup A
|
|
143
|
+
{ chatId: '-1002222', messageId: 500 }, // supergroup B, SAME id as A
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
// Model the handler's identity extraction from a grammy-shaped update. The
|
|
147
|
+
// thread id is present on forum updates but deliberately NOT part of the
|
|
148
|
+
// ownership/delete identity.
|
|
149
|
+
type PinnedUpdate = {
|
|
150
|
+
chat: { id: number; type: 'private' | 'supergroup' }
|
|
151
|
+
message_id: number
|
|
152
|
+
message_thread_id?: number
|
|
153
|
+
pinned_message: { message_id: number }
|
|
154
|
+
}
|
|
155
|
+
const wouldDelete = (u: PinnedUpdate) =>
|
|
156
|
+
pinnedMessageIsOurs(tracked, String(u.chat.id), u.pinned_message.message_id)
|
|
157
|
+
|
|
158
|
+
it('deletes on a genuine same-chat match in a plain supergroup', () => {
|
|
159
|
+
const update: PinnedUpdate = {
|
|
160
|
+
chat: { id: -1001111, type: 'supergroup' },
|
|
161
|
+
message_id: 9001, // the service message
|
|
162
|
+
pinned_message: { message_id: 500 },
|
|
163
|
+
}
|
|
164
|
+
expect(wouldDelete(update)).toBe(true)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('deletes on a same-chat match in a FORUM topic (message_thread_id set)', () => {
|
|
168
|
+
const update: PinnedUpdate = {
|
|
169
|
+
chat: { id: -1001111, type: 'supergroup' },
|
|
170
|
+
message_id: 9002,
|
|
171
|
+
message_thread_id: 77, // forum topic — irrelevant to the delete identity
|
|
172
|
+
pinned_message: { message_id: 500 },
|
|
173
|
+
}
|
|
174
|
+
expect(wouldDelete(update)).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('does NOT delete when the id collides across two supergroups', () => {
|
|
178
|
+
// Supergroup B receives a pin update for id 500, which also happens to be
|
|
179
|
+
// supergroup A's tracked status-pin id. Chat-scoped guard keeps them apart.
|
|
180
|
+
const update: PinnedUpdate = {
|
|
181
|
+
chat: { id: -1002222, type: 'supergroup' },
|
|
182
|
+
message_id: 9003,
|
|
183
|
+
message_thread_id: 12,
|
|
184
|
+
pinned_message: { message_id: 500 },
|
|
185
|
+
}
|
|
186
|
+
// 500 IS tracked for chat -1002222 too in this fixture, so this is a
|
|
187
|
+
// genuine match — assert the positive, then flip to a true cross-chat miss.
|
|
188
|
+
expect(wouldDelete(update)).toBe(true)
|
|
189
|
+
|
|
190
|
+
// A supergroup NOT in the tracked set, colliding on id 500 → must NOT fire.
|
|
191
|
+
const foreign: PinnedUpdate = {
|
|
192
|
+
chat: { id: -1009999, type: 'supergroup' },
|
|
193
|
+
message_id: 9004,
|
|
194
|
+
message_thread_id: 5,
|
|
195
|
+
pinned_message: { message_id: 500 },
|
|
196
|
+
}
|
|
197
|
+
expect(wouldDelete(foreign)).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('the handler logs a missing-admin-right hint on delete failure (supergroup)', () => {
|
|
201
|
+
// Structural: the catch block names the likely supergroup cause so an
|
|
202
|
+
// operator isn't left with silent nothing.
|
|
203
|
+
const src = readFileSync(
|
|
204
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
205
|
+
'utf8',
|
|
206
|
+
)
|
|
207
|
+
expect(src).toContain('could not delete pin service message')
|
|
208
|
+
expect(src).toContain('missing can_delete_messages')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
loadStatusPins,
|
|
4
|
+
persistStatusPins,
|
|
5
|
+
pinnedMessageIsOurs,
|
|
6
|
+
reconcileAndPersistStatusPin,
|
|
7
|
+
runStatusPinBootCleanup,
|
|
8
|
+
type PersistedStatusPin,
|
|
9
|
+
type StatusPinStoreFsSeam,
|
|
10
|
+
type TrackedStatusPin,
|
|
11
|
+
} from "../gateway/status-pin-store.js";
|
|
12
|
+
|
|
13
|
+
/** In-memory fs seam with an atomic rename, so the store's tmp→rename
|
|
14
|
+
* crash-safety contract is exercised without touching the real disk. */
|
|
15
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
16
|
+
const files = new Map<string, string>(Object.entries(seed));
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
const fs: StatusPinStoreFsSeam = {
|
|
19
|
+
readFileSync: (p) => {
|
|
20
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`);
|
|
21
|
+
return files.get(p)!;
|
|
22
|
+
},
|
|
23
|
+
writeFileSync: (p, d) => {
|
|
24
|
+
calls.push(`write:${p}`);
|
|
25
|
+
files.set(p, d);
|
|
26
|
+
},
|
|
27
|
+
renameSync: (a, b) => {
|
|
28
|
+
calls.push(`rename:${a}->${b}`);
|
|
29
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`);
|
|
30
|
+
files.set(b, files.get(a)!);
|
|
31
|
+
files.delete(a);
|
|
32
|
+
},
|
|
33
|
+
existsSync: (p) => files.has(p),
|
|
34
|
+
};
|
|
35
|
+
return { fs, files, calls };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PATH = "/state/agent/telegram/status-pins.json";
|
|
39
|
+
|
|
40
|
+
function pin(over: Partial<PersistedStatusPin> = {}): PersistedStatusPin {
|
|
41
|
+
return { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, ...over };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("status-pin-store", () => {
|
|
45
|
+
it("round-trips the pin claim set", () => {
|
|
46
|
+
const { fs } = memFs();
|
|
47
|
+
const snap: PersistedStatusPin[] = [
|
|
48
|
+
pin({ pinKey: "fg:c:3", messageId: 715 }),
|
|
49
|
+
pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
|
|
50
|
+
];
|
|
51
|
+
persistStatusPins(PATH, fs, snap);
|
|
52
|
+
expect(loadStatusPins(PATH, fs)).toEqual(snap);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("writes via tmp + atomic rename (crash-safe)", () => {
|
|
56
|
+
const { fs, calls } = memFs();
|
|
57
|
+
persistStatusPins(PATH, fs, [pin()]);
|
|
58
|
+
expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("persist-on-pin: a new claim is reflected on disk", () => {
|
|
62
|
+
const { fs } = memFs();
|
|
63
|
+
// Simulate reconcile writing state, then snapshotting.
|
|
64
|
+
persistStatusPins(PATH, fs, [pin({ pinKey: "fg:c:3", messageId: 715 })]);
|
|
65
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
66
|
+
expect(loaded).toHaveLength(1);
|
|
67
|
+
expect(loaded[0].messageId).toBe(715);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("remove-on-unpin: unpinning empties the persisted set", () => {
|
|
71
|
+
const { fs } = memFs();
|
|
72
|
+
persistStatusPins(PATH, fs, [pin()]);
|
|
73
|
+
expect(loadStatusPins(PATH, fs)).toHaveLength(1);
|
|
74
|
+
// Reconcile deleted the key → snapshot of the now-empty Map.
|
|
75
|
+
persistStatusPins(PATH, fs, []);
|
|
76
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("loads [] when no file exists (fresh boot)", () => {
|
|
80
|
+
const { fs } = memFs();
|
|
81
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("fail-open: corrupt JSON loads as [] (never crashes boot)", () => {
|
|
85
|
+
const { fs } = memFs({ [PATH]: "{not json" });
|
|
86
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("fail-open: wrong envelope version / shape loads as []", () => {
|
|
90
|
+
// v3 is an unknown FUTURE version (v1 + v2 are the supported set).
|
|
91
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
|
|
92
|
+
expect(loadStatusPins(PATH, f1)).toEqual([]);
|
|
93
|
+
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
|
|
94
|
+
expect(loadStatusPins(PATH, f2)).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drops malformed rows but keeps valid ones", () => {
|
|
98
|
+
const { fs } = memFs({
|
|
99
|
+
[PATH]: JSON.stringify({
|
|
100
|
+
v: 1,
|
|
101
|
+
pins: [
|
|
102
|
+
pin({ messageId: 715 }),
|
|
103
|
+
{ pinKey: "", chatId: "c", messageId: 1 }, // empty pinKey → dropped
|
|
104
|
+
{ pinKey: "k", chatId: "", messageId: 1 }, // empty chatId → dropped
|
|
105
|
+
{ pinKey: "k", chatId: "c" }, // missing messageId → dropped
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
110
|
+
expect(loaded).toHaveLength(1);
|
|
111
|
+
expect(loaded[0].messageId).toBe(715);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("persist never throws on a failing fs (durability degrades, live path unaffected)", () => {
|
|
115
|
+
const throwingFs: StatusPinStoreFsSeam = {
|
|
116
|
+
readFileSync: () => {
|
|
117
|
+
throw new Error("boom");
|
|
118
|
+
},
|
|
119
|
+
writeFileSync: () => {
|
|
120
|
+
throw new Error("disk full");
|
|
121
|
+
},
|
|
122
|
+
renameSync: () => {
|
|
123
|
+
throw new Error("nope");
|
|
124
|
+
},
|
|
125
|
+
existsSync: () => false,
|
|
126
|
+
};
|
|
127
|
+
const logs: string[] = [];
|
|
128
|
+
expect(() =>
|
|
129
|
+
persistStatusPins(PATH, throwingFs, [pin()], (l) => logs.push(l)),
|
|
130
|
+
).not.toThrow();
|
|
131
|
+
expect(logs.join("")).toContain("persist FAILED");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Chat-scoped ownership guard (BUG 1). message_ids are per-chat small integers
|
|
137
|
+
* and the gateway tracks many pins across chats/topics simultaneously — the
|
|
138
|
+
* guard MUST match on messageId AND chatId, never messageId alone.
|
|
139
|
+
*/
|
|
140
|
+
describe("pinnedMessageIsOurs — chat-scoped guard", () => {
|
|
141
|
+
const tracked: TrackedStatusPin[] = [
|
|
142
|
+
{ chatId: "-100AAA", messageId: 715 }, // chat A
|
|
143
|
+
{ chatId: "-100BBB", messageId: 42 }, // chat B, different id
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
it("matches a genuine same-chat + same-id pin", () => {
|
|
147
|
+
expect(pinnedMessageIsOurs(tracked, "-100AAA", 715)).toBe(true);
|
|
148
|
+
expect(pinnedMessageIsOurs(tracked, "-100BBB", 42)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does NOT match when the id collides but the chat differs (the bug)", () => {
|
|
152
|
+
// Chat B receives a pinned_message update whose id (715) collides with
|
|
153
|
+
// chat A's tracked status pin. A messageId-only guard would wrongly return
|
|
154
|
+
// true and delete chat B's (possibly operator-manual) service message.
|
|
155
|
+
expect(pinnedMessageIsOurs(tracked, "-100BBB", 715)).toBe(false);
|
|
156
|
+
// Symmetric: chat A with chat B's id.
|
|
157
|
+
expect(pinnedMessageIsOurs(tracked, "-100AAA", 42)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does NOT match an untracked id in a tracked chat", () => {
|
|
161
|
+
expect(pinnedMessageIsOurs(tracked, "-100AAA", 999)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does NOT match against an empty tracked set", () => {
|
|
165
|
+
expect(pinnedMessageIsOurs([], "-100AAA", 715)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Boot-orphan cleanup — calls the REAL `runStatusPinBootCleanup` (extracted
|
|
171
|
+
* pure over injected fs + unpin seams) so ordering / contract regressions are
|
|
172
|
+
* caught here, not just structurally.
|
|
173
|
+
*/
|
|
174
|
+
describe("runStatusPinBootCleanup", () => {
|
|
175
|
+
it("unpins every persisted entry and empties the store", async () => {
|
|
176
|
+
const { fs } = memFs();
|
|
177
|
+
const orphans: PersistedStatusPin[] = [
|
|
178
|
+
pin({ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 }),
|
|
179
|
+
pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
|
|
180
|
+
];
|
|
181
|
+
persistStatusPins(PATH, fs, orphans);
|
|
182
|
+
|
|
183
|
+
const unpinned: Array<[string, number]> = [];
|
|
184
|
+
const res = await runStatusPinBootCleanup({
|
|
185
|
+
path: PATH,
|
|
186
|
+
fs,
|
|
187
|
+
unpin: async (chatId, messageId) => {
|
|
188
|
+
unpinned.push([chatId, messageId]);
|
|
189
|
+
},
|
|
190
|
+
log: () => {},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(unpinned).toEqual([
|
|
194
|
+
["-100123", 715],
|
|
195
|
+
["-100999", 42],
|
|
196
|
+
]);
|
|
197
|
+
expect(res).toEqual({ cleared: 2, total: 2 });
|
|
198
|
+
// Store empty afterwards → no re-attempt next boot.
|
|
199
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("a failing unpin is non-fatal and the store is still emptied", async () => {
|
|
203
|
+
const { fs } = memFs();
|
|
204
|
+
persistStatusPins(PATH, fs, [
|
|
205
|
+
pin({ pinKey: "fg:c:1", chatId: "-100123", messageId: 5 }),
|
|
206
|
+
pin({ pinKey: "fg:c:2", chatId: "-100123", messageId: 6 }),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const res = await runStatusPinBootCleanup({
|
|
210
|
+
path: PATH,
|
|
211
|
+
fs,
|
|
212
|
+
unpin: async (_c, messageId) => {
|
|
213
|
+
if (messageId === 5) throw new Error("chat gone");
|
|
214
|
+
},
|
|
215
|
+
log: () => {},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// One failed, one succeeded — still non-fatal, store still emptied.
|
|
219
|
+
expect(res).toEqual({ cleared: 1, total: 2 });
|
|
220
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("no-op on a fresh boot with no persisted pins (no unpin calls)", async () => {
|
|
224
|
+
const { fs } = memFs();
|
|
225
|
+
let calls = 0;
|
|
226
|
+
const res = await runStatusPinBootCleanup({
|
|
227
|
+
path: PATH,
|
|
228
|
+
fs,
|
|
229
|
+
unpin: async () => {
|
|
230
|
+
calls++;
|
|
231
|
+
},
|
|
232
|
+
log: () => {},
|
|
233
|
+
});
|
|
234
|
+
expect(res).toEqual({ cleared: 0, total: 0 });
|
|
235
|
+
expect(calls).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("unpins a PENDING record too (crash in the persist-after-pin window)", async () => {
|
|
239
|
+
// Fix 1: a pin that landed in Telegram but whose confirming rewrite never
|
|
240
|
+
// ran leaves a `pending` record. Boot cleanup must treat it like a confirmed
|
|
241
|
+
// pin and unpin it — otherwise the orphan lingers forever.
|
|
242
|
+
const { fs } = memFs();
|
|
243
|
+
persistStatusPins(PATH, fs, [
|
|
244
|
+
pin({ pinKey: "fg:c:9", chatId: "-100777", messageId: 314, pending: true }),
|
|
245
|
+
]);
|
|
246
|
+
const unpinned: Array<[string, number]> = [];
|
|
247
|
+
const res = await runStatusPinBootCleanup({
|
|
248
|
+
path: PATH,
|
|
249
|
+
fs,
|
|
250
|
+
unpin: async (chatId, messageId) => {
|
|
251
|
+
unpinned.push([chatId, messageId]);
|
|
252
|
+
},
|
|
253
|
+
log: () => {},
|
|
254
|
+
});
|
|
255
|
+
expect(unpinned).toEqual([["-100777", 314]]);
|
|
256
|
+
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
257
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Envelope versioning — a v1 snapshot (no `pending` field) must still load
|
|
263
|
+
* fail-open as a confirmed pin, and v2 must round-trip the `pending` flag.
|
|
264
|
+
*/
|
|
265
|
+
describe("status-pin-store — envelope version compat", () => {
|
|
266
|
+
it("loads a legacy v1 snapshot (no pending field) as confirmed pins", () => {
|
|
267
|
+
const { fs } = memFs({
|
|
268
|
+
[PATH]: JSON.stringify({
|
|
269
|
+
v: 1,
|
|
270
|
+
pins: [{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 }],
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
274
|
+
expect(loaded).toHaveLength(1);
|
|
275
|
+
expect(loaded[0].pending).toBeUndefined();
|
|
276
|
+
expect(loaded[0].messageId).toBe(715);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("round-trips a v2 pending flag and writes v2 envelopes", () => {
|
|
280
|
+
const { fs, files } = memFs();
|
|
281
|
+
persistStatusPins(PATH, fs, [pin({ pending: true })]);
|
|
282
|
+
expect(JSON.parse(files.get(PATH)!).v).toBe(2);
|
|
283
|
+
expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("rejects an unknown future envelope version (fail-open [])", () => {
|
|
287
|
+
const { fs } = memFs({ [PATH]: JSON.stringify({ v: 3, pins: [pin()] }) });
|
|
288
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("drops a row with a non-boolean pending field", () => {
|
|
292
|
+
const { fs } = memFs({
|
|
293
|
+
[PATH]: JSON.stringify({
|
|
294
|
+
v: 2,
|
|
295
|
+
pins: [
|
|
296
|
+
pin({ messageId: 715 }),
|
|
297
|
+
{ pinKey: "k", chatId: "c", messageId: 1, pending: "yes" },
|
|
298
|
+
],
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
302
|
+
expect(loaded).toHaveLength(1);
|
|
303
|
+
expect(loaded[0].messageId).toBe(715);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* reconcileAndPersistStatusPin — the persist-BEFORE-pin ordering that closes the
|
|
309
|
+
* leak window (Fix 1). These exercise the REAL wrapper the gateway calls, with
|
|
310
|
+
* an fs seam that can crash at a chosen write so we can prove the leak is
|
|
311
|
+
* recovered (red→green against the pre-fix persist-after ordering).
|
|
312
|
+
*/
|
|
313
|
+
describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
314
|
+
it("writes a PENDING record BEFORE the pin API call", async () => {
|
|
315
|
+
const { fs } = memFs();
|
|
316
|
+
const order: string[] = [];
|
|
317
|
+
// Record what's on disk at the moment the pin API is invoked.
|
|
318
|
+
let onDiskAtPin: PersistedStatusPin[] = [];
|
|
319
|
+
const next = await reconcileAndPersistStatusPin({
|
|
320
|
+
path: PATH,
|
|
321
|
+
fs,
|
|
322
|
+
pinKey: "fg:c:3",
|
|
323
|
+
chatId: "-100123",
|
|
324
|
+
op: { kind: "pin", messageId: 715 },
|
|
325
|
+
snapshotOthers: () => [],
|
|
326
|
+
applyPin: async () => {
|
|
327
|
+
order.push("pin-api");
|
|
328
|
+
onDiskAtPin = loadStatusPins(PATH, fs);
|
|
329
|
+
return { messageId: 715 };
|
|
330
|
+
},
|
|
331
|
+
log: () => {},
|
|
332
|
+
});
|
|
333
|
+
// At the moment the pin API ran, a pending record was already on disk.
|
|
334
|
+
expect(onDiskAtPin).toEqual([
|
|
335
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
336
|
+
]);
|
|
337
|
+
// After success it's confirmed (pending cleared).
|
|
338
|
+
expect(next).toEqual({ messageId: 715 });
|
|
339
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
340
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("RED→GREEN: a crash between pin-lands and confirm leaves a recoverable pending record", async () => {
|
|
345
|
+
// Simulate SIGKILL: the pin API lands in Telegram, then the process dies
|
|
346
|
+
// before the confirming rewrite. We model the crash by throwing out of
|
|
347
|
+
// applyPin AFTER it 'pinned' — the pending record must survive on disk.
|
|
348
|
+
const { fs } = memFs();
|
|
349
|
+
let pinnedInTelegram: number | null = null;
|
|
350
|
+
await expect(
|
|
351
|
+
reconcileAndPersistStatusPin({
|
|
352
|
+
path: PATH,
|
|
353
|
+
fs,
|
|
354
|
+
pinKey: "fg:c:3",
|
|
355
|
+
chatId: "-100123",
|
|
356
|
+
op: { kind: "pin", messageId: 715 },
|
|
357
|
+
snapshotOthers: () => [],
|
|
358
|
+
applyPin: async () => {
|
|
359
|
+
pinnedInTelegram = 715; // the pin landed in Telegram
|
|
360
|
+
throw new Error("SIGKILL — process died before confirm rewrite");
|
|
361
|
+
},
|
|
362
|
+
log: () => {},
|
|
363
|
+
}),
|
|
364
|
+
).rejects.toThrow("SIGKILL");
|
|
365
|
+
|
|
366
|
+
// The pin is live in Telegram...
|
|
367
|
+
expect(pinnedInTelegram).toBe(715);
|
|
368
|
+
// ...AND a pending record survived on disk (this is what the pre-fix
|
|
369
|
+
// persist-AFTER-pin ordering FAILED to do — the store would have been empty
|
|
370
|
+
// and boot cleanup blind to the orphan).
|
|
371
|
+
const persisted = loadStatusPins(PATH, fs);
|
|
372
|
+
expect(persisted).toEqual([
|
|
373
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
// Next boot: cleanup unpins the orphan using the pending record.
|
|
377
|
+
const unpinned: Array<[string, number]> = [];
|
|
378
|
+
await runStatusPinBootCleanup({
|
|
379
|
+
path: PATH,
|
|
380
|
+
fs,
|
|
381
|
+
unpin: async (c, m) => {
|
|
382
|
+
unpinned.push([c, m]);
|
|
383
|
+
},
|
|
384
|
+
log: () => {},
|
|
385
|
+
});
|
|
386
|
+
expect(unpinned).toEqual([["-100123", 715]]);
|
|
387
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("clears the pending record when the pin API fails (no phantom claim)", async () => {
|
|
391
|
+
const { fs } = memFs();
|
|
392
|
+
const next = await reconcileAndPersistStatusPin({
|
|
393
|
+
path: PATH,
|
|
394
|
+
fs,
|
|
395
|
+
pinKey: "fg:c:3",
|
|
396
|
+
chatId: "-100123",
|
|
397
|
+
op: { kind: "pin", messageId: 715 },
|
|
398
|
+
snapshotOthers: () => [],
|
|
399
|
+
// reconcilePin returns null (prevState) when the pin API throws.
|
|
400
|
+
applyPin: async () => null,
|
|
401
|
+
log: () => {},
|
|
402
|
+
});
|
|
403
|
+
expect(next).toBeNull();
|
|
404
|
+
// Pending record dropped — nothing to leak, nothing was pinned.
|
|
405
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("preserves OTHER live claims while flipping one key pending→confirmed", async () => {
|
|
409
|
+
const { fs } = memFs();
|
|
410
|
+
const other: PersistedStatusPin = {
|
|
411
|
+
pinKey: "wk:agent-x",
|
|
412
|
+
chatId: "-100999",
|
|
413
|
+
messageId: 42,
|
|
414
|
+
};
|
|
415
|
+
await reconcileAndPersistStatusPin({
|
|
416
|
+
path: PATH,
|
|
417
|
+
fs,
|
|
418
|
+
pinKey: "fg:c:3",
|
|
419
|
+
chatId: "-100123",
|
|
420
|
+
op: { kind: "pin", messageId: 715 },
|
|
421
|
+
snapshotOthers: () => [other],
|
|
422
|
+
applyPin: async () => ({ messageId: 715 }),
|
|
423
|
+
log: () => {},
|
|
424
|
+
});
|
|
425
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
426
|
+
expect(loaded).toContainEqual(other);
|
|
427
|
+
expect(loaded).toContainEqual({
|
|
428
|
+
pinKey: "fg:c:3",
|
|
429
|
+
chatId: "-100123",
|
|
430
|
+
messageId: 715,
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("clear op: unpins then drops the record (post-pin ordering is safe)", async () => {
|
|
435
|
+
const { fs } = memFs();
|
|
436
|
+
persistStatusPins(PATH, fs, [pin({ pinKey: "fg:c:3", messageId: 715 })]);
|
|
437
|
+
const next = await reconcileAndPersistStatusPin({
|
|
438
|
+
path: PATH,
|
|
439
|
+
fs,
|
|
440
|
+
pinKey: "fg:c:3",
|
|
441
|
+
chatId: "-100123",
|
|
442
|
+
op: { kind: "clear" },
|
|
443
|
+
snapshotOthers: () => [],
|
|
444
|
+
applyPin: async () => null,
|
|
445
|
+
log: () => {},
|
|
446
|
+
});
|
|
447
|
+
expect(next).toBeNull();
|
|
448
|
+
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
449
|
+
});
|
|
450
|
+
});
|