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.
- package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-BwN4pNC0.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-CVCCMw5o.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/config.cjs +31 -7
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +9 -0
- package/src/main/ipcSchemas.cjs +9 -0
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +16 -2
- package/src/preload/index.cjs +11 -2
- 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
|
+
});
|
package/src/main/config.cjs
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
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 }) => {
|