switchroom 0.14.0 → 0.14.1
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/auth-broker/index.js +16 -1
- package/dist/cli/switchroom.js +1082 -873
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +47 -1
- package/telegram-plugin/dist/gateway/gateway.js +967 -542
- package/telegram-plugin/gateway/boot-card.ts +100 -0
- package/telegram-plugin/gateway/config-snapshot.ts +274 -0
- package/telegram-plugin/gateway/gateway.ts +221 -14
- package/telegram-plugin/operator-events.ts +2 -10
- package/telegram-plugin/quota-watch.ts +276 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +133 -1
- package/telegram-plugin/tests/boot-card-render.test.ts +93 -0
- package/telegram-plugin/tests/config-snapshot.test.ts +409 -0
- package/telegram-plugin/tests/operator-events.test.ts +12 -6
- package/telegram-plugin/tests/quota-watch.test.ts +366 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +48 -0
- package/telegram-plugin/turn-flush-safety.ts +47 -0
- package/telegram-plugin/uat/assertions.ts +4 -4
|
@@ -57,8 +57,18 @@ import {
|
|
|
57
57
|
applyAndSave as saveBootIssueCache,
|
|
58
58
|
type ProbeDiffMap,
|
|
59
59
|
} from './boot-issue-cache.js'
|
|
60
|
+
import {
|
|
61
|
+
loadSnapshot as loadConfigSnapshot,
|
|
62
|
+
persistSnapshot as persistConfigSnapshot,
|
|
63
|
+
captureConfigSnapshot,
|
|
64
|
+
diffSnapshots as diffConfigSnapshots,
|
|
65
|
+
renderConfigChangeDim,
|
|
66
|
+
type ConfigSnapshot,
|
|
67
|
+
type ConfigDiff,
|
|
68
|
+
} from './config-snapshot.js'
|
|
60
69
|
import { join } from 'path'
|
|
61
70
|
import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
|
|
71
|
+
import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
|
|
62
72
|
|
|
63
73
|
// ─── Persona name resolution ─────────────────────────────────────────────────
|
|
64
74
|
|
|
@@ -298,6 +308,16 @@ export interface RenderBootCardOpts {
|
|
|
298
308
|
* the operator sees what happened with the update that triggered
|
|
299
309
|
* this boot without trawling the audit log. */
|
|
300
310
|
updateOutcomeLine?: string
|
|
311
|
+
/**
|
|
312
|
+
* Config-change dimensions detected since the previous boot.
|
|
313
|
+
* One row per changed field (model, tools, skills, memory backend).
|
|
314
|
+
* Empty array / undefined = no config changes → section is silent,
|
|
315
|
+
* preserving the "silent-when-healthy" boot card contract.
|
|
316
|
+
*
|
|
317
|
+
* Computed by diffing the current config snapshot against the
|
|
318
|
+
* persisted snapshot from the prior boot. See `config-snapshot.ts`.
|
|
319
|
+
*/
|
|
320
|
+
configChanges?: ConfigDiff
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
/**
|
|
@@ -403,6 +423,17 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
403
423
|
? renderAuthLine(opts.accounts, agentName, (opts.now ?? new Date()).getTime())
|
|
404
424
|
: []
|
|
405
425
|
|
|
426
|
+
// Config-change rows (E3: boot card surfaces config changes since last boot).
|
|
427
|
+
// Only rendered when the diff is non-empty — silent on identical boots.
|
|
428
|
+
// Each changed dimension gets its own row; model and memory backend show
|
|
429
|
+
// verbatim before/after; tools and skills show a coarse hash-diff hint.
|
|
430
|
+
const configChangeRows: string[] = []
|
|
431
|
+
if (opts.configChanges && opts.configChanges.length > 0) {
|
|
432
|
+
for (const dim of opts.configChanges) {
|
|
433
|
+
configChangeRows.push(renderConfigChangeDim(dim))
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
406
437
|
const sections: string[] = [ack]
|
|
407
438
|
if (degradedRows.length > 0) sections.push('', ...degradedRows)
|
|
408
439
|
if (accountRows.length > 0) sections.push('', ...accountRows)
|
|
@@ -412,6 +443,9 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
412
443
|
// failure-with-recovery hint cleanly.
|
|
413
444
|
sections.push('', ...opts.updateOutcomeLine.split('\n'))
|
|
414
445
|
}
|
|
446
|
+
if (configChangeRows.length > 0) {
|
|
447
|
+
sections.push('', ...configChangeRows)
|
|
448
|
+
}
|
|
415
449
|
if (sections.length === 1) return ack
|
|
416
450
|
return sections.join('\n')
|
|
417
451
|
}
|
|
@@ -520,6 +554,20 @@ export interface RunProbesOpts {
|
|
|
520
554
|
* failure body with a recovery hint. Append-only — never replaces
|
|
521
555
|
* the existing ack/probe sections. */
|
|
522
556
|
updateOutcomeLine?: string
|
|
557
|
+
/**
|
|
558
|
+
* Path to the per-agent config snapshot file. When set, the post-settle
|
|
559
|
+
* render diffs the current resolved config against the persisted snapshot
|
|
560
|
+
* from the prior boot and appends a row per changed dimension (model,
|
|
561
|
+
* tools allowlist, skills, memory backend).
|
|
562
|
+
*
|
|
563
|
+
* Omit to disable config-change detection entirely (legacy behaviour /
|
|
564
|
+
* test harnesses that don't need it).
|
|
565
|
+
*
|
|
566
|
+
* Typically `<agentDir>/.config-snapshot.json`. Must be provided
|
|
567
|
+
* alongside `agentName` — the capture function reads `agentName` to
|
|
568
|
+
* resolve the default memory collection label.
|
|
569
|
+
*/
|
|
570
|
+
configSnapshotPath?: string
|
|
523
571
|
}
|
|
524
572
|
|
|
525
573
|
/** Run all six probes concurrently with their own per-probe timeouts.
|
|
@@ -670,6 +718,53 @@ export async function startBootCard(
|
|
|
670
718
|
}
|
|
671
719
|
}
|
|
672
720
|
|
|
721
|
+
// Config-snapshot diff (E3): compare this boot's resolved config
|
|
722
|
+
// against the snapshot persisted on the previous boot. Fires once
|
|
723
|
+
// per gateway lifetime — read up-front, write on the way out.
|
|
724
|
+
// Silent on first boot (no prior snapshot) and on identical boots.
|
|
725
|
+
let configChanges: ConfigDiff = []
|
|
726
|
+
if (opts.configSnapshotPath) {
|
|
727
|
+
try {
|
|
728
|
+
const agentSlug = opts.agentSlug ?? opts.agentName
|
|
729
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME ?? agentSlug
|
|
730
|
+
let currentCfg: ConfigSnapshot | undefined
|
|
731
|
+
try {
|
|
732
|
+
const loaded = _loadSwitchroomConfig()
|
|
733
|
+
const rawAgent = loaded.agents?.[agentName] ?? {}
|
|
734
|
+
const resolved = _resolveAgentConfig(loaded.defaults, loaded.profiles, rawAgent)
|
|
735
|
+
currentCfg = captureConfigSnapshot({
|
|
736
|
+
agentName,
|
|
737
|
+
model: resolved.model,
|
|
738
|
+
toolsAllow: resolved.tools?.allow,
|
|
739
|
+
skills: resolved.skills,
|
|
740
|
+
memoryCollection: resolved.memory?.collection,
|
|
741
|
+
})
|
|
742
|
+
} catch {
|
|
743
|
+
// Config unreadable (test env, no switchroom.yaml) — skip diff.
|
|
744
|
+
}
|
|
745
|
+
if (currentCfg != null) {
|
|
746
|
+
const previousSnapshot = loadConfigSnapshot(opts.configSnapshotPath)
|
|
747
|
+
configChanges = diffConfigSnapshots(currentCfg, previousSnapshot)
|
|
748
|
+
// Persist unconditionally so the next boot always has a
|
|
749
|
+
// fresh baseline. A failed persist is non-fatal.
|
|
750
|
+
persistConfigSnapshot(opts.configSnapshotPath, currentCfg)
|
|
751
|
+
if (configChanges.length > 0) {
|
|
752
|
+
logger(
|
|
753
|
+
`telegram gateway: boot-card: config-snapshot diff detected ${configChanges.length} change(s): ${
|
|
754
|
+
configChanges.map(d => d.field).join(', ')
|
|
755
|
+
}\n`,
|
|
756
|
+
)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
} catch (snapErr: unknown) {
|
|
760
|
+
logger(
|
|
761
|
+
`telegram gateway: boot-card: config-snapshot diff failed: ${
|
|
762
|
+
(snapErr as Error)?.message ?? String(snapErr)
|
|
763
|
+
}\n`,
|
|
764
|
+
)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
673
768
|
// Render with current probe state and edit if anything changed.
|
|
674
769
|
let currentText = renderBootCard({
|
|
675
770
|
agentName: opts.agentName,
|
|
@@ -682,6 +777,7 @@ export async function startBootCard(
|
|
|
682
777
|
...(resolvedRows.length > 0 ? { resolvedRows } : {}),
|
|
683
778
|
...(snoozeRows.length > 0 ? { snoozeRows } : {}),
|
|
684
779
|
...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
|
|
780
|
+
...(configChanges.length > 0 ? { configChanges } : {}),
|
|
685
781
|
})
|
|
686
782
|
|
|
687
783
|
if (currentText !== ackText) {
|
|
@@ -734,6 +830,10 @@ export async function startBootCard(
|
|
|
734
830
|
...(resolvedRows.length > 0 ? { resolvedRows } : {}),
|
|
735
831
|
...(snoozeRows.length > 0 ? { snoozeRows } : {}),
|
|
736
832
|
...(opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}),
|
|
833
|
+
// Config-change rows are stable for the lifetime of this card
|
|
834
|
+
// (computed once in Phase 1). Pass them through unchanged so
|
|
835
|
+
// the live-agent-status edits keep the config-diff rows.
|
|
836
|
+
...(configChanges.length > 0 ? { configChanges } : {}),
|
|
737
837
|
})
|
|
738
838
|
|
|
739
839
|
if (updatedText === currentText) continue
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-snapshot cache — captures a fingerprint of each agent's
|
|
3
|
+
* active configuration at gateway boot and diffs against the prior boot.
|
|
4
|
+
*
|
|
5
|
+
* Closes the JTBD "no need to ask" gap in `restart-and-know-what-im-running`:
|
|
6
|
+
* a user who restarts after editing switchroom.yaml now sees exactly what
|
|
7
|
+
* changed on the boot card, without running `/status`.
|
|
8
|
+
*
|
|
9
|
+
* What's captured:
|
|
10
|
+
* - model slug (single string, shown verbatim in the diff row)
|
|
11
|
+
* - tools allowlist hash (sorted SHA-256 prefix — coarse diff only;
|
|
12
|
+
* granular enumeration is a follow-up)
|
|
13
|
+
* - skills hash (same pattern)
|
|
14
|
+
* - memory backend / collection name (single string, shown verbatim)
|
|
15
|
+
*
|
|
16
|
+
* Diff policy:
|
|
17
|
+
* - model / memoryBackend: shown verbatim ("model: A → B")
|
|
18
|
+
* - toolsHash / skillsHash: "tools allowlist changed — run /status for
|
|
19
|
+
* details" (honest without being exhaustive for long allowlists)
|
|
20
|
+
*
|
|
21
|
+
* Silent when unchanged: the caller ONLY surfaces a row when
|
|
22
|
+
* `diffSnapshots()` returns at least one `ConfigChangeDim`.
|
|
23
|
+
*
|
|
24
|
+
* First boot (no prior snapshot): returns an empty diff and persists
|
|
25
|
+
* the current snapshot for the next boot. Corrupt / missing snapshot:
|
|
26
|
+
* treated as first boot.
|
|
27
|
+
*
|
|
28
|
+
* Storage: `<agentDir>/.config-snapshot.json` (mode 0600), same
|
|
29
|
+
* location pattern as `boot-issue-cache.json`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'crypto'
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
|
|
34
|
+
import { dirname } from 'path'
|
|
35
|
+
import { escapeHtml } from '../card-format.js'
|
|
36
|
+
|
|
37
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface ConfigSnapshot {
|
|
40
|
+
/** Schema version — bump on incompatible layout changes. */
|
|
41
|
+
schema: 1
|
|
42
|
+
/** Wall-clock ms at the time of capture (for forensics / GC). */
|
|
43
|
+
capturedAtMs: number
|
|
44
|
+
/** Resolved model slug, e.g. "claude-sonnet-4-5".
|
|
45
|
+
* null when not set in config (gateway uses the claude CLI default). */
|
|
46
|
+
model: string | null
|
|
47
|
+
/**
|
|
48
|
+
* Sorted SHA-256 prefix of the tools allowlist.
|
|
49
|
+
* Null when the allowlist is unset / empty (uses claude CLI default).
|
|
50
|
+
* Computed from the sorted join of the array so reordering the YAML
|
|
51
|
+
* does not fire a spurious diff.
|
|
52
|
+
*/
|
|
53
|
+
toolsHash: string | null
|
|
54
|
+
/**
|
|
55
|
+
* Sorted SHA-256 prefix of the skills list.
|
|
56
|
+
* Null when no skills are configured.
|
|
57
|
+
*/
|
|
58
|
+
skillsHash: string | null
|
|
59
|
+
/**
|
|
60
|
+
* Memory backend identifier — `memory.collection` from switchroom.yaml,
|
|
61
|
+
* or null when not configured.
|
|
62
|
+
*/
|
|
63
|
+
memoryBackend: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One dimension that changed between the prior snapshot and this boot. */
|
|
67
|
+
export interface ConfigChangeDim {
|
|
68
|
+
/** Which field changed. */
|
|
69
|
+
field: 'model' | 'tools' | 'skills' | 'memoryBackend'
|
|
70
|
+
/** Previous value (null = "not configured" or "first boot"). */
|
|
71
|
+
from: string | null
|
|
72
|
+
/** Current value (null = "removed" — field was cleared in config). */
|
|
73
|
+
to: string | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ConfigDiff = ConfigChangeDim[]
|
|
77
|
+
|
|
78
|
+
// ─── Hashing ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute a short deterministic fingerprint for an array of strings.
|
|
82
|
+
* Sort before hashing so reordering the array in YAML doesn't fire a
|
|
83
|
+
* spurious diff row. Returns null for empty / null input.
|
|
84
|
+
*/
|
|
85
|
+
export function hashStringArray(items: string[] | null | undefined): string | null {
|
|
86
|
+
if (!items || items.length === 0) return null
|
|
87
|
+
const sorted = [...items].sort()
|
|
88
|
+
const raw = createHash('sha256').update(sorted.join('\0')).digest('hex')
|
|
89
|
+
// 12-char prefix is visually compact and collision-resistant enough for
|
|
90
|
+
// config arrays that rarely exceed a few dozen entries.
|
|
91
|
+
return raw.slice(0, 12)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a model slug for comparison: lowercase, strip trailing
|
|
96
|
+
* whitespace, collapse multiple spaces. Prevents spurious diffs from
|
|
97
|
+
* case/whitespace variance in the YAML value.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeModel(model: string | null | undefined): string | null {
|
|
100
|
+
if (!model || model.trim().length === 0) return null
|
|
101
|
+
return model.trim().toLowerCase()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Capture ────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export interface CaptureInput {
|
|
107
|
+
/** Agent name — used for the default memoryBackend when not explicitly set. */
|
|
108
|
+
agentName: string
|
|
109
|
+
/** Resolved model from the config cascade (may be undefined/null). */
|
|
110
|
+
model?: string | null
|
|
111
|
+
/** Full tools allowlist from the config cascade (may be undefined/null). */
|
|
112
|
+
toolsAllow?: string[] | null
|
|
113
|
+
/** Full skills list from the config cascade (may be undefined/null). */
|
|
114
|
+
skills?: string[] | null
|
|
115
|
+
/** Memory collection name from the config cascade (may be undefined/null). */
|
|
116
|
+
memoryCollection?: string | null
|
|
117
|
+
/** Clock injection for tests. */
|
|
118
|
+
now?: () => number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a ConfigSnapshot from the current resolved agent config.
|
|
123
|
+
*
|
|
124
|
+
* The caller is responsible for loading the config and passing the
|
|
125
|
+
* relevant fields. This function is pure — no disk I/O.
|
|
126
|
+
*/
|
|
127
|
+
export function captureConfigSnapshot(input: CaptureInput): ConfigSnapshot {
|
|
128
|
+
return {
|
|
129
|
+
schema: 1,
|
|
130
|
+
capturedAtMs: (input.now ?? Date.now)(),
|
|
131
|
+
model: normalizeModel(input.model),
|
|
132
|
+
toolsHash: hashStringArray(input.toolsAllow ?? null),
|
|
133
|
+
skillsHash: hashStringArray(input.skills ?? null),
|
|
134
|
+
memoryBackend: input.memoryCollection?.trim() || null,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Diff ────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compare `current` against `previous` and return one entry per changed
|
|
142
|
+
* dimension. Returns an empty array when nothing changed.
|
|
143
|
+
*
|
|
144
|
+
* Edge cases:
|
|
145
|
+
* - `previous` is null (first boot or corrupt cache): returns empty diff.
|
|
146
|
+
* The caller will persist `current` for the next boot.
|
|
147
|
+
* - A field is null on both sides: not a change.
|
|
148
|
+
*/
|
|
149
|
+
export function diffSnapshots(
|
|
150
|
+
current: ConfigSnapshot,
|
|
151
|
+
previous: ConfigSnapshot | null,
|
|
152
|
+
): ConfigDiff {
|
|
153
|
+
if (previous === null) return []
|
|
154
|
+
|
|
155
|
+
const changes: ConfigDiff = []
|
|
156
|
+
|
|
157
|
+
if (current.model !== previous.model) {
|
|
158
|
+
changes.push({ field: 'model', from: previous.model, to: current.model })
|
|
159
|
+
}
|
|
160
|
+
if (current.toolsHash !== previous.toolsHash) {
|
|
161
|
+
changes.push({ field: 'tools', from: previous.toolsHash, to: current.toolsHash })
|
|
162
|
+
}
|
|
163
|
+
if (current.skillsHash !== previous.skillsHash) {
|
|
164
|
+
changes.push({ field: 'skills', from: previous.skillsHash, to: current.skillsHash })
|
|
165
|
+
}
|
|
166
|
+
if (current.memoryBackend !== previous.memoryBackend) {
|
|
167
|
+
changes.push({
|
|
168
|
+
field: 'memoryBackend',
|
|
169
|
+
from: previous.memoryBackend,
|
|
170
|
+
to: current.memoryBackend,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return changes
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Render a single config-change row for the boot card.
|
|
181
|
+
*
|
|
182
|
+
* Model and memory backend: shown verbatim ("model: A → B").
|
|
183
|
+
* Tools / skills: coarse hash diff ("tools allowlist changed — run /status
|
|
184
|
+
* for details"), since enumerating the full list would be too verbose.
|
|
185
|
+
*
|
|
186
|
+
* The null-to-value case means "field was first configured on this boot".
|
|
187
|
+
* The value-to-null case means "field was removed" (unusual but legal).
|
|
188
|
+
*/
|
|
189
|
+
export function renderConfigChangeDim(dim: ConfigChangeDim): string {
|
|
190
|
+
switch (dim.field) {
|
|
191
|
+
case 'model': {
|
|
192
|
+
const from = escapeHtml(dim.from ?? '(default)')
|
|
193
|
+
const to = escapeHtml(dim.to ?? '(default)')
|
|
194
|
+
return `⚙️ <b>Config</b> model: ${from} → ${to}`
|
|
195
|
+
}
|
|
196
|
+
case 'memoryBackend': {
|
|
197
|
+
const from = escapeHtml(dim.from ?? '(default)')
|
|
198
|
+
const to = escapeHtml(dim.to ?? '(default)')
|
|
199
|
+
return `⚙️ <b>Config</b> memory backend: ${from} → ${to}`
|
|
200
|
+
}
|
|
201
|
+
case 'tools':
|
|
202
|
+
return `⚙️ <b>Config</b> tools allowlist changed — run /status for details`
|
|
203
|
+
case 'skills':
|
|
204
|
+
return `⚙️ <b>Config</b> skills changed — run /status for details`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Persistence ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load the config snapshot from `path`.
|
|
212
|
+
*
|
|
213
|
+
* Returns null on:
|
|
214
|
+
* - file missing (first boot)
|
|
215
|
+
* - JSON parse error (corrupt — renamed aside for forensics)
|
|
216
|
+
* - schema mismatch
|
|
217
|
+
*/
|
|
218
|
+
export function loadSnapshot(path: string, now: () => number = Date.now): ConfigSnapshot | null {
|
|
219
|
+
if (!existsSync(path)) return null
|
|
220
|
+
let raw: string
|
|
221
|
+
try {
|
|
222
|
+
raw = readFileSync(path, 'utf-8')
|
|
223
|
+
} catch {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
let parsed: unknown
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(raw)
|
|
229
|
+
} catch {
|
|
230
|
+
// Corrupt — preserve for forensics, return null (treat as first boot).
|
|
231
|
+
try {
|
|
232
|
+
renameSync(path, `${path}.corrupt-${now()}`)
|
|
233
|
+
} catch {
|
|
234
|
+
// best-effort
|
|
235
|
+
}
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
const obj = parsed as Partial<ConfigSnapshot>
|
|
239
|
+
if (!obj || obj.schema !== 1) return null
|
|
240
|
+
// Validate required keys exist (defensive against partial writes).
|
|
241
|
+
if (
|
|
242
|
+
typeof obj.capturedAtMs !== 'number' ||
|
|
243
|
+
!('model' in obj) ||
|
|
244
|
+
!('toolsHash' in obj) ||
|
|
245
|
+
!('skillsHash' in obj) ||
|
|
246
|
+
!('memoryBackend' in obj)
|
|
247
|
+
) {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
schema: 1,
|
|
252
|
+
capturedAtMs: obj.capturedAtMs,
|
|
253
|
+
model: obj.model ?? null,
|
|
254
|
+
toolsHash: obj.toolsHash ?? null,
|
|
255
|
+
skillsHash: obj.skillsHash ?? null,
|
|
256
|
+
memoryBackend: obj.memoryBackend ?? null,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Persist `snapshot` to `path` atomically (via `.tmp` + rename).
|
|
262
|
+
* Failures are swallowed — the snapshot is best-effort and should not
|
|
263
|
+
* block the boot sequence.
|
|
264
|
+
*/
|
|
265
|
+
export function persistSnapshot(path: string, snapshot: ConfigSnapshot): void {
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
268
|
+
const tmp = `${path}.tmp`
|
|
269
|
+
writeFileSync(tmp, JSON.stringify(snapshot), { mode: 0o600 })
|
|
270
|
+
renameSync(tmp, path)
|
|
271
|
+
} catch {
|
|
272
|
+
// Non-fatal: best-effort persistence. Next boot will re-detect.
|
|
273
|
+
}
|
|
274
|
+
}
|