claude-code-session-manager 0.62.1 → 0.64.0

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 (45) hide show
  1. package/dist/assets/AgentLibrary-9KJ4fedH.js +1 -0
  2. package/dist/assets/History-Dp3-_bjQ.js +2 -0
  3. package/dist/assets/Hooks-DMQFgGDd.js +3 -0
  4. package/dist/assets/HostBilko-zNU1NtBh.js +1 -0
  5. package/dist/assets/Library-BrODBbeL.js +46 -0
  6. package/dist/assets/ListDetail-C5vbA1gC.js +1 -0
  7. package/dist/assets/MarkdownEditor-CpzAEiiG.js +1 -0
  8. package/dist/assets/McpServers-DWuw-flj.js +2 -0
  9. package/dist/assets/Memory-B4TC9SsJ.js +8 -0
  10. package/dist/assets/Panel-Bap7G-pI.js +1 -0
  11. package/dist/assets/Permissions-BIpNWItH.js +3 -0
  12. package/dist/assets/Plugins-DrwklmW4.js +2 -0
  13. package/dist/assets/ProvenanceBadge-CCp_EhjV.js +1 -0
  14. package/dist/assets/Scheduler-DfoTegGG.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BlQvpyLE.js +1 -0
  16. package/dist/assets/Settings-BzvmZvGa.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-CGtwmCjm.js +46 -0
  18. package/dist/assets/Skills-Cauvbtk7.js +3 -0
  19. package/dist/assets/SystemPrompt-BqzM76PV.js +1 -0
  20. package/dist/assets/TagLibrary-DVKPjeRu.js +1 -0
  21. package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-CUWdBAYf.js} +1 -1
  22. package/dist/assets/Toggle-Bos67yDJ.js +1 -0
  23. package/dist/assets/index-BfPVknuV.js +3066 -0
  24. package/dist/assets/{index-CVCCMw5o.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-DKuyaEK5.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +430 -0
  34. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  35. package/src/main/config.cjs +31 -7
  36. package/src/main/health.cjs +32 -0
  37. package/src/main/heapSnapshot.cjs +122 -0
  38. package/src/main/historyAggregator.cjs +176 -36
  39. package/src/main/index.cjs +29 -0
  40. package/src/main/ipcSchemas.cjs +9 -0
  41. package/src/main/lib/runLogRetention.cjs +379 -0
  42. package/src/main/transcripts.cjs +29 -5
  43. package/src/preload/api.d.ts +16 -2
  44. package/src/preload/index.cjs +11 -2
  45. package/dist/assets/index-ZjTDk5b7.js +0 -3197
@@ -0,0 +1,430 @@
1
+ /**
2
+ * runLogRetention.test.cjs — unit tests for src/main/lib/runLogRetention.cjs.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/runLogRetention.test.cjs
5
+ *
6
+ * Fixtures live under os.tmpdir() only — never the real
7
+ * ~/.claude/session-manager/scheduled-plans/runs.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeEach, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const {
17
+ scanRunEntries,
18
+ computeEligibility,
19
+ computeReport,
20
+ isRetentionEnabled,
21
+ applyRetention,
22
+ liveKeysFromJobs,
23
+ isLiveJob,
24
+ runBootSweep,
25
+ } = require('../lib/runLogRetention.cjs');
26
+
27
+ let runsDir;
28
+
29
+ beforeEach(() => {
30
+ runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-retention-test-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ fs.rmSync(runsDir, { recursive: true, force: true });
35
+ });
36
+
37
+ const DAY_MS = 24 * 60 * 60 * 1000;
38
+
39
+ function makeRun(runId, slug, { finishedAt, extraFiles = [] } = {}) {
40
+ const dir = path.join(runsDir, runId);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(path.join(dir, `${slug}.log`), 'log output\n');
43
+ fs.writeFileSync(
44
+ path.join(dir, `${slug}.meta.json`),
45
+ JSON.stringify({ slug, exitCode: 0, finishedAt })
46
+ );
47
+ for (const name of extraFiles) {
48
+ fs.writeFileSync(path.join(dir, name), 'extra\n');
49
+ }
50
+ return dir;
51
+ }
52
+
53
+ // ─── scanRunEntries ─────────────────────────────────────────────────────────
54
+
55
+ test('scanRunEntries: returns [] for a missing runsDir (never throws)', () => {
56
+ const missing = path.join(runsDir, 'does-not-exist');
57
+ expect(scanRunEntries(missing)).toEqual([]);
58
+ });
59
+
60
+ test('scanRunEntries: finds one entry per slug, even when a dir holds many slugs', () => {
61
+ makeRun('2026-01-01T00-00-00-000Z', 'a-slug', { finishedAt: Date.now() });
62
+ const dir = path.join(runsDir, '2026-01-01T00-00-00-000Z');
63
+ fs.writeFileSync(path.join(dir, 'b-slug.log'), 'log\n');
64
+ fs.writeFileSync(path.join(dir, 'b-slug.meta.json'), JSON.stringify({ finishedAt: Date.now() }));
65
+
66
+ const entries = scanRunEntries(runsDir);
67
+ expect(entries.map((e) => e.slug).sort()).toEqual(['a-slug', 'b-slug']);
68
+ expect(entries.every((e) => e.files.length === 2)).toBe(true);
69
+ });
70
+
71
+ // ─── default is dry-run: nothing removed with no opt-in ────────────────────
72
+
73
+ test('applyRetention: DEFAULT (no settings) is dry-run — deletes nothing, regardless of how old the runs are', () => {
74
+ const now = Date.now();
75
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
76
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
77
+
78
+ const result = applyRetention(runsDir, undefined, { now });
79
+ expect(result.deleted).toBe(false);
80
+ expect(result.removedFiles).toBeUndefined();
81
+ expect(fs.readdirSync(runsDir).length).toBe(2);
82
+
83
+ // The same data WOULD flag one eligible run once a real policy is computed
84
+ // (this is what the human-facing report is built from) — proves the dry
85
+ // run above wasn't a no-op because nothing was ever eligible.
86
+ const withPolicy = computeReport(runsDir, { maxAgeDays: 30 }, { now });
87
+ expect(withPolicy.eligible.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ test('applyRetention: enabled=false is still dry-run regardless of policy', () => {
91
+ const now = Date.now();
92
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
93
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
94
+
95
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 30 } } };
96
+ const result = applyRetention(runsDir, settings, { now });
97
+ expect(result.deleted).toBe(false);
98
+ expect(fs.readdirSync(runsDir).length).toBe(2);
99
+ });
100
+
101
+ test('isRetentionEnabled: requires enabled===true AND a non-empty policy', () => {
102
+ expect(isRetentionEnabled(undefined)).toBe(false);
103
+ expect(isRetentionEnabled({})).toBe(false);
104
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true } })).toBe(false);
105
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: {} } })).toBe(false);
106
+ expect(
107
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } })
108
+ ).toBe(true);
109
+ expect(
110
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: 'true', policy: { maxAgeDays: 30 } } })
111
+ ).toBe(false); // must be boolean true, not truthy
112
+ });
113
+
114
+ // ─── explicit opt-in actually deletes ───────────────────────────────────────
115
+
116
+ test('applyRetention: deletes ONLY when schedulerRunLogRetention.enabled===true plus a policy', () => {
117
+ const now = Date.now();
118
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
119
+ const recentDir = makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 1 * DAY_MS });
120
+
121
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
122
+ const result = applyRetention(runsDir, settings, { now });
123
+
124
+ expect(result.deleted).toBe(true);
125
+ expect(result.removedFiles).toBeGreaterThan(0);
126
+ // old, non-most-recent run's directory is gone entirely
127
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
128
+ // most-recent run for the slug survives regardless of policy
129
+ expect(fs.existsSync(recentDir)).toBe(true);
130
+ });
131
+
132
+ // ─── live-job protection ────────────────────────────────────────────────────
133
+
134
+ test('isLiveJob: pending/running/needs_review/investigating are live; completed/failed are not', () => {
135
+ expect(isLiveJob({ status: 'pending' })).toBe(true);
136
+ expect(isLiveJob({ status: 'running' })).toBe(true);
137
+ expect(isLiveJob({ status: 'needs_review' })).toBe(true);
138
+ expect(isLiveJob({ status: 'investigating' })).toBe(true);
139
+ expect(isLiveJob({ status: 'completed' })).toBe(false);
140
+ expect(isLiveJob({ status: 'failed' })).toBe(false);
141
+ expect(isLiveJob(null)).toBe(false);
142
+ });
143
+
144
+ test('a run belonging to a needs_review job is NEVER eligible, regardless of age', () => {
145
+ const now = Date.now();
146
+ // Two runs of the same slug so the protected one is not simply "most recent".
147
+ makeRun('2020-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 500 * DAY_MS });
148
+ makeRun('2020-02-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 490 * DAY_MS }); // this one is live
149
+ makeRun('2026-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 1 * DAY_MS }); // most recent
150
+
151
+ const jobs = [{ slug: 'watched-slug', runId: '2020-02-01T00-00-00-000Z', status: 'needs_review' }];
152
+ const liveKeys = liveKeysFromJobs(jobs);
153
+ expect(liveKeys.has('watched-slug|2020-02-01T00-00-00-000Z')).toBe(true);
154
+
155
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now, liveKeys });
156
+ const eligibleRunIds = report.eligible.map((e) => e.runId);
157
+ expect(eligibleRunIds).toContain('2020-01-01T00-00-00-000Z'); // old, not live, not most-recent — eligible
158
+ expect(eligibleRunIds).not.toContain('2020-02-01T00-00-00-000Z'); // live — protected
159
+ expect(eligibleRunIds).not.toContain('2026-01-01T00-00-00-000Z'); // most recent — protected
160
+
161
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
162
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
163
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
164
+ });
165
+
166
+ test('queued/running jobs are also protected, same as needs_review', () => {
167
+ const now = Date.now();
168
+ makeRun('2020-01-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 500 * DAY_MS });
169
+ makeRun('2020-02-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, also running
170
+
171
+ const jobs = [{ slug: 'busy-slug', runId: '2020-02-01T00-00-00-000Z', status: 'running' }];
172
+ const liveKeys = liveKeysFromJobs(jobs);
173
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
174
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
175
+
176
+ // old one is eligible and removed (age 0 means "older than 0 days")
177
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
178
+ // running one is protected even though its own age also clears the 0-day cap
179
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
180
+ });
181
+
182
+ // ─── most-recent-per-slug protection ────────────────────────────────────────
183
+
184
+ test('the most recent run for a slug is never eligible, even past the age cap', () => {
185
+ const now = Date.now();
186
+ makeRun('2018-01-01T00-00-00-000Z', 'lonely-slug', { finishedAt: now - 3000 * DAY_MS });
187
+
188
+ const report = computeReport(runsDir, { maxAgeDays: 1 }, { now });
189
+ expect(report.eligible.length).toBe(0);
190
+
191
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 1 } } };
192
+ const result = applyRetention(runsDir, settings, { now });
193
+ expect(result.removedFiles).toBe(0);
194
+ expect(fs.existsSync(path.join(runsDir, '2018-01-01T00-00-00-000Z'))).toBe(true);
195
+ });
196
+
197
+ // ─── age + count combinability ──────────────────────────────────────────────
198
+
199
+ test('age-based policy: only entries older than maxAgeDays (and not most-recent) are eligible', () => {
200
+ const now = Date.now();
201
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 90 * DAY_MS });
202
+ makeRun('2020-06-01T00-00-00-000Z', 's', { finishedAt: now - 10 * DAY_MS });
203
+ makeRun('2020-09-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
204
+
205
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
206
+ expect(report.eligible.map((e) => e.runId)).toEqual(['2020-01-01T00-00-00-000Z']);
207
+ });
208
+
209
+ test('count-based policy: keeps the N most recent runs per slug, rest eligible', () => {
210
+ const now = Date.now();
211
+ const ids = [];
212
+ for (let i = 0; i < 5; i++) {
213
+ const runId = `2020-0${i + 1}-01T00-00-00-000Z`;
214
+ ids.push(runId);
215
+ makeRun(runId, 'count-slug', { finishedAt: now - (5 - i) * DAY_MS });
216
+ }
217
+
218
+ const report = computeReport(runsDir, { keepPerSlug: 2 }, { now });
219
+ // 5 runs, keep 2 most recent (ranks 0,1) -> 3 eligible (ranks 2,3,4)
220
+ expect(report.eligible.length).toBe(3);
221
+ const eligibleRunIds = new Set(report.eligible.map((e) => e.runId));
222
+ expect(eligibleRunIds.has(ids[4])).toBe(false); // most recent
223
+ expect(eligibleRunIds.has(ids[3])).toBe(false); // 2nd most recent, kept by count
224
+ expect(eligibleRunIds.has(ids[0])).toBe(true);
225
+ });
226
+
227
+ test('age + count policies combine conservatively (AND): an entry needs both to be eligible', () => {
228
+ const now = Date.now();
229
+ // Old by age, but within the keepPerSlug=3 window (rank 1 of 3).
230
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 400 * DAY_MS });
231
+ makeRun('2020-02-01T00-00-00-000Z', 's', { finishedAt: now - 300 * DAY_MS });
232
+ makeRun('2020-03-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
233
+
234
+ const policy = { maxAgeDays: 30, keepPerSlug: 3 };
235
+ const report = computeReport(runsDir, policy, { now });
236
+ // Both are old enough by age, but keepPerSlug=3 keeps all 3 ranks (0,1,2) — none eligible.
237
+ expect(report.eligible.length).toBe(0);
238
+ });
239
+
240
+ // ─── report surface: usage stats ────────────────────────────────────────────
241
+
242
+ test('computeReport: reports total bytes, dir count, and oldest run date', () => {
243
+ const now = Date.now();
244
+ makeRun('2020-01-01T00-00-00-000Z', 'a', { finishedAt: now - 100 * DAY_MS });
245
+ makeRun('2020-06-01T00-00-00-000Z', 'b', { finishedAt: now - 10 * DAY_MS });
246
+
247
+ const report = computeReport(runsDir, {}, { now });
248
+ expect(report.usage.dirCount).toBe(2);
249
+ expect(report.usage.runCount).toBe(2);
250
+ expect(report.usage.totalBytes).toBeGreaterThan(0);
251
+ expect(report.usage.oldestRunAt).toBe(now - 100 * DAY_MS);
252
+ // no policy set -> nothing eligible, purely informational
253
+ expect(report.eligible.length).toBe(0);
254
+ });
255
+
256
+ // ─── directory-level cleanup: partial dirs never fully removed ─────────────
257
+
258
+ test('a directory holding an unclaimed extra file is never in removableDirs, even if its own entry is eligible', () => {
259
+ const now = Date.now();
260
+ makeRun('2020-01-01T00-00-00-000Z', 'shared-dir-slug', {
261
+ finishedAt: now - 400 * DAY_MS,
262
+ extraFiles: ['definition-of-done-abc12345.md'],
263
+ });
264
+ makeRun('2026-01-01T00-00-00-000Z', 'shared-dir-slug', { finishedAt: now - 1 * DAY_MS });
265
+
266
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
267
+ expect(report.eligible.length).toBe(1);
268
+ expect(report.removableDirs).toEqual([]);
269
+
270
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
271
+ const result = applyRetention(runsDir, settings, { now });
272
+ // the slug's own log+meta are removed...
273
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'shared-dir-slug.log'))).toBe(false);
274
+ // ...but the directory itself survives because of the unclaimed DoD report
275
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(true);
276
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'definition-of-done-abc12345.md'))).toBe(true);
277
+ });
278
+
279
+ test('a directory whose sole entry is eligible is fully removed (dir + files)', () => {
280
+ const now = Date.now();
281
+ makeRun('2020-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 400 * DAY_MS });
282
+ makeRun('2026-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 1 * DAY_MS });
283
+
284
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
285
+ applyRetention(runsDir, settings, { now });
286
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
287
+ });
288
+
289
+ // ─── liveKeysFromJobs: null-runId backfill (needs_review can lose its runId) ─
290
+
291
+ test('liveKeysFromJobs: backfills protection via a runsDir scan when a live job has lost its runId', () => {
292
+ makeRun('2020-01-01T00-00-00-000Z', 'lost-slug', { finishedAt: Date.now() });
293
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
294
+ const keys = liveKeysFromJobs(jobs, { runsDir });
295
+ expect(keys.has('lost-slug|2020-01-01T00-00-00-000Z')).toBe(true);
296
+ });
297
+
298
+ test('liveKeysFromJobs: without a runsDir hint, a live job with no runId yields no protection (documented limitation — callers should always pass runsDir)', () => {
299
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
300
+ expect(liveKeysFromJobs(jobs).size).toBe(0);
301
+ });
302
+
303
+ // ─── applyRetention: fail-safe fallback when the caller forgets liveKeys/jobs ─
304
+
305
+ test('applyRetention: enabled=true with no liveKeys/jobs falls back to reading the real scheduler queue (queueStore) instead of silently protecting nothing', () => {
306
+ const now = Date.now();
307
+ makeRun('2020-05-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 400 * DAY_MS });
308
+ makeRun('2020-06-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, unrelated
309
+
310
+ const queueStore = require('../lib/queueStore.cjs');
311
+ const original = queueStore.readMergedSync;
312
+ queueStore.readMergedSync = () => ({
313
+ jobs: [{ slug: 'fallback-slug', runId: '2020-05-01T00-00-00-000Z', status: 'running' }],
314
+ });
315
+ try {
316
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
317
+ applyRetention(runsDir, settings, { now }); // deliberately no liveKeys, no jobs
318
+ expect(fs.existsSync(path.join(runsDir, '2020-05-01T00-00-00-000Z'))).toBe(true);
319
+ } finally {
320
+ queueStore.readMergedSync = original;
321
+ }
322
+ });
323
+
324
+ // ─── runBootSweep: the one production caller ────────────────────────────────
325
+
326
+ test('runBootSweep: reads queueStore ONCE and threads its jobs explicitly into applyRetention (never the internal fallback re-read)', () => {
327
+ const now = Date.now();
328
+ makeRun('2020-01-01T00-00-00-000Z', 'boot-slug', { finishedAt: now - 400 * DAY_MS });
329
+ makeRun('2020-02-01T00-00-00-000Z', 'boot-slug', { finishedAt: now - 390 * DAY_MS }); // live, protected
330
+ makeRun('2026-01-01T00-00-00-000Z', 'boot-slug', { finishedAt: now - 1 * DAY_MS }); // most recent
331
+
332
+ const queueStore = require('../lib/queueStore.cjs');
333
+ const original = queueStore.readMergedSync;
334
+ let calls = 0;
335
+ queueStore.readMergedSync = () => {
336
+ calls += 1;
337
+ if (calls === 1) {
338
+ return {
339
+ config: { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 7 } } },
340
+ jobs: [{ slug: 'boot-slug', runId: '2020-02-01T00-00-00-000Z', status: 'running' }],
341
+ };
342
+ }
343
+ // If applyRetention's internal fallback ever re-reads the store, this
344
+ // second shape (no live jobs) would let the protected run be deleted —
345
+ // proving runBootSweep did NOT thread jobs explicitly.
346
+ return { config: {}, jobs: [] };
347
+ };
348
+ try {
349
+ const result = runBootSweep({ now, runsDir });
350
+ expect(calls).toBe(1); // proves applyRetention never fell back to its own re-read
351
+ expect(result.deleted).toBe(true);
352
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false); // eligible, removed
353
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true); // live, protected
354
+ expect(fs.existsSync(path.join(runsDir, '2026-01-01T00-00-00-000Z'))).toBe(true); // most recent, protected
355
+ } finally {
356
+ queueStore.readMergedSync = original;
357
+ }
358
+ });
359
+
360
+ test('runBootSweep: dry-run when config has no opt-in', () => {
361
+ const now = Date.now();
362
+ makeRun('2020-01-01T00-00-00-000Z', 'noop-slug', { finishedAt: now - 400 * DAY_MS });
363
+ makeRun('2026-01-01T00-00-00-000Z', 'noop-slug', { finishedAt: now - 1 * DAY_MS });
364
+
365
+ const queueStore = require('../lib/queueStore.cjs');
366
+ const original = queueStore.readMergedSync;
367
+ queueStore.readMergedSync = () => ({ config: {}, jobs: [] });
368
+ try {
369
+ const result = runBootSweep({ now, runsDir });
370
+ expect(result.deleted).toBe(false);
371
+ expect(fs.readdirSync(runsDir).length).toBe(2);
372
+ } finally {
373
+ queueStore.readMergedSync = original;
374
+ }
375
+ });
376
+
377
+ test('runBootSweep: a deletion failure (permission error) is caught, logged in result.errors, and never thrown', () => {
378
+ const now = Date.now();
379
+ const dir = makeRun('2020-01-01T00-00-00-000Z', 'fail-slug', { finishedAt: now - 400 * DAY_MS });
380
+ makeRun('2026-01-01T00-00-00-000Z', 'fail-slug', { finishedAt: now - 1 * DAY_MS });
381
+
382
+ const queueStore = require('../lib/queueStore.cjs');
383
+ const original = queueStore.readMergedSync;
384
+ queueStore.readMergedSync = () => ({
385
+ config: { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 7 } } },
386
+ jobs: [],
387
+ });
388
+
389
+ const originalUnlink = fs.unlinkSync;
390
+ fs.unlinkSync = (p) => {
391
+ if (String(p).startsWith(dir)) {
392
+ const err = new Error('EACCES: permission denied');
393
+ err.code = 'EACCES';
394
+ throw err;
395
+ }
396
+ return originalUnlink(p);
397
+ };
398
+
399
+ try {
400
+ expect(() => runBootSweep({ now, runsDir })).not.toThrow();
401
+ const result = runBootSweep({ now, runsDir });
402
+ expect(result.deleted).toBe(true);
403
+ expect(result.errors.length).toBeGreaterThan(0);
404
+ } finally {
405
+ fs.unlinkSync = originalUnlink;
406
+ queueStore.readMergedSync = original;
407
+ }
408
+ });
409
+
410
+ test('applyRetention: dry-run path (enabled=false) never reads queueStore for live protection', () => {
411
+ const now = Date.now();
412
+ makeRun('2020-05-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 400 * DAY_MS });
413
+ makeRun('2020-06-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 1 * DAY_MS });
414
+
415
+ const queueStore = require('../lib/queueStore.cjs');
416
+ const original = queueStore.readMergedSync;
417
+ let called = false;
418
+ queueStore.readMergedSync = () => {
419
+ called = true;
420
+ return { jobs: [] };
421
+ };
422
+ try {
423
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 0 } } };
424
+ const result = applyRetention(runsDir, settings, { now });
425
+ expect(result.deleted).toBe(false);
426
+ expect(called).toBe(false);
427
+ } finally {
428
+ queueStore.readMergedSync = original;
429
+ }
430
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * transcripts-batch-flush.test.cjs — unit tests for the batched IPC flush
3
+ * (PRD transcript-batch-flush): transcripts.cjs's doFlush now sends the
4
+ * events produced by ONE flush as a SINGLE `transcript:event:<tabId>` IPC
5
+ * message (an array), instead of one message per event, while still
6
+ * recording one OTEL span per event and preserving exact event order.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/__tests__/transcripts-batch-flush.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
14
+
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+
19
+ const {
20
+ subscribe,
21
+ closeTab,
22
+ transcriptPath,
23
+ attachWindow,
24
+ __getSubForTest,
25
+ __doFlushForTest,
26
+ MAX_EVENTS_PER_BATCH,
27
+ } = require('../transcripts.cjs');
28
+ const otel = require('../otel.cjs');
29
+
30
+ let cwd;
31
+
32
+ beforeEach(() => {
33
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-batch-flush-test-'));
34
+ });
35
+
36
+ afterEach(() => {
37
+ fs.rmSync(cwd, { recursive: true, force: true });
38
+ fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
39
+ attachWindow(null);
40
+ });
41
+
42
+ function writeTranscript(cwdArg, sessionId, lines) {
43
+ const filePath = transcriptPath(cwdArg, sessionId);
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
46
+ return filePath;
47
+ }
48
+
49
+ function appendTranscript(filePath, lines) {
50
+ fs.appendFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
51
+ }
52
+
53
+ /** A fake BrowserWindow that records every webContents.send call. */
54
+ function makeFakeWindow() {
55
+ const sent = [];
56
+ return {
57
+ sent,
58
+ isDestroyed: () => false,
59
+ webContents: {
60
+ isDestroyed: () => false,
61
+ isCrashed: () => false,
62
+ send: (channel, payload) => sent.push({ channel, payload }),
63
+ },
64
+ };
65
+ }
66
+
67
+ function assistantLine(text, toolId) {
68
+ return {
69
+ type: 'assistant',
70
+ message: {
71
+ content: [
72
+ { type: 'text', text },
73
+ { type: 'tool_use', name: 'Bash', id: toolId, input: { command: 'ls' } },
74
+ ],
75
+ },
76
+ };
77
+ }
78
+
79
+ test('one flush with several multi-event lines sends exactly ONE IPC message carrying the whole ordered batch', async () => {
80
+ const sessionId = 'sess-one-batch';
81
+ const tabId = 'tab-one-batch';
82
+ const filePath = writeTranscript(cwd, sessionId, []);
83
+
84
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
85
+ expect(res.ok).toBe(true);
86
+
87
+ const win = makeFakeWindow();
88
+ attachWindow(win);
89
+
90
+ // 5 lines * 2 events each = 10 events, well under MAX_EVENTS_PER_BATCH.
91
+ appendTranscript(
92
+ filePath,
93
+ Array.from({ length: 5 }, (_, i) => assistantLine(`line ${i}`, `tu-${i}`)),
94
+ );
95
+ const sub = __getSubForTest(tabId);
96
+ await sub.watcher?.close();
97
+ await __doFlushForTest(sub);
98
+
99
+ const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
100
+ expect(messages).toHaveLength(1);
101
+ expect(Array.isArray(messages[0].payload)).toBe(true);
102
+ expect(messages[0].payload).toHaveLength(10);
103
+ // Event order preserved: text/tool_use pairs in line order.
104
+ expect(messages[0].payload.map((e) => e.kind)).toEqual([
105
+ 'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use',
106
+ ]);
107
+ expect(messages[0].payload.map((e) => e.data?.id ?? e.data).filter(Boolean)).toEqual(
108
+ expect.arrayContaining(['tu-0', 'tu-1', 'tu-2', 'tu-3', 'tu-4']),
109
+ );
110
+
111
+ closeTab(tabId);
112
+ });
113
+
114
+ test('order is preserved exactly across a multi-event flush (matches per-event order semantics)', async () => {
115
+ const sessionId = 'sess-order';
116
+ const tabId = 'tab-order';
117
+ const filePath = writeTranscript(cwd, sessionId, []);
118
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
119
+ expect(res.ok).toBe(true);
120
+
121
+ const win = makeFakeWindow();
122
+ attachWindow(win);
123
+
124
+ appendTranscript(filePath, [
125
+ { type: 'user', message: { content: 'first' } },
126
+ assistantLine('second', 'tu-order-1'),
127
+ { type: 'user', message: { content: 'third' } },
128
+ ]);
129
+ const sub = __getSubForTest(tabId);
130
+ await sub.watcher?.close();
131
+ await __doFlushForTest(sub);
132
+
133
+ const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
134
+ expect(messages).toHaveLength(1);
135
+ const kinds = messages[0].payload.map((e) => e.kind);
136
+ const texts = messages[0].payload.map((e) =>
137
+ typeof e.data === 'string' ? e.data : e.data?.id ?? e.data?.message?.content,
138
+ );
139
+ expect(kinds).toEqual(['user', 'assistant', 'tool_use', 'user']);
140
+ expect(texts).toEqual(['first', 'second', 'tu-order-1', 'third']);
141
+
142
+ closeTab(tabId);
143
+ });
144
+
145
+ test('OTEL still records ONE span per event, not one per batch', async () => {
146
+ const sessionId = 'sess-otel-batch';
147
+ const tabId = 'tab-otel-batch';
148
+ const filePath = writeTranscript(cwd, sessionId, []);
149
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
150
+ expect(res.ok).toBe(true);
151
+
152
+ const win = makeFakeWindow();
153
+ attachWindow(win);
154
+
155
+ const spy = vi.spyOn(otel, 'recordTranscriptEvent');
156
+ const before = spy.mock.calls.length;
157
+
158
+ appendTranscript(
159
+ filePath,
160
+ Array.from({ length: 6 }, (_, i) => assistantLine(`otel line ${i}`, `otel-tu-${i}`)),
161
+ );
162
+ const sub = __getSubForTest(tabId);
163
+ await sub.watcher?.close();
164
+ await __doFlushForTest(sub);
165
+
166
+ // 6 lines * 2 events = 12 spans, sent as one IPC batch.
167
+ expect(spy.mock.calls.length - before).toBe(12);
168
+ const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
169
+ expect(messages).toHaveLength(1);
170
+ expect(messages[0].payload).toHaveLength(12);
171
+
172
+ spy.mockRestore();
173
+ closeTab(tabId);
174
+ });
175
+
176
+ test('backpressure: a flush producing more than MAX_EVENTS_PER_BATCH events is sent as multiple ordered, capped batches', async () => {
177
+ const sessionId = 'sess-backpressure';
178
+ const tabId = 'tab-backpressure';
179
+ const filePath = writeTranscript(cwd, sessionId, []);
180
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
181
+ expect(res.ok).toBe(true);
182
+
183
+ const win = makeFakeWindow();
184
+ attachWindow(win);
185
+
186
+ // Each line yields 2 events; pick a line count that produces more than
187
+ // 2x the cap so at least 3 batches are required.
188
+ const lineCount = Math.ceil((MAX_EVENTS_PER_BATCH * 2.5) / 2);
189
+ const totalEvents = lineCount * 2;
190
+ appendTranscript(
191
+ filePath,
192
+ Array.from({ length: lineCount }, (_, i) => assistantLine(`bp line ${i}`, `bp-tu-${i}`)),
193
+ );
194
+ const sub = __getSubForTest(tabId);
195
+ await sub.watcher?.close();
196
+ await __doFlushForTest(sub);
197
+
198
+ const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
199
+ expect(messages.length).toBeGreaterThan(1);
200
+ for (const m of messages) {
201
+ expect(m.payload.length).toBeLessThanOrEqual(MAX_EVENTS_PER_BATCH);
202
+ }
203
+ // Concatenating every batch in send order reproduces the full ordered
204
+ // event list — no event dropped, none duplicated, none reordered.
205
+ const flattened = messages.flatMap((m) => m.payload);
206
+ expect(flattened).toHaveLength(totalEvents);
207
+ const ids = flattened.map((e) => (e.kind === 'tool_use' ? e.data.id : null)).filter(Boolean);
208
+ expect(ids).toEqual(Array.from({ length: lineCount }, (_, i) => `bp-tu-${i}`));
209
+
210
+ closeTab(tabId);
211
+ });
212
+
213
+ test('benchmark: IPC message count drops from N-per-event to a small number of batches for a 20+ event flush', async () => {
214
+ const sessionId = 'sess-benchmark';
215
+ const tabId = 'tab-benchmark';
216
+ const filePath = writeTranscript(cwd, sessionId, []);
217
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
218
+ expect(res.ok).toBe(true);
219
+
220
+ const win = makeFakeWindow();
221
+ attachWindow(win);
222
+
223
+ // 12 lines * 2 events = 24 events — at least 20, comfortably under one cap.
224
+ const lineCount = 12;
225
+ appendTranscript(
226
+ filePath,
227
+ Array.from({ length: lineCount }, (_, i) => assistantLine(`bench line ${i}`, `bench-tu-${i}`)),
228
+ );
229
+ const sub = __getSubForTest(tabId);
230
+ await sub.watcher?.close();
231
+ await __doFlushForTest(sub);
232
+
233
+ const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
234
+ const totalEvents = messages.reduce((n, m) => n + m.payload.length, 0);
235
+ expect(totalEvents).toBeGreaterThanOrEqual(20);
236
+
237
+ // BEFORE this PRD: doFlush sent one IPC message PER EVENT, so a 24-event
238
+ // flush meant 24 messages and (in the renderer) 24 store commits. AFTER:
239
+ // one message per flush (bounded by MAX_EVENTS_PER_BATCH), so the same
240
+ // flush is 1 message — a >20x reduction in IPC round-trips, and the
241
+ // renderer's live.ts/chat.ts now fold that one message into exactly one
242
+ // store commit each (see live.test.ts / chatTranscriptFeed.test.ts).
243
+ const beforeMessageCount = totalEvents; // one send per event, historically
244
+ const afterMessageCount = messages.length;
245
+ expect(afterMessageCount).toBeLessThan(beforeMessageCount);
246
+ expect(afterMessageCount).toBe(1);
247
+
248
+ closeTab(tabId);
249
+ });