claude-code-session-manager 0.35.18 → 0.37.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/{TiptapBody-DgFO_m3g.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/docEdit.test.cjs +82 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/memoryStale.test.cjs +88 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +57 -18
- package/src/main/config.cjs +8 -0
- package/src/main/docEdit.cjs +133 -0
- package/src/main/files.cjs +53 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +37 -1
- package/src/main/ipcSchemas.cjs +29 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/memoryStale.cjs +116 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/memoryTool.cjs +49 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +7 -0
- package/dist/assets/index-4dJkn9nT.js +0 -3559
- package/dist/assets/index-DX2w2YhJ.css +0 -32
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* historyRollup.test.cjs — unit tests for src/main/lib/historyRollup.cjs
|
|
3
|
+
* (durable daily rollup) and its integration into historyAggregator.cjs
|
|
4
|
+
* (rollup-first closed-day aggregation, finalize pass, backfill, deleted-file
|
|
5
|
+
* immunity).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/historyRollup.test.cjs
|
|
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
|
+
|
|
17
|
+
let tmpHome;
|
|
18
|
+
let realHome;
|
|
19
|
+
let historyRollup;
|
|
20
|
+
let historyAggregator;
|
|
21
|
+
|
|
22
|
+
function encodeCwd(cwd) {
|
|
23
|
+
return cwd.replace(/\//g, '-');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeTranscript(projectCwd, sessionId, lines, mtime) {
|
|
27
|
+
const encoded = encodeCwd(projectCwd);
|
|
28
|
+
const dir = path.join(tmpHome, '.claude', 'projects', encoded);
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
const file = path.join(dir, `${sessionId}.jsonl`);
|
|
31
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8');
|
|
32
|
+
if (mtime) fs.utimesSync(file, mtime, mtime);
|
|
33
|
+
return file;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function userLine(ts) {
|
|
37
|
+
return { role: 'user', ts };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assistantLine(ts, model, tokens = {}) {
|
|
41
|
+
return {
|
|
42
|
+
ts,
|
|
43
|
+
message: {
|
|
44
|
+
role: 'assistant',
|
|
45
|
+
model,
|
|
46
|
+
usage: {
|
|
47
|
+
input_tokens: tokens.in ?? 10,
|
|
48
|
+
output_tokens: tokens.out ?? 5,
|
|
49
|
+
cache_read_input_tokens: tokens.cacheR ?? 0,
|
|
50
|
+
cache_creation_input_tokens: tokens.cacheC ?? 0,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// These modules are required via Node's own require() (this is a .cjs file,
|
|
57
|
+
// not transformed ESM), so vi.resetModules() — which only resets vitest's
|
|
58
|
+
// ESM module graph — does NOT clear them. Each module also bakes os.homedir()
|
|
59
|
+
// into a top-level const (ROLLUP_PATH, config.cjs's allowedRoots/
|
|
60
|
+
// WRITE_PREFIXES) at first require. Without purging Node's require.cache here,
|
|
61
|
+
// every test after the first would keep writing to the FIRST test's tmpHome.
|
|
62
|
+
const MODULES_TO_RELOAD = [
|
|
63
|
+
'../lib/historyRollup.cjs',
|
|
64
|
+
'../historyAggregator.cjs',
|
|
65
|
+
'../config.cjs',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function purgeRequireCache() {
|
|
69
|
+
for (const m of MODULES_TO_RELOAD) {
|
|
70
|
+
try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
realHome = process.env.HOME;
|
|
76
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'history-rollup-test-'));
|
|
77
|
+
process.env.HOME = tmpHome;
|
|
78
|
+
purgeRequireCache();
|
|
79
|
+
historyRollup = require('../lib/historyRollup.cjs');
|
|
80
|
+
historyAggregator = require('../historyAggregator.cjs');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
process.env.HOME = realHome;
|
|
85
|
+
purgeRequireCache();
|
|
86
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('rollupKey is stable and distinguishes date/project/model', () => {
|
|
90
|
+
const a = historyRollup.rollupKey('2026-07-01', 'proj-a', 'sonnet');
|
|
91
|
+
const b = historyRollup.rollupKey('2026-07-01', 'proj-a', 'opus');
|
|
92
|
+
const c = historyRollup.rollupKey('2026-07-02', 'proj-a', 'sonnet');
|
|
93
|
+
expect(a).not.toBe(b);
|
|
94
|
+
expect(a).not.toBe(c);
|
|
95
|
+
expect(historyRollup.rollupKey('2026-07-01', 'proj-a', 'sonnet')).toBe(a);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('mergeRollupLines: last-write-wins per key', () => {
|
|
99
|
+
const lines = [
|
|
100
|
+
JSON.stringify({ date: '2026-07-01', projectDir: 'p', modelId: 'sonnet', inputTokens: 10 }),
|
|
101
|
+
JSON.stringify({ date: '2026-07-01', projectDir: 'p', modelId: 'sonnet', inputTokens: 99 }),
|
|
102
|
+
JSON.stringify({ date: '2026-07-01', projectDir: 'p', modelId: 'opus', inputTokens: 5 }),
|
|
103
|
+
];
|
|
104
|
+
const merged = historyRollup.mergeRollupLines(lines);
|
|
105
|
+
expect(merged.size).toBe(2);
|
|
106
|
+
const key = historyRollup.rollupKey('2026-07-01', 'p', 'sonnet');
|
|
107
|
+
expect(merged.get(key).inputTokens).toBe(99);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('mergeRollupLines: skips malformed lines without throwing', () => {
|
|
111
|
+
const lines = ['not json', '', JSON.stringify({ date: '2026-07-01', projectDir: 'p', modelId: '' })];
|
|
112
|
+
expect(() => historyRollup.mergeRollupLines(lines)).not.toThrow();
|
|
113
|
+
expect(historyRollup.mergeRollupLines(lines).size).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('appendRollupDays + readRollup: date-range read only returns in-range buckets', async () => {
|
|
117
|
+
await historyRollup.appendRollupDays([
|
|
118
|
+
{ date: '2026-07-01', projectDir: 'p', modelId: 'sonnet', inputTokens: 1 },
|
|
119
|
+
{ date: '2026-07-05', projectDir: 'p', modelId: 'sonnet', inputTokens: 2 },
|
|
120
|
+
{ date: '2026-07-10', projectDir: 'p', modelId: 'sonnet', inputTokens: 3 },
|
|
121
|
+
]);
|
|
122
|
+
const map = await historyRollup.readRollup('2026-07-02', '2026-07-09');
|
|
123
|
+
expect(map.size).toBe(1);
|
|
124
|
+
const only = Array.from(map.values())[0];
|
|
125
|
+
expect(only.date).toBe('2026-07-05');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('appendRollupDays: compacts (dedupes) once file exceeds threshold', async () => {
|
|
129
|
+
// Force a tiny threshold isn't exposed, so just verify repeated appends of
|
|
130
|
+
// the SAME key collapse to one line on disk after a compact() call.
|
|
131
|
+
await historyRollup.appendRollupDays([
|
|
132
|
+
{ date: '2026-07-01', projectDir: 'p', modelId: 'sonnet', inputTokens: 1 },
|
|
133
|
+
]);
|
|
134
|
+
await historyRollup.appendRollupDays([
|
|
135
|
+
{ date: '2026-07-01', projectDir: 'p', modelId: 'sonnet', inputTokens: 2 },
|
|
136
|
+
]);
|
|
137
|
+
await historyRollup.compact();
|
|
138
|
+
const raw = fs.readFileSync(historyRollup.ROLLUP_PATH, 'utf8');
|
|
139
|
+
const nonEmptyLines = raw.split('\n').filter((l) => l.trim());
|
|
140
|
+
expect(nonEmptyLines.length).toBe(1);
|
|
141
|
+
expect(JSON.parse(nonEmptyLines[0]).inputTokens).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('finalizeClosedDays: skips today, only finalizes days strictly before today', async () => {
|
|
145
|
+
const today = new Date().toLocaleDateString('en-CA');
|
|
146
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toLocaleDateString('en-CA');
|
|
147
|
+
const cwd = '/tmp/projfinalize';
|
|
148
|
+
|
|
149
|
+
writeTranscript(cwd, 'sess-today', [
|
|
150
|
+
userLine(new Date().toISOString()),
|
|
151
|
+
assistantLine(new Date().toISOString(), 'claude-sonnet-5'),
|
|
152
|
+
]);
|
|
153
|
+
writeTranscript(cwd, 'sess-yesterday', [
|
|
154
|
+
userLine(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()),
|
|
155
|
+
assistantLine(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), 'claude-sonnet-5'),
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
await historyAggregator.finalizeClosedDays();
|
|
159
|
+
|
|
160
|
+
const map = await historyRollup.readRollup('2000-01-01', today);
|
|
161
|
+
const todayFinalized = historyRollup.isDateFinalized(map, today);
|
|
162
|
+
const yesterdayFinalized = historyRollup.isDateFinalized(map, yesterday);
|
|
163
|
+
expect(todayFinalized).toBe(false);
|
|
164
|
+
expect(yesterdayFinalized).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('deleted-transcript immunity: aggregate returns a finalized day even after its .jsonl is deleted', async () => {
|
|
168
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
169
|
+
const cwd = '/tmp/projdeleted';
|
|
170
|
+
const file = writeTranscript(cwd, 'sess-del', [
|
|
171
|
+
userLine(yesterday.toISOString()),
|
|
172
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 100, out: 50 }),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
await historyAggregator.finalizeClosedDays();
|
|
176
|
+
fs.rmSync(file);
|
|
177
|
+
|
|
178
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
179
|
+
const result = await historyAggregator.remote.aggregate({ fromDate: yStr, toDate: yStr });
|
|
180
|
+
const row = result.rows.find((r) => r.encodedCwd === encodeCwd(cwd));
|
|
181
|
+
expect(row).toBeTruthy();
|
|
182
|
+
expect(row.inputTokens).toBeGreaterThanOrEqual(100);
|
|
183
|
+
expect(result.rollupDays).toBeGreaterThanOrEqual(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('backfill fold-in: aggregate() folds a rollup-missing past day into the rollup', async () => {
|
|
187
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
188
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
189
|
+
const cwd = '/tmp/projbackfill';
|
|
190
|
+
writeTranscript(cwd, 'sess-backfill', [
|
|
191
|
+
userLine(yesterday.toISOString()),
|
|
192
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 7, out: 3 }),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
// No finalize pass has run — the rollup has nothing for this day yet.
|
|
196
|
+
const before = await historyRollup.readRollup(yStr, yStr);
|
|
197
|
+
expect(before.size).toBe(0);
|
|
198
|
+
|
|
199
|
+
const result = await historyAggregator.remote.aggregate({ fromDate: yStr, toDate: yStr });
|
|
200
|
+
const row = result.rows.find((r) => r.encodedCwd === encodeCwd(cwd));
|
|
201
|
+
expect(row).toBeTruthy();
|
|
202
|
+
expect(row.inputTokens).toBeGreaterThanOrEqual(7);
|
|
203
|
+
|
|
204
|
+
const after = await historyRollup.readRollup(yStr, yStr);
|
|
205
|
+
expect(after.size).toBeGreaterThan(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('finalizeClosedDays: budgetMs exceeded before any dir is walked → no dates finalized, partial=true', async () => {
|
|
209
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
210
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
211
|
+
const cwd = '/tmp/projbudgetcap';
|
|
212
|
+
writeTranscript(cwd, 'sess-budgetcap', [
|
|
213
|
+
userLine(yesterday.toISOString()),
|
|
214
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 11, out: 4 }),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const result = await historyAggregator.finalizeClosedDays({ budgetMs: -1 });
|
|
218
|
+
expect(result.partial).toBe(true);
|
|
219
|
+
expect(result.finalizedDates).toEqual([]);
|
|
220
|
+
|
|
221
|
+
const map = await historyRollup.readRollup('2000-01-01', yStr);
|
|
222
|
+
expect(historyRollup.isDateFinalized(map, yStr)).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('finalizeClosedDays: unbounded budgetMs finalizes normally (partial=false)', async () => {
|
|
226
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
227
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
228
|
+
const cwd = '/tmp/projbudgetok';
|
|
229
|
+
writeTranscript(cwd, 'sess-budgetok', [
|
|
230
|
+
userLine(yesterday.toISOString()),
|
|
231
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 9, out: 2 }),
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const result = await historyAggregator.finalizeClosedDays({ budgetMs: 60_000 });
|
|
235
|
+
expect(result.partial).toBe(false);
|
|
236
|
+
expect(result.finalizedDates).toContain(yStr);
|
|
237
|
+
|
|
238
|
+
const map = await historyRollup.readRollup('2000-01-01', yStr);
|
|
239
|
+
expect(historyRollup.isDateFinalized(map, yStr)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('finalizeClosedDays: dryRun computes finalizedDates but writes nothing', async () => {
|
|
243
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
244
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
245
|
+
const cwd = '/tmp/projdryrun';
|
|
246
|
+
writeTranscript(cwd, 'sess-dryrun', [
|
|
247
|
+
userLine(yesterday.toISOString()),
|
|
248
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 5, out: 1 }),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
const result = await historyAggregator.finalizeClosedDays({ dryRun: true });
|
|
252
|
+
expect(result.partial).toBe(false);
|
|
253
|
+
expect(result.finalizedDates).toContain(yStr);
|
|
254
|
+
|
|
255
|
+
const map = await historyRollup.readRollup('2000-01-01', yStr);
|
|
256
|
+
expect(map.size).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('computeActiveMinutes: clamps to [0, 24h] and handles missing/invalid timestamps', () => {
|
|
260
|
+
const { computeActiveMinutes } = historyAggregator;
|
|
261
|
+
expect(computeActiveMinutes(null, '2026-07-01T00:10:00Z')).toBe(0);
|
|
262
|
+
expect(computeActiveMinutes('2026-07-01T00:00:00Z', null)).toBe(0);
|
|
263
|
+
expect(computeActiveMinutes('not-a-date', '2026-07-01T00:10:00Z')).toBe(0);
|
|
264
|
+
expect(computeActiveMinutes('2026-07-01T00:00:00Z', '2026-07-01T00:10:00Z')).toBeCloseTo(10, 5);
|
|
265
|
+
// Out-of-order (last before first) clamps to 0, not negative.
|
|
266
|
+
expect(computeActiveMinutes('2026-07-01T00:10:00Z', '2026-07-01T00:00:00Z')).toBe(0);
|
|
267
|
+
// A 30h span clamps to the 24h ceiling.
|
|
268
|
+
expect(computeActiveMinutes('2026-07-01T00:00:00Z', '2026-07-02T06:00:00Z')).toBe(24 * 60);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('rollup v1 lines default activeMinutes to 0 and v to 1 at read time', async () => {
|
|
272
|
+
await historyRollup.appendRollupDays([
|
|
273
|
+
// v1-shaped line: no activeMinutes, no v field.
|
|
274
|
+
{ date: '2026-07-01', projectDir: 'p', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 5 },
|
|
275
|
+
]);
|
|
276
|
+
const map = await historyRollup.readRollup('2026-07-01', '2026-07-01');
|
|
277
|
+
const bucket = map.get(historyRollup.rollupKey('2026-07-01', 'p', historyRollup.TOTALS_MODEL_ID));
|
|
278
|
+
expect(bucket.activeMinutes).toBe(0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('refreshIntradayToday: upserts a provisional (non-finalized) line for today; a second run replaces it after compact', async () => {
|
|
282
|
+
const now = new Date();
|
|
283
|
+
const today = now.toLocaleDateString('en-CA');
|
|
284
|
+
const cwd = '/tmp/projintraday';
|
|
285
|
+
|
|
286
|
+
writeTranscript(cwd, 'sess-intraday', [
|
|
287
|
+
userLine(new Date(now.getTime() - 5 * 60_000).toISOString()),
|
|
288
|
+
assistantLine(new Date(now.getTime() - 5 * 60_000).toISOString(), 'claude-sonnet-5', { in: 10, out: 5 }),
|
|
289
|
+
assistantLine(now.toISOString(), 'claude-sonnet-5', { in: 3, out: 1 }),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const first = await historyAggregator.refreshIntradayToday();
|
|
293
|
+
expect(first.date).toBe(today);
|
|
294
|
+
expect(first.projectsUpdated).toBeGreaterThanOrEqual(1);
|
|
295
|
+
|
|
296
|
+
let map = await historyRollup.readRollup(today, today);
|
|
297
|
+
expect(historyRollup.isDateFinalized(map, today)).toBe(false);
|
|
298
|
+
const totalsKey = historyRollup.rollupKey(today, encodeCwd(cwd), historyRollup.TOTALS_MODEL_ID);
|
|
299
|
+
const firstBucket = map.get(totalsKey);
|
|
300
|
+
expect(firstBucket).toBeTruthy();
|
|
301
|
+
expect(firstBucket.promptCount).toBeGreaterThanOrEqual(1);
|
|
302
|
+
expect(firstBucket.activeMinutes).toBeGreaterThan(0);
|
|
303
|
+
|
|
304
|
+
// A second refresh (e.g. 5 min later, more activity) replaces — not
|
|
305
|
+
// duplicates — today's line for the same key once the file is compacted.
|
|
306
|
+
await historyAggregator.refreshIntradayToday();
|
|
307
|
+
await historyRollup.compact();
|
|
308
|
+
const raw = fs.readFileSync(historyRollup.ROLLUP_PATH, 'utf8');
|
|
309
|
+
const matchingLines = raw.split('\n').filter((l) => {
|
|
310
|
+
if (!l.trim()) return false;
|
|
311
|
+
const obj = JSON.parse(l);
|
|
312
|
+
return obj.date === today && obj.projectDir === encodeCwd(cwd) && obj.modelId === historyRollup.TOTALS_MODEL_ID;
|
|
313
|
+
});
|
|
314
|
+
expect(matchingLines.length).toBe(1);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('history:aggregate PARSE_BUDGET_MS truncation only affects today; finalized closed days come back complete', async () => {
|
|
318
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
319
|
+
const yStr = yesterday.toLocaleDateString('en-CA');
|
|
320
|
+
const cwd = '/tmp/projbudget';
|
|
321
|
+
writeTranscript(cwd, 'sess-budget', [
|
|
322
|
+
userLine(yesterday.toISOString()),
|
|
323
|
+
assistantLine(yesterday.toISOString(), 'claude-sonnet-5', { in: 42, out: 21 }),
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
await historyAggregator.finalizeClosedDays();
|
|
327
|
+
|
|
328
|
+
const result = await historyAggregator.remote.aggregate({ fromDate: yStr, toDate: yStr });
|
|
329
|
+
expect(result.truncated).toBe(false);
|
|
330
|
+
const row = result.rows.find((r) => r.encodedCwd === encodeCwd(cwd));
|
|
331
|
+
expect(row).toBeTruthy();
|
|
332
|
+
expect(row.inputTokens).toBeGreaterThanOrEqual(42);
|
|
333
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { scoreMemories } = require('../lib/memoryStale.cjs');
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 86_400_000;
|
|
8
|
+
const NOW = 1_800_000_000_000; // fixed epoch ms for deterministic tests
|
|
9
|
+
|
|
10
|
+
test('a fresh linked memory is not stale', () => {
|
|
11
|
+
const entries = [
|
|
12
|
+
{ name: 'a.md', mtimeMs: NOW - 5 * DAY_MS, body: 'links to [[b]]' },
|
|
13
|
+
{ name: 'b.md', mtimeMs: NOW - 5 * DAY_MS, body: 'nothing here' },
|
|
14
|
+
];
|
|
15
|
+
const result = scoreMemories({ entries, now: NOW, existsPath: () => true });
|
|
16
|
+
const b = result.find((r) => r.name === 'b.md');
|
|
17
|
+
assert.equal(b.stale, false);
|
|
18
|
+
assert.equal(b.inboundLinks, 1);
|
|
19
|
+
assert.deepEqual(b.reasons, []);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a 200-day-old memory with zero inbound links is stale', () => {
|
|
23
|
+
const entries = [
|
|
24
|
+
{ name: 'old.md', mtimeMs: NOW - 200 * DAY_MS, body: 'no links to me' },
|
|
25
|
+
];
|
|
26
|
+
const result = scoreMemories({ entries, now: NOW, existsPath: () => true });
|
|
27
|
+
const old = result[0];
|
|
28
|
+
assert.equal(old.ageDays, 200);
|
|
29
|
+
assert.equal(old.inboundLinks, 0);
|
|
30
|
+
assert.equal(old.stale, true);
|
|
31
|
+
assert.ok(old.reasons.some((r) => /90\+ days old/.test(r)));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a 200-day-old memory WITH an inbound link is not stale', () => {
|
|
35
|
+
const entries = [
|
|
36
|
+
{ name: 'old.md', mtimeMs: NOW - 200 * DAY_MS, body: 'old content' },
|
|
37
|
+
{ name: 'linker.md', mtimeMs: NOW - 1 * DAY_MS, body: 'see [[old]] for context' },
|
|
38
|
+
];
|
|
39
|
+
const result = scoreMemories({ entries, now: NOW, existsPath: () => true });
|
|
40
|
+
const old = result.find((r) => r.name === 'old.md');
|
|
41
|
+
assert.equal(old.inboundLinks, 1);
|
|
42
|
+
assert.equal(old.stale, false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a recent memory naming a dead path IS stale', () => {
|
|
46
|
+
const entries = [
|
|
47
|
+
{ name: 'recent.md', mtimeMs: NOW - 1 * DAY_MS, body: 'see `src/main/gone.cjs` for details' },
|
|
48
|
+
];
|
|
49
|
+
const existsPath = (p) => p !== 'src/main/gone.cjs';
|
|
50
|
+
const result = scoreMemories({ entries, now: NOW, existsPath });
|
|
51
|
+
const r = result[0];
|
|
52
|
+
assert.equal(r.ageDays, 1);
|
|
53
|
+
assert.deepEqual(r.deadRefs, ['src/main/gone.cjs']);
|
|
54
|
+
assert.equal(r.stale, true);
|
|
55
|
+
assert.ok(r.reasons.some((s) => /no longer exist/.test(s)));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a :42 line suffix is stripped before the existence check', () => {
|
|
59
|
+
const entries = [
|
|
60
|
+
{ name: 'recent.md', mtimeMs: NOW - 1 * DAY_MS, body: 'see `src/main/config.cjs:42` for the helper' },
|
|
61
|
+
];
|
|
62
|
+
let checked = null;
|
|
63
|
+
const existsPath = (p) => { checked = p; return true; };
|
|
64
|
+
const result = scoreMemories({ entries, now: NOW, existsPath });
|
|
65
|
+
assert.equal(checked, 'src/main/config.cjs');
|
|
66
|
+
assert.deepEqual(result[0].deadRefs, []);
|
|
67
|
+
assert.equal(result[0].stale, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('the candidate cap holds at 20', () => {
|
|
71
|
+
const paths = Array.from({ length: 30 }, (_, i) => `src/main/file${i}.cjs`);
|
|
72
|
+
const body = paths.map((p) => `\`${p}\``).join(' ');
|
|
73
|
+
const entries = [{ name: 'many.md', mtimeMs: NOW - 1 * DAY_MS, body }];
|
|
74
|
+
let callCount = 0;
|
|
75
|
+
const existsPath = () => { callCount += 1; return false; };
|
|
76
|
+
const result = scoreMemories({ entries, now: NOW, existsPath });
|
|
77
|
+
assert.equal(result[0].deadRefs.length, 20);
|
|
78
|
+
assert.equal(callCount, 20);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('[[self]] in a memory own body does not count as an inbound link', () => {
|
|
82
|
+
const entries = [
|
|
83
|
+
{ name: 'self.md', mtimeMs: NOW - 200 * DAY_MS, body: 'refers to itself: [[self]]' },
|
|
84
|
+
];
|
|
85
|
+
const result = scoreMemories({ entries, now: NOW, existsPath: () => true });
|
|
86
|
+
assert.equal(result[0].inboundLinks, 0);
|
|
87
|
+
assert.equal(result[0].stale, true);
|
|
88
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdParserHighWater.test.cjs — regression test for allocateParallelGroup's
|
|
3
|
+
* high-water mark: once queueOps.cjs's autoArchiveCompleted starts moving
|
|
4
|
+
* old NN-*.md files out of prds/ into prds-archived/, a bare directory scan
|
|
5
|
+
* (maxParallelGroupInUse) can regress once the highest-NN files age out —
|
|
6
|
+
* letting a later allocation reissue an already-used NN and collide with an
|
|
7
|
+
* archived group. The high-water-mark sidecar (.max-allocated-group) must
|
|
8
|
+
* keep the allocator monotonic even after every file is archived away.
|
|
9
|
+
*
|
|
10
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/prdParserHighWater.test.cjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import { test, expect } from 'vitest';
|
|
16
|
+
const fsp = require('node:fs/promises');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const { allocateParallelGroup, maxParallelGroupInUse, _resetCache } = require('../scheduler/prdParser.cjs');
|
|
21
|
+
|
|
22
|
+
function mkTmpPrdsDir() {
|
|
23
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-highwater-'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('allocator stays monotonic after every NN-*.md file (and its reservation marker) is archived away', async () => {
|
|
27
|
+
const dir = await mkTmpPrdsDir();
|
|
28
|
+
_resetCache();
|
|
29
|
+
|
|
30
|
+
// Directly-authored legacy files (never allocated via allocateParallelGroup
|
|
31
|
+
// — e.g. /develop's deliberate-sibling path — so no .reserved-NN marker
|
|
32
|
+
// exists for them). This is the real gap: reservation markers alone would
|
|
33
|
+
// already protect any NN the allocator itself minted, so the regression
|
|
34
|
+
// this PRD guards against is specifically legacy/directly-authored NNs.
|
|
35
|
+
await fsp.writeFile(path.join(dir, '10-a.md'), '# a\n');
|
|
36
|
+
await fsp.writeFile(path.join(dir, '544-b.md'), '# b\n');
|
|
37
|
+
|
|
38
|
+
// A scan (via allocateParallelGroup) observes 544 and persists it as the
|
|
39
|
+
// high-water mark before returning the next free NN.
|
|
40
|
+
const first = await allocateParallelGroup(dir);
|
|
41
|
+
expect(first).toBe(545);
|
|
42
|
+
|
|
43
|
+
// Simulate autoArchiveCompleted moving every .md out of the live dir, and
|
|
44
|
+
// also strip the reservation marker the allocation above created — so
|
|
45
|
+
// nothing on disk still evidences 544/545 except the high-water sidecar.
|
|
46
|
+
await fsp.unlink(path.join(dir, '10-a.md'));
|
|
47
|
+
await fsp.unlink(path.join(dir, '544-b.md'));
|
|
48
|
+
await fsp.unlink(path.join(dir, '.reserved-545'));
|
|
49
|
+
|
|
50
|
+
const bareScanMax = await maxParallelGroupInUse(dir);
|
|
51
|
+
expect(bareScanMax).toBe(0);
|
|
52
|
+
|
|
53
|
+
_resetCache();
|
|
54
|
+
const second = await allocateParallelGroup(dir);
|
|
55
|
+
expect(second).toBeGreaterThan(545);
|
|
56
|
+
expect(second).toBe(546);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('high-water mark also protects against a fresh scan that is lower than a prior allocation, without any archiving involved', async () => {
|
|
60
|
+
const dir = await mkTmpPrdsDir();
|
|
61
|
+
_resetCache();
|
|
62
|
+
|
|
63
|
+
await fsp.writeFile(path.join(dir, '5-only.md'), '# x\n');
|
|
64
|
+
const nn = await allocateParallelGroup(dir);
|
|
65
|
+
expect(nn).toBe(6);
|
|
66
|
+
|
|
67
|
+
// A slow/partial directory listing (or a caller passing a differently
|
|
68
|
+
// scoped dir) must not be able to walk the allocator backwards.
|
|
69
|
+
const rescanMax = await maxParallelGroupInUse(dir);
|
|
70
|
+
expect(rescanMax).toBe(6); // the .reserved-6 marker from the allocation above still counts
|
|
71
|
+
|
|
72
|
+
const next = await allocateParallelGroup(dir);
|
|
73
|
+
expect(next).toBe(7);
|
|
74
|
+
});
|