switchroom 0.19.18 → 0.19.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,145 @@
1
+ /**
2
+ * status-pin-api.ts — the Bot API surface the status-pin driver calls, bound to
3
+ * the gateway's retry policy + send gate (#3664).
4
+ *
5
+ * Extracted out of `gateway.ts` (which is under a line ratchet,
6
+ * `scripts/check-gateway-line-ratchet.mjs`) so the two invariants below can be
7
+ * enforced in code and proven in isolation, instead of resting on defaults that
8
+ * live in other modules.
9
+ *
10
+ * INVARIANT 1 — the bot must exist (`assertBotReady`).
11
+ * `lockedBot` in gateway.ts is declared `let lockedBot!: Bot<Context>`. The
12
+ * definite-assignment `!` is an ASSERTION, not a guarantee: it told `tsc` to
13
+ * stop checking, which is exactly why the boot orphan sweep dereferencing an
14
+ * unset `lockedBot` at module-eval time compiled clean and shipped. Every unpin
15
+ * it issued failed with the opaque
16
+ * `undefined is not an object (evaluating 'lockedBot.api')`, which read like a
17
+ * Telegram fault for a month. The ordering FIX is the two-condition gate in
18
+ * `boot-sweep-gate.ts`; this is the BACKSTOP, so any future pre-ready caller
19
+ * gets a named, greppable `STATUS_PIN_BOT_NOT_READY` instead.
20
+ *
21
+ * INVARIANT 2 — a shed send must not look like a landed one (`assertLanded`).
22
+ * See the docblock on `assertLanded`.
23
+ */
24
+
25
+ import type { PinBotApi } from '../status-pin-driver.js'
26
+ import { SEND_GATE_SHED } from '../send-gate.js'
27
+
28
+ /** Thrown when the pin API is used before `lockedBot` has been assigned. */
29
+ export class BotNotReadyError extends Error {
30
+ constructor(what: string) {
31
+ super(
32
+ `STATUS_PIN_BOT_NOT_READY: ${what} used before initGatewayBot() assigned ` +
33
+ `lockedBot — the boot sweep must run behind the boot-sweep gate`,
34
+ )
35
+ this.name = 'BotNotReadyError'
36
+ }
37
+ }
38
+
39
+ /** Narrow `bot` to non-null or throw {@link BotNotReadyError}. */
40
+ export function assertBotReady<T>(bot: T | undefined | null, what: string): T {
41
+ if (bot == null) throw new BotNotReadyError(what)
42
+ return bot
43
+ }
44
+
45
+ /** Thrown when the outbound send gate SHED a pin/unpin — it never reached Telegram. */
46
+ export class SendGateShedError extends Error {
47
+ constructor(verb: string) {
48
+ super(`STATUS_PIN_SEND_SHED: ${verb} was shed by the send gate and never reached Telegram`)
49
+ this.name = 'SendGateShedError'
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Convert a send-gate SHED into a throw.
55
+ *
56
+ * The pin state machine reads "the call did not throw" as "the pin/unpin
57
+ * LANDED": `reconcilePin` claims the message on a resolved pin, and treats a
58
+ * resolved unpin as confirmed — dropping the in-memory claim AND (via the null
59
+ * branch of `reconcileAndPersistStatusPin`) the durable `status-pins.json` row.
60
+ * A send that was DROPPED but resolved success-shaped therefore reopens #3664
61
+ * Defect B through a second door: the still-pinned message loses its last
62
+ * record and no reaper or boot sweep can ever find it again.
63
+ *
64
+ * `robustApiCall` does not throw on a shed — it RESOLVES the gate's
65
+ * `SEND_GATE_SHED` sentinel (send-gate.ts). Today a status-pin call
66
+ * cannot be shed, because it is an untagged SEND and untagged sends admit as
67
+ * `UNTAGGED_SEND_CLASS`, which is `'critical'` and is never shed.
68
+ * But that is a DEFAULT IN ANOTHER MODULE: tagging `status-pin.unpin` with a
69
+ * `priorityClass`, or changing that default, would silently reopen the defect
70
+ * with no test failing. This turns the default into an enforced invariant.
71
+ *
72
+ * Sentinel ONLY. A plain `undefined` is deliberately NOT a failure: the gate
73
+ * also resolves `undefined` for a benign no-op drop (identical payload already
74
+ * on screen) and the retry policy resolves `undefined` for swallowed benign
75
+ * 400s. Conflating those with a shed is the exact ambiguity `SEND_GATE_SHED`
76
+ * was introduced to remove (see its docblock in send-gate.ts).
77
+ *
78
+ * Known residual: a `useful`-classed send whose queue TTL EXPIRES also resolves
79
+ * `undefined` (`send-gate.ts`, `outcome.result === 'expired'`) and so is not
80
+ * caught here. That is unreachable for status pins — they are untagged, hence
81
+ * `critical`, which is never TTL-dropped — and closing it would mean treating
82
+ * every benign `undefined` as a failure, which is strictly worse. If a status
83
+ * pin is ever deliberately tagged `useful`, this needs a distinguishable
84
+ * expiry sentinel too.
85
+ */
86
+ export function assertLanded(result: unknown, verb: string): unknown {
87
+ if (result === SEND_GATE_SHED) throw new SendGateShedError(verb)
88
+ return result
89
+ }
90
+
91
+ /** Minimal shape of the wrapped gateway bot this module needs. */
92
+ export interface PinCapableBot {
93
+ api: {
94
+ pinChatMessage: (
95
+ chatId: string | number,
96
+ messageId: number,
97
+ opts?: Record<string, unknown>,
98
+ ) => Promise<unknown>
99
+ unpinChatMessage: (chatId: string | number, messageId: number) => Promise<unknown>
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The gateway's `robustApiCall` seam (retry policy + send gate), erased to the
105
+ * shape this module needs so `gateway.ts` can pass it with a single cast.
106
+ */
107
+ export type RobustApiSeam = (
108
+ fn: () => Promise<unknown>,
109
+ opts: Record<string, unknown>,
110
+ ) => Promise<unknown>
111
+
112
+ /**
113
+ * Build the pin API.
114
+ *
115
+ * `getBot` is read LAZILY on every call — `lockedBot` is assigned late, inside
116
+ * `initGatewayBot()` — and asserted non-null (INVARIANT 1). `robust` is the
117
+ * gateway's `robustApiCall`, so pins/unpins ride the send gate and retry policy
118
+ * exactly as before this extraction, with the shed sentinel converted to a
119
+ * throw on the way out (INVARIANT 2).
120
+ */
121
+ export function createStatusPinApi(
122
+ getBot: () => PinCapableBot | undefined,
123
+ robust: RobustApiSeam,
124
+ ): PinBotApi {
125
+ const call = (verb: string, fn: (bot: PinCapableBot) => Promise<unknown>, chatId: string) =>
126
+ robust(() => fn(assertBotReady(getBot(), verb)), { chat_id: chatId, verb }).then((r) =>
127
+ assertLanded(r, verb),
128
+ )
129
+ return {
130
+ pinChatMessage: (chat_id, message_id, opts) =>
131
+ call(
132
+ 'status-pin.pin',
133
+ // allow-raw-bot-api: `call` runs this inside the injected `robust` seam (robustApiCall).
134
+ (bot) => bot.api.pinChatMessage(chat_id, message_id, opts),
135
+ String(chat_id),
136
+ ),
137
+ unpinChatMessage: (chat_id, message_id) =>
138
+ call(
139
+ 'status-pin.unpin',
140
+ // allow-raw-bot-api: as above — wrapped by the injected `robust` seam.
141
+ (bot) => bot.api.unpinChatMessage(chat_id, message_id),
142
+ String(chat_id),
143
+ ),
144
+ }
145
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Shared read side for the gateway's small JSON state stores
3
+ * (`pending-card-store.ts`, `scoped-grant-store.ts`,
4
+ * `missed-approvals-store.ts`, `always-allow-persist-queue.ts`).
5
+ *
6
+ * Problem: each of those stores read its backing file with a bare
7
+ * `try { JSON.parse(readFileSync(...)) } catch { return [] }`. Combined
8
+ * with a non-atomic `writeFileSync` straight over the destination (fixed
9
+ * separately — every writer now goes through `atomicWriteFileSync`), a
10
+ * crash between truncate and write left a TORN file. On the next boot the
11
+ * parse threw, the catch swallowed it, and the store came up EMPTY: every
12
+ * pending approval card and every live scoped grant silently forgotten,
13
+ * with nothing in the logs. The operator just saw dead buttons.
14
+ *
15
+ * Losing security state must be OBSERVABLE. So a parse failure here is
16
+ * never silent:
17
+ * - the corrupt bytes are preserved by renaming them aside to
18
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>` (never deleted while they are the
19
+ * newest few — they're the forensic record of what was lost, and the
20
+ * only chance of manual recovery);
21
+ * - a loud `telegram gateway: <store> CORRUPT …` line goes to stderr,
22
+ * the same channel the surrounding stores already log write failures
23
+ * on (the gateway's stderr is captured in the agent's runtime log);
24
+ * - the caller gets a non-`ok` result and starts from an empty store —
25
+ * the process still boots, it just no longer does so in silence.
26
+ *
27
+ * Three read outcomes, deliberately distinguished:
28
+ * - `missing` — the normal cold start. Silent, no quarantine.
29
+ * - `corrupt` — unparseable JSON (or a shape the store rejects, via
30
+ * `quarantineCorruptStoreFile`). Quarantine + loud log.
31
+ * - `unreadable` — any other fs error (EACCES, EIO, EISDIR, …). Loud log,
32
+ * but NO quarantine: renaming a file we merely failed to
33
+ * read would destroy good state over a transient fault.
34
+ * Instead the caller FAILS CLOSED on its next write —
35
+ * see `preserveUnreadableStoreFile` below.
36
+ *
37
+ * ── Known hazard: two writers ──────────────────────────────────────────
38
+ * Quarantine is a destructive-looking move (it renames the destination
39
+ * away), and it is NOT safe against a second process writing the same
40
+ * file: process A can parse-fail on a torn file, process B can then
41
+ * atomically rename GOOD state into place, and A's quarantine would move
42
+ * B's good file aside. Pre-fix a parse failure was inert, so this hazard is
43
+ * introduced here. It is accepted because the gateway is (near-)singleton
44
+ * per agent — see the `withLock` note in `always-allow-persist-queue.ts`
45
+ * for why "near", and note the window is the few microseconds between the
46
+ * failed parse and the rename. If a genuine multi-writer arrangement ever
47
+ * appears, quarantine must become an flock-guarded compare-and-rename.
48
+ */
49
+
50
+ import { randomBytes } from 'node:crypto'
51
+ import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
52
+ import { basename, dirname, join } from 'node:path'
53
+
54
+ /** Default sink — matches the stores' existing `process.stderr.write` use. */
55
+ const defaultLog = (line: string): void => {
56
+ process.stderr.write(line)
57
+ }
58
+
59
+ /**
60
+ * How many `<file>.corrupt-*` forensic copies to keep per store file.
61
+ * They're tiny, but a store that corrupts on a loop must not fill the
62
+ * state dir — so the oldest are reaped past this cap. Newest wins.
63
+ */
64
+ export const MAX_QUARANTINED_COPIES = 5
65
+
66
+ /** Outcome of a store-file read. See the module docblock. */
67
+ export type StoreReadResult =
68
+ | { status: 'ok'; value: unknown }
69
+ | { status: 'missing' }
70
+ | { status: 'corrupt' }
71
+ | { status: 'unreadable' }
72
+
73
+ /**
74
+ * Monotonic within this process. Purely a name-uniqueness aid now — the
75
+ * reaper orders by mtime, NOT by name (see `reapOldQuarantines`), because a
76
+ * name-based order is only monotonic within one process: a restart resets
77
+ * this counter to 0, and a fresh copy written in the same epoch-ms as a
78
+ * prior process's burst would sort as the OLDEST and be reaped first.
79
+ */
80
+ let quarantineSeq = 0
81
+
82
+ /**
83
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>`.
84
+ *
85
+ * All three tail components exist to make the name UNIQUE: two quarantines
86
+ * in the same millisecond, or from two processes, would otherwise collide
87
+ * and the second `renameSync` would silently clobber the first forensic copy
88
+ * — same reasoning as the tempfile naming in `src/util/atomic.ts`. The name
89
+ * is deliberately NOT the ordering key; mtime is.
90
+ */
91
+ function quarantinePath(filePath: string): string {
92
+ const seq = String(quarantineSeq++).padStart(6, '0')
93
+ return `${filePath}.corrupt-${Date.now()}-${seq}-${randomBytes(4).toString('hex')}`
94
+ }
95
+
96
+ /**
97
+ * Reap all but the newest {@link MAX_QUARANTINED_COPIES} forensic copies.
98
+ *
99
+ * Ordered by the filesystem's own mtime (nanosecond resolution via the
100
+ * bigint stat) — unlike the embedded `<epoch-ms>-<seq>`, whose sequence
101
+ * restarts at 0 on every boot and would make a fresh copy sort as the oldest
102
+ * whenever a restart lands in the same millisecond as the previous process's
103
+ * burst. The name is used only as a stable tie-break.
104
+ *
105
+ * Where the guarantee ends: this is exact only on filesystems that record
106
+ * mtime at (sub-)nanosecond granularity — ext4, xfs, apfs, btrfs all do. On a
107
+ * coarse-granularity filesystem (older ext3, some network/FUSE mounts, where
108
+ * mtime rounds to a whole second) a burst of more than MAX_QUARANTINED_COPIES
109
+ * quarantines inside one second all tie on `mtimeNs`, the sort falls through
110
+ * to the name, and the restart-reset ordering above comes back: the prior
111
+ * process burns seq 000001-000005 in second T, a restart quarantines at
112
+ * 000000, all six tie, and the freshest copy is reaped first. That costs a
113
+ * forensic copy on a rare filesystem and never live state, so it is not
114
+ * defended against here — but do not read this reaper as unconditionally
115
+ * restart-safe.
116
+ */
117
+ function reapOldQuarantines(filePath: string, log: (line: string) => void): void {
118
+ const dir = dirname(filePath)
119
+ const prefix = `${basename(filePath)}.corrupt-`
120
+ try {
121
+ const copies = readdirSync(dir)
122
+ .filter(f => f.startsWith(prefix))
123
+ .map(name => {
124
+ let mtimeNs = 0n
125
+ try {
126
+ mtimeNs = statSync(join(dir, name), { bigint: true }).mtimeNs
127
+ } catch {
128
+ // Vanished under us (a concurrent reap) — sorts oldest; the rmSync
129
+ // below is `force`, so a missing file is a no-op either way.
130
+ }
131
+ return { name, mtimeNs }
132
+ })
133
+ .sort((a, b) => (a.mtimeNs === b.mtimeNs ? a.name.localeCompare(b.name) : a.mtimeNs < b.mtimeNs ? -1 : 1))
134
+ for (const stale of copies.slice(0, Math.max(0, copies.length - MAX_QUARANTINED_COPIES))) {
135
+ rmSync(join(dir, stale.name), { force: true })
136
+ }
137
+ } catch (err) {
138
+ // Reaping is opportunistic — never let it mask the corruption itself.
139
+ log(`telegram gateway: quarantine reap failed dir=${dir}: ${(err as Error).message}\n`)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Move a corrupt/unusable store file aside so its bytes survive, and log
145
+ * loudly. Exported for stores whose corruption is a SHAPE failure (valid
146
+ * JSON, wrong structure) that only the store itself can detect.
147
+ */
148
+ export function quarantineCorruptStoreFile(
149
+ filePath: string,
150
+ store: string,
151
+ reason: string,
152
+ log: (line: string) => void = defaultLog,
153
+ ): void {
154
+ const target = quarantinePath(filePath)
155
+ let preserved = target
156
+ try {
157
+ renameSync(filePath, target)
158
+ } catch (err) {
159
+ preserved = `NOT preserved (${(err as Error).message})`
160
+ }
161
+ log(
162
+ `telegram gateway: ${store} CORRUPT — ${reason}. ` +
163
+ `Persisted state was LOST and the store is starting EMPTY; ` +
164
+ `corrupt file ${preserved}\n`,
165
+ )
166
+ reapOldQuarantines(filePath, log)
167
+ }
168
+
169
+ /**
170
+ * FAIL-CLOSED guard for the `unreadable` read outcome.
171
+ *
172
+ * A read that failed for a non-ENOENT reason (a flaky mount returning EIO,
173
+ * a transient EACCES) leaves the store in memory EMPTY while the on-disk
174
+ * file may still hold perfectly good state. The next `save()` would then
175
+ * rename a fresh, near-empty file over it and destroy that state
176
+ * permanently — quietly, and for a fault that may have lasted one syscall.
177
+ *
178
+ * So the stores call this immediately BEFORE their first write following an
179
+ * unreadable read: the existing bytes are renamed aside (same
180
+ * `.corrupt-<ts>-<seq>-<rand>` forensic convention) so the overwrite can never be
181
+ * the thing that loses them. If the rename itself fails the write is
182
+ * ABORTED by throwing — better a loud failed write than a silent
183
+ * destruction of the only copy.
184
+ */
185
+ export function preserveUnreadableStoreFile(
186
+ filePath: string,
187
+ store: string,
188
+ log: (line: string) => void = defaultLog,
189
+ ): void {
190
+ const target = quarantinePath(filePath)
191
+ try {
192
+ renameSync(filePath, target)
193
+ } catch (err) {
194
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
195
+ // Nothing there to lose — the read failure wasn't about existing bytes.
196
+ return
197
+ }
198
+ log(
199
+ `telegram gateway: ${store} REFUSING to overwrite an unreadable file at ` +
200
+ `${filePath} — could not preserve it first: ${(err as Error).message}\n`,
201
+ )
202
+ throw err
203
+ }
204
+ log(
205
+ `telegram gateway: ${store} could not READ its file but is about to write it. ` +
206
+ `Preserved the previous (unreadable) bytes as ${target} rather than ` +
207
+ `overwriting them; the store continues from EMPTY\n`,
208
+ )
209
+ reapOldQuarantines(filePath, log)
210
+ }
211
+
212
+ /**
213
+ * Read + parse a store's backing JSON file. See {@link StoreReadResult} and
214
+ * the module docblock for what each outcome means and what it triggers.
215
+ */
216
+ export function readStoreJsonSync(
217
+ filePath: string,
218
+ store: string,
219
+ log: (line: string) => void = defaultLog,
220
+ ): StoreReadResult {
221
+ let raw: string
222
+ try {
223
+ raw = readFileSync(filePath, 'utf-8')
224
+ } catch (err) {
225
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }
226
+ log(
227
+ `telegram gateway: ${store} read FAILED path=${filePath}: ` +
228
+ `${(err as Error).message} — starting EMPTY for this read; the next write ` +
229
+ `will preserve the unreadable file rather than overwrite it\n`,
230
+ )
231
+ return { status: 'unreadable' }
232
+ }
233
+ try {
234
+ return { status: 'ok', value: JSON.parse(raw) }
235
+ } catch (err) {
236
+ quarantineCorruptStoreFile(
237
+ filePath,
238
+ store,
239
+ `unparseable JSON (${(err as Error).message}) — likely a torn write from a crash mid-persist`,
240
+ log,
241
+ )
242
+ return { status: 'corrupt' }
243
+ }
244
+ }