claude-code-session-manager 0.62.1 → 0.63.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-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.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-B7dMJaix.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 +343 -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 +9 -0
  40. package/src/main/ipcSchemas.cjs +9 -0
  41. package/src/main/lib/runLogRetention.cjs +358 -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,343 @@
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
+ } = require('../lib/runLogRetention.cjs');
25
+
26
+ let runsDir;
27
+
28
+ beforeEach(() => {
29
+ runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-retention-test-'));
30
+ });
31
+
32
+ afterEach(() => {
33
+ fs.rmSync(runsDir, { recursive: true, force: true });
34
+ });
35
+
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+
38
+ function makeRun(runId, slug, { finishedAt, extraFiles = [] } = {}) {
39
+ const dir = path.join(runsDir, runId);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(path.join(dir, `${slug}.log`), 'log output\n');
42
+ fs.writeFileSync(
43
+ path.join(dir, `${slug}.meta.json`),
44
+ JSON.stringify({ slug, exitCode: 0, finishedAt })
45
+ );
46
+ for (const name of extraFiles) {
47
+ fs.writeFileSync(path.join(dir, name), 'extra\n');
48
+ }
49
+ return dir;
50
+ }
51
+
52
+ // ─── scanRunEntries ─────────────────────────────────────────────────────────
53
+
54
+ test('scanRunEntries: returns [] for a missing runsDir (never throws)', () => {
55
+ const missing = path.join(runsDir, 'does-not-exist');
56
+ expect(scanRunEntries(missing)).toEqual([]);
57
+ });
58
+
59
+ test('scanRunEntries: finds one entry per slug, even when a dir holds many slugs', () => {
60
+ makeRun('2026-01-01T00-00-00-000Z', 'a-slug', { finishedAt: Date.now() });
61
+ const dir = path.join(runsDir, '2026-01-01T00-00-00-000Z');
62
+ fs.writeFileSync(path.join(dir, 'b-slug.log'), 'log\n');
63
+ fs.writeFileSync(path.join(dir, 'b-slug.meta.json'), JSON.stringify({ finishedAt: Date.now() }));
64
+
65
+ const entries = scanRunEntries(runsDir);
66
+ expect(entries.map((e) => e.slug).sort()).toEqual(['a-slug', 'b-slug']);
67
+ expect(entries.every((e) => e.files.length === 2)).toBe(true);
68
+ });
69
+
70
+ // ─── default is dry-run: nothing removed with no opt-in ────────────────────
71
+
72
+ test('applyRetention: DEFAULT (no settings) is dry-run — deletes nothing, regardless of how old the runs are', () => {
73
+ const now = Date.now();
74
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
75
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
76
+
77
+ const result = applyRetention(runsDir, undefined, { now });
78
+ expect(result.deleted).toBe(false);
79
+ expect(result.removedFiles).toBeUndefined();
80
+ expect(fs.readdirSync(runsDir).length).toBe(2);
81
+
82
+ // The same data WOULD flag one eligible run once a real policy is computed
83
+ // (this is what the human-facing report is built from) — proves the dry
84
+ // run above wasn't a no-op because nothing was ever eligible.
85
+ const withPolicy = computeReport(runsDir, { maxAgeDays: 30 }, { now });
86
+ expect(withPolicy.eligible.length).toBeGreaterThan(0);
87
+ });
88
+
89
+ test('applyRetention: enabled=false is still dry-run regardless of policy', () => {
90
+ const now = Date.now();
91
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
92
+ makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 300 * DAY_MS });
93
+
94
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 30 } } };
95
+ const result = applyRetention(runsDir, settings, { now });
96
+ expect(result.deleted).toBe(false);
97
+ expect(fs.readdirSync(runsDir).length).toBe(2);
98
+ });
99
+
100
+ test('isRetentionEnabled: requires enabled===true AND a non-empty policy', () => {
101
+ expect(isRetentionEnabled(undefined)).toBe(false);
102
+ expect(isRetentionEnabled({})).toBe(false);
103
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true } })).toBe(false);
104
+ expect(isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: {} } })).toBe(false);
105
+ expect(
106
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } })
107
+ ).toBe(true);
108
+ expect(
109
+ isRetentionEnabled({ schedulerRunLogRetention: { enabled: 'true', policy: { maxAgeDays: 30 } } })
110
+ ).toBe(false); // must be boolean true, not truthy
111
+ });
112
+
113
+ // ─── explicit opt-in actually deletes ───────────────────────────────────────
114
+
115
+ test('applyRetention: deletes ONLY when schedulerRunLogRetention.enabled===true plus a policy', () => {
116
+ const now = Date.now();
117
+ makeRun('2020-01-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 400 * DAY_MS });
118
+ const recentDir = makeRun('2020-06-01T00-00-00-000Z', 'old-slug', { finishedAt: now - 1 * DAY_MS });
119
+
120
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
121
+ const result = applyRetention(runsDir, settings, { now });
122
+
123
+ expect(result.deleted).toBe(true);
124
+ expect(result.removedFiles).toBeGreaterThan(0);
125
+ // old, non-most-recent run's directory is gone entirely
126
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
127
+ // most-recent run for the slug survives regardless of policy
128
+ expect(fs.existsSync(recentDir)).toBe(true);
129
+ });
130
+
131
+ // ─── live-job protection ────────────────────────────────────────────────────
132
+
133
+ test('isLiveJob: pending/running/needs_review/investigating are live; completed/failed are not', () => {
134
+ expect(isLiveJob({ status: 'pending' })).toBe(true);
135
+ expect(isLiveJob({ status: 'running' })).toBe(true);
136
+ expect(isLiveJob({ status: 'needs_review' })).toBe(true);
137
+ expect(isLiveJob({ status: 'investigating' })).toBe(true);
138
+ expect(isLiveJob({ status: 'completed' })).toBe(false);
139
+ expect(isLiveJob({ status: 'failed' })).toBe(false);
140
+ expect(isLiveJob(null)).toBe(false);
141
+ });
142
+
143
+ test('a run belonging to a needs_review job is NEVER eligible, regardless of age', () => {
144
+ const now = Date.now();
145
+ // Two runs of the same slug so the protected one is not simply "most recent".
146
+ makeRun('2020-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 500 * DAY_MS });
147
+ makeRun('2020-02-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 490 * DAY_MS }); // this one is live
148
+ makeRun('2026-01-01T00-00-00-000Z', 'watched-slug', { finishedAt: now - 1 * DAY_MS }); // most recent
149
+
150
+ const jobs = [{ slug: 'watched-slug', runId: '2020-02-01T00-00-00-000Z', status: 'needs_review' }];
151
+ const liveKeys = liveKeysFromJobs(jobs);
152
+ expect(liveKeys.has('watched-slug|2020-02-01T00-00-00-000Z')).toBe(true);
153
+
154
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now, liveKeys });
155
+ const eligibleRunIds = report.eligible.map((e) => e.runId);
156
+ expect(eligibleRunIds).toContain('2020-01-01T00-00-00-000Z'); // old, not live, not most-recent — eligible
157
+ expect(eligibleRunIds).not.toContain('2020-02-01T00-00-00-000Z'); // live — protected
158
+ expect(eligibleRunIds).not.toContain('2026-01-01T00-00-00-000Z'); // most recent — protected
159
+
160
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
161
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
162
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
163
+ });
164
+
165
+ test('queued/running jobs are also protected, same as needs_review', () => {
166
+ const now = Date.now();
167
+ makeRun('2020-01-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 500 * DAY_MS });
168
+ makeRun('2020-02-01T00-00-00-000Z', 'busy-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, also running
169
+
170
+ const jobs = [{ slug: 'busy-slug', runId: '2020-02-01T00-00-00-000Z', status: 'running' }];
171
+ const liveKeys = liveKeysFromJobs(jobs);
172
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
173
+ const result = applyRetention(runsDir, settings, { now, liveKeys });
174
+
175
+ // old one is eligible and removed (age 0 means "older than 0 days")
176
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
177
+ // running one is protected even though its own age also clears the 0-day cap
178
+ expect(fs.existsSync(path.join(runsDir, '2020-02-01T00-00-00-000Z'))).toBe(true);
179
+ });
180
+
181
+ // ─── most-recent-per-slug protection ────────────────────────────────────────
182
+
183
+ test('the most recent run for a slug is never eligible, even past the age cap', () => {
184
+ const now = Date.now();
185
+ makeRun('2018-01-01T00-00-00-000Z', 'lonely-slug', { finishedAt: now - 3000 * DAY_MS });
186
+
187
+ const report = computeReport(runsDir, { maxAgeDays: 1 }, { now });
188
+ expect(report.eligible.length).toBe(0);
189
+
190
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 1 } } };
191
+ const result = applyRetention(runsDir, settings, { now });
192
+ expect(result.removedFiles).toBe(0);
193
+ expect(fs.existsSync(path.join(runsDir, '2018-01-01T00-00-00-000Z'))).toBe(true);
194
+ });
195
+
196
+ // ─── age + count combinability ──────────────────────────────────────────────
197
+
198
+ test('age-based policy: only entries older than maxAgeDays (and not most-recent) are eligible', () => {
199
+ const now = Date.now();
200
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 90 * DAY_MS });
201
+ makeRun('2020-06-01T00-00-00-000Z', 's', { finishedAt: now - 10 * DAY_MS });
202
+ makeRun('2020-09-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
203
+
204
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
205
+ expect(report.eligible.map((e) => e.runId)).toEqual(['2020-01-01T00-00-00-000Z']);
206
+ });
207
+
208
+ test('count-based policy: keeps the N most recent runs per slug, rest eligible', () => {
209
+ const now = Date.now();
210
+ const ids = [];
211
+ for (let i = 0; i < 5; i++) {
212
+ const runId = `2020-0${i + 1}-01T00-00-00-000Z`;
213
+ ids.push(runId);
214
+ makeRun(runId, 'count-slug', { finishedAt: now - (5 - i) * DAY_MS });
215
+ }
216
+
217
+ const report = computeReport(runsDir, { keepPerSlug: 2 }, { now });
218
+ // 5 runs, keep 2 most recent (ranks 0,1) -> 3 eligible (ranks 2,3,4)
219
+ expect(report.eligible.length).toBe(3);
220
+ const eligibleRunIds = new Set(report.eligible.map((e) => e.runId));
221
+ expect(eligibleRunIds.has(ids[4])).toBe(false); // most recent
222
+ expect(eligibleRunIds.has(ids[3])).toBe(false); // 2nd most recent, kept by count
223
+ expect(eligibleRunIds.has(ids[0])).toBe(true);
224
+ });
225
+
226
+ test('age + count policies combine conservatively (AND): an entry needs both to be eligible', () => {
227
+ const now = Date.now();
228
+ // Old by age, but within the keepPerSlug=3 window (rank 1 of 3).
229
+ makeRun('2020-01-01T00-00-00-000Z', 's', { finishedAt: now - 400 * DAY_MS });
230
+ makeRun('2020-02-01T00-00-00-000Z', 's', { finishedAt: now - 300 * DAY_MS });
231
+ makeRun('2020-03-01T00-00-00-000Z', 's', { finishedAt: now - 1 * DAY_MS }); // most recent
232
+
233
+ const policy = { maxAgeDays: 30, keepPerSlug: 3 };
234
+ const report = computeReport(runsDir, policy, { now });
235
+ // Both are old enough by age, but keepPerSlug=3 keeps all 3 ranks (0,1,2) — none eligible.
236
+ expect(report.eligible.length).toBe(0);
237
+ });
238
+
239
+ // ─── report surface: usage stats ────────────────────────────────────────────
240
+
241
+ test('computeReport: reports total bytes, dir count, and oldest run date', () => {
242
+ const now = Date.now();
243
+ makeRun('2020-01-01T00-00-00-000Z', 'a', { finishedAt: now - 100 * DAY_MS });
244
+ makeRun('2020-06-01T00-00-00-000Z', 'b', { finishedAt: now - 10 * DAY_MS });
245
+
246
+ const report = computeReport(runsDir, {}, { now });
247
+ expect(report.usage.dirCount).toBe(2);
248
+ expect(report.usage.runCount).toBe(2);
249
+ expect(report.usage.totalBytes).toBeGreaterThan(0);
250
+ expect(report.usage.oldestRunAt).toBe(now - 100 * DAY_MS);
251
+ // no policy set -> nothing eligible, purely informational
252
+ expect(report.eligible.length).toBe(0);
253
+ });
254
+
255
+ // ─── directory-level cleanup: partial dirs never fully removed ─────────────
256
+
257
+ test('a directory holding an unclaimed extra file is never in removableDirs, even if its own entry is eligible', () => {
258
+ const now = Date.now();
259
+ makeRun('2020-01-01T00-00-00-000Z', 'shared-dir-slug', {
260
+ finishedAt: now - 400 * DAY_MS,
261
+ extraFiles: ['definition-of-done-abc12345.md'],
262
+ });
263
+ makeRun('2026-01-01T00-00-00-000Z', 'shared-dir-slug', { finishedAt: now - 1 * DAY_MS });
264
+
265
+ const report = computeReport(runsDir, { maxAgeDays: 30 }, { now });
266
+ expect(report.eligible.length).toBe(1);
267
+ expect(report.removableDirs).toEqual([]);
268
+
269
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
270
+ const result = applyRetention(runsDir, settings, { now });
271
+ // the slug's own log+meta are removed...
272
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'shared-dir-slug.log'))).toBe(false);
273
+ // ...but the directory itself survives because of the unclaimed DoD report
274
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(true);
275
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z', 'definition-of-done-abc12345.md'))).toBe(true);
276
+ });
277
+
278
+ test('a directory whose sole entry is eligible is fully removed (dir + files)', () => {
279
+ const now = Date.now();
280
+ makeRun('2020-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 400 * DAY_MS });
281
+ makeRun('2026-01-01T00-00-00-000Z', 'solo-slug', { finishedAt: now - 1 * DAY_MS });
282
+
283
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 30 } } };
284
+ applyRetention(runsDir, settings, { now });
285
+ expect(fs.existsSync(path.join(runsDir, '2020-01-01T00-00-00-000Z'))).toBe(false);
286
+ });
287
+
288
+ // ─── liveKeysFromJobs: null-runId backfill (needs_review can lose its runId) ─
289
+
290
+ test('liveKeysFromJobs: backfills protection via a runsDir scan when a live job has lost its runId', () => {
291
+ makeRun('2020-01-01T00-00-00-000Z', 'lost-slug', { finishedAt: Date.now() });
292
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
293
+ const keys = liveKeysFromJobs(jobs, { runsDir });
294
+ expect(keys.has('lost-slug|2020-01-01T00-00-00-000Z')).toBe(true);
295
+ });
296
+
297
+ test('liveKeysFromJobs: without a runsDir hint, a live job with no runId yields no protection (documented limitation — callers should always pass runsDir)', () => {
298
+ const jobs = [{ slug: 'lost-slug', runId: null, status: 'needs_review' }];
299
+ expect(liveKeysFromJobs(jobs).size).toBe(0);
300
+ });
301
+
302
+ // ─── applyRetention: fail-safe fallback when the caller forgets liveKeys/jobs ─
303
+
304
+ test('applyRetention: enabled=true with no liveKeys/jobs falls back to reading the real scheduler queue (queueStore) instead of silently protecting nothing', () => {
305
+ const now = Date.now();
306
+ makeRun('2020-05-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 400 * DAY_MS });
307
+ makeRun('2020-06-01T00-00-00-000Z', 'fallback-slug', { finishedAt: now - 1 * DAY_MS }); // most recent, unrelated
308
+
309
+ const queueStore = require('../lib/queueStore.cjs');
310
+ const original = queueStore.readMergedSync;
311
+ queueStore.readMergedSync = () => ({
312
+ jobs: [{ slug: 'fallback-slug', runId: '2020-05-01T00-00-00-000Z', status: 'running' }],
313
+ });
314
+ try {
315
+ const settings = { schedulerRunLogRetention: { enabled: true, policy: { maxAgeDays: 0 } } };
316
+ applyRetention(runsDir, settings, { now }); // deliberately no liveKeys, no jobs
317
+ expect(fs.existsSync(path.join(runsDir, '2020-05-01T00-00-00-000Z'))).toBe(true);
318
+ } finally {
319
+ queueStore.readMergedSync = original;
320
+ }
321
+ });
322
+
323
+ test('applyRetention: dry-run path (enabled=false) never reads queueStore for live protection', () => {
324
+ const now = Date.now();
325
+ makeRun('2020-05-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 400 * DAY_MS });
326
+ makeRun('2020-06-01T00-00-00-000Z', 'no-touch-slug', { finishedAt: now - 1 * DAY_MS });
327
+
328
+ const queueStore = require('../lib/queueStore.cjs');
329
+ const original = queueStore.readMergedSync;
330
+ let called = false;
331
+ queueStore.readMergedSync = () => {
332
+ called = true;
333
+ return { jobs: [] };
334
+ };
335
+ try {
336
+ const settings = { schedulerRunLogRetention: { enabled: false, policy: { maxAgeDays: 0 } } };
337
+ const result = applyRetention(runsDir, settings, { now });
338
+ expect(result.deleted).toBe(false);
339
+ expect(called).toBe(false);
340
+ } finally {
341
+ queueStore.readMergedSync = original;
342
+ }
343
+ });
@@ -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
+ });
@@ -199,17 +199,41 @@ async function readJson(abs) {
199
199
  }
200
200
  }
201
201
 
202
- async function readText(abs) {
202
+ async function readText(abs, opts = {}) {
203
+ const { maxBytes } = opts;
203
204
  try {
204
205
  abs = validatePath(expandHome(abs));
205
- const raw = await fsp.readFile(abs, 'utf8');
206
- const stat = await fsp.stat(abs);
207
- return { exists: true, text: raw, mtimeMs: stat.mtimeMs, error: null };
206
+ if (maxBytes == null) {
207
+ const raw = await fsp.readFile(abs, 'utf8');
208
+ const stat = await fsp.stat(abs);
209
+ return { exists: true, text: raw, mtimeMs: stat.mtimeMs, error: null, truncated: false };
210
+ }
211
+ const handle = await fsp.open(abs, 'r');
212
+ try {
213
+ const stat = await handle.stat();
214
+ const readLen = Math.min(maxBytes, stat.size);
215
+ const buf = Buffer.alloc(readLen);
216
+ if (readLen > 0) {
217
+ await handle.read(buf, 0, readLen, 0);
218
+ }
219
+ const truncated = stat.size > readLen;
220
+ let text = buf.toString('utf8');
221
+ if (truncated) {
222
+ // A byte-bounded read can split a multi-byte UTF-8 char or a JSONL
223
+ // line mid-way; drop the partial trailing line so callers never
224
+ // JSON.parse a truncated fragment.
225
+ const lastNewline = text.lastIndexOf('\n');
226
+ text = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
227
+ }
228
+ return { exists: true, text, mtimeMs: stat.mtimeMs, error: null, truncated };
229
+ } finally {
230
+ await handle.close();
231
+ }
208
232
  } catch (e) {
209
233
  if (e.code === 'ENOENT') {
210
- return { exists: false, text: '', mtimeMs: 0, error: null };
234
+ return { exists: false, text: '', mtimeMs: 0, error: null, truncated: false };
211
235
  }
212
- return { exists: false, text: '', mtimeMs: 0, error: e.message };
236
+ return { exists: false, text: '', mtimeMs: 0, error: e.message, truncated: false };
213
237
  }
214
238
  }
215
239
 
@@ -438,7 +462,7 @@ function closeAllWatchers() {
438
462
  function registerConfigHandlers() {
439
463
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
440
464
  ipcMain.handle('config:read-json', v(s.configPath, ({ path: p }) => readJson(p)));
441
- ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
465
+ ipcMain.handle('config:read-text', v(s.configReadText, ({ path: p, maxBytes }) => readText(p, { maxBytes })));
442
466
  // `writer` is the renderer's declared owner id for the single-writer law
443
467
  // (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
444
468
  ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {