claude-code-session-manager 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-DXQDiOUx.js} +58 -58
  2. package/dist/assets/index--a8m5_ET.js +3496 -0
  3. package/dist/assets/index-CTTjT08J.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +2 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/scripts/lib/activeSessions.cjs +117 -0
  8. package/scripts/lib/watchdogHelpers.cjs +699 -0
  9. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  10. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  11. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  12. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  14. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  15. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  16. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  17. package/src/main/chatRunner.cjs +46 -12
  18. package/src/main/config.cjs +8 -0
  19. package/src/main/historyAggregator.cjs +391 -50
  20. package/src/main/historyDashboard.cjs +226 -0
  21. package/src/main/index.cjs +35 -1
  22. package/src/main/ipcSchemas.cjs +5 -0
  23. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  24. package/src/main/lib/historyRollup.cjs +148 -0
  25. package/src/main/lib/queueHistory.cjs +216 -0
  26. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  27. package/src/main/lib/schedulerConfig.cjs +16 -0
  28. package/src/main/queueOps.cjs +92 -0
  29. package/src/main/scheduler/prdParser.cjs +52 -1
  30. package/src/main/scheduler.cjs +237 -29
  31. package/src/preload/api.d.ts +69 -1
  32. package/src/preload/index.cjs +1 -0
  33. package/dist/assets/index-CkiGRskz.css +0 -32
  34. package/dist/assets/index-DrzirIUy.js +0 -3562
@@ -0,0 +1,104 @@
1
+ /**
2
+ * broadcastCoalescer.test.cjs — unit tests for src/main/lib/broadcastCoalescer.cjs,
3
+ * the trailing-edge debounce used by scheduler.cjs's broadcast().
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/__tests__/broadcastCoalescer.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, vi, beforeEach, afterEach } from 'vitest';
11
+
12
+ const { createBroadcastCoalescer } = require('../lib/broadcastCoalescer.cjs');
13
+
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ function makeCoalescer(delayMs = 200) {
23
+ const sent = [];
24
+ let counter = 0;
25
+ const getPayload = vi.fn(() => `state-${counter}`);
26
+ const send = vi.fn((payload) => sent.push(payload));
27
+ const coalescer = createBroadcastCoalescer({ delayMs, send, getPayload });
28
+ return {
29
+ coalescer,
30
+ sent,
31
+ send,
32
+ getPayload,
33
+ bump: () => { counter += 1; },
34
+ };
35
+ }
36
+
37
+ test('5 rapid schedule() calls within the window produce exactly 1 send', async () => {
38
+ const { coalescer, sent, bump } = makeCoalescer(200);
39
+
40
+ bump(); coalescer.schedule();
41
+ bump(); coalescer.schedule();
42
+ bump(); coalescer.schedule();
43
+ bump(); coalescer.schedule();
44
+ bump(); coalescer.schedule();
45
+
46
+ await vi.advanceTimersByTimeAsync(200);
47
+
48
+ expect(sent.length).toBe(1);
49
+ });
50
+
51
+ test('the trailing send reflects the LATEST state, not the state at the first schedule() call', async () => {
52
+ const { coalescer, sent, bump } = makeCoalescer(200);
53
+
54
+ bump(); // state-1
55
+ coalescer.schedule();
56
+ bump(); // state-2
57
+ coalescer.schedule(); // no-op — timer already armed
58
+ bump(); // state-3
59
+ coalescer.schedule(); // no-op
60
+
61
+ await vi.advanceTimersByTimeAsync(200);
62
+
63
+ expect(sent).toEqual(['state-3']);
64
+ });
65
+
66
+ test('flush() cancels the pending timer and sends immediately with the current payload', async () => {
67
+ const { coalescer, sent, bump } = makeCoalescer(200);
68
+
69
+ bump();
70
+ coalescer.schedule();
71
+
72
+ // Flush before the 200ms window elapses.
73
+ await vi.advanceTimersByTimeAsync(50);
74
+ await coalescer.flush();
75
+
76
+ expect(sent).toEqual(['state-1']);
77
+
78
+ // The window's original timer must not fire a second, stale send.
79
+ await vi.advanceTimersByTimeAsync(200);
80
+ expect(sent).toEqual(['state-1']);
81
+ });
82
+
83
+ test('a schedule() call after a quiet period still arrives (not dropped)', async () => {
84
+ const { coalescer, sent, bump } = makeCoalescer(200);
85
+
86
+ bump();
87
+ coalescer.schedule();
88
+ await vi.advanceTimersByTimeAsync(200);
89
+ expect(sent).toEqual(['state-1']);
90
+
91
+ bump();
92
+ coalescer.schedule();
93
+ await vi.advanceTimersByTimeAsync(200);
94
+ expect(sent).toEqual(['state-1', 'state-2']);
95
+ });
96
+
97
+ test('flush() with no pending timer still sends (no-arm case)', async () => {
98
+ const { coalescer, sent, bump } = makeCoalescer(200);
99
+ bump();
100
+ await coalescer.flush();
101
+ expect(sent).toEqual(['state-1']);
102
+ });
103
+
104
+ console.log('broadcastCoalescer tests: PASS');
@@ -0,0 +1,163 @@
1
+ /**
2
+ * historyDashboard.test.cjs — unit tests for src/main/historyDashboard.cjs
3
+ * (the `history:dashboard` IPC's pure rollup-only computation).
4
+ *
5
+ * Run: timeout 300 npx vitest run src/main/__tests__/historyDashboard.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ let tmpHome;
16
+ let realHome;
17
+ let historyRollup;
18
+ let historyDashboard;
19
+
20
+ const MODULES_TO_RELOAD = [
21
+ '../lib/historyRollup.cjs',
22
+ '../historyAggregator.cjs',
23
+ '../historyDashboard.cjs',
24
+ '../config.cjs',
25
+ ];
26
+
27
+ function purgeRequireCache() {
28
+ for (const m of MODULES_TO_RELOAD) {
29
+ try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
30
+ }
31
+ }
32
+
33
+ beforeEach(() => {
34
+ realHome = process.env.HOME;
35
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'history-dashboard-test-'));
36
+ process.env.HOME = tmpHome;
37
+ purgeRequireCache();
38
+ historyRollup = require('../lib/historyRollup.cjs');
39
+ historyDashboard = require('../historyDashboard.cjs');
40
+ });
41
+
42
+ afterEach(() => {
43
+ process.env.HOME = realHome;
44
+ purgeRequireCache();
45
+ fs.rmSync(tmpHome, { recursive: true, force: true });
46
+ });
47
+
48
+ function today() {
49
+ return new Date().toLocaleDateString('en-CA');
50
+ }
51
+
52
+ function daysAgo(n) {
53
+ const d = new Date();
54
+ d.setDate(d.getDate() - n);
55
+ return d.toLocaleDateString('en-CA');
56
+ }
57
+
58
+ async function seedThreeDayFixture() {
59
+ const d2 = daysAgo(2);
60
+ const d1 = daysAgo(1);
61
+ const d0 = today();
62
+
63
+ await historyRollup.appendRollupDays([
64
+ // day -2: project A, finalized, sonnet
65
+ { date: d2, projectDir: 'proj-a', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 4, toolCallCount: 2, toolBreakdown: { Read: 2 }, errorCount: 0, sessionCount: 1, activeMinutes: 12, v: 2 },
66
+ { date: d2, projectDir: 'proj-a', modelId: 'claude-sonnet-5', promptCount: 0, inputTokens: 1000, outputTokens: 200, cacheReadTokens: 0, cacheCreationTokens: 0, v: 2 },
67
+ { date: d2, projectDir: historyRollup.FINALIZED_PROJECT_ID, modelId: historyRollup.FINALIZED_MODEL_ID, finalizedAt: 111, v: 2 },
68
+
69
+ // day -1: project A and B, finalized
70
+ { date: d1, projectDir: 'proj-a', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 3, toolCallCount: 1, toolBreakdown: { Edit: 1 }, errorCount: 1, sessionCount: 1, activeMinutes: 8, v: 2 },
71
+ { date: d1, projectDir: 'proj-a', modelId: 'claude-sonnet-5', promptCount: 0, inputTokens: 500, outputTokens: 100, cacheReadTokens: 0, cacheCreationTokens: 0, v: 2 },
72
+ { date: d1, projectDir: 'proj-b', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 2, toolCallCount: 0, toolBreakdown: {}, errorCount: 0, sessionCount: 1, activeMinutes: 5, v: 2 },
73
+ { date: d1, projectDir: 'proj-b', modelId: 'claude-opus-5', promptCount: 0, inputTokens: 300, outputTokens: 50, cacheReadTokens: 0, cacheCreationTokens: 0, v: 2 },
74
+ { date: d1, projectDir: historyRollup.FINALIZED_PROJECT_ID, modelId: historyRollup.FINALIZED_MODEL_ID, finalizedAt: 222, v: 2 },
75
+
76
+ // today: provisional, project A only, no finalized marker
77
+ { date: d0, projectDir: 'proj-a', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 1, toolCallCount: 1, toolBreakdown: { Bash: 1 }, errorCount: 0, sessionCount: 1, activeMinutes: 3, v: 2 },
78
+ { date: d0, projectDir: 'proj-a', modelId: 'claude-sonnet-5', promptCount: 0, inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheCreationTokens: 0, v: 2 },
79
+ ]);
80
+
81
+ return { d2, d1, d0 };
82
+ }
83
+
84
+ test('dashboard response shape: 3-day fixture with rangeDays=30 includes all seeded days, correct totals, provisional today', async () => {
85
+ const { d2, d1, d0 } = await seedThreeDayFixture();
86
+
87
+ const result = await historyDashboard.computeDashboard({ rangeDays: 30 });
88
+
89
+ expect(result.from).toBeTruthy();
90
+ expect(result.to).toBe(d0);
91
+ expect(Array.isArray(result.days)).toBe(true);
92
+
93
+ const dates = result.days.map((d) => d.date);
94
+ expect(dates).toContain(d2);
95
+ expect(dates).toContain(d1);
96
+ expect(dates).toContain(d0);
97
+
98
+ // provisionalDates: only today (no finalized marker was written for it).
99
+ expect(result.provisionalDates).toEqual([d0]);
100
+
101
+ // Totals across all 3 days: promptCount 4+3+2+1 = 10
102
+ expect(result.totals.promptCount).toBe(10);
103
+ expect(result.totals.activeMinutes).toBe(12 + 8 + 5 + 3);
104
+
105
+ // byProjectTotals: proj-a spans all 3 days, proj-b only day -1
106
+ expect(result.byProjectTotals['proj-a'].promptCount).toBe(4 + 3 + 1);
107
+ expect(result.byProjectTotals['proj-b'].promptCount).toBe(2);
108
+
109
+ // byModelTotals: sonnet appears on all 3 days for proj-a + day -1 for... only proj-a uses sonnet
110
+ expect(result.byModelTotals['claude-sonnet-5'].inputTokens).toBe(1000 + 500 + 100);
111
+ expect(result.byModelTotals['claude-sonnet-5'].costUsd).toBeGreaterThan(0);
112
+ expect(result.byModelTotals['claude-opus-5'].inputTokens).toBe(300);
113
+
114
+ // toolsByProject
115
+ expect(result.toolsByProject['proj-a']).toEqual({ Read: 2, Edit: 1, Bash: 1 });
116
+ expect(result.toolsByProject['proj-b']).toEqual({});
117
+
118
+ // Per-day, per-project bucket shape.
119
+ const dayMinus2 = result.days.find((d) => d.date === d2);
120
+ expect(dayMinus2.byProject['proj-a'].promptCount).toBe(4);
121
+ expect(dayMinus2.byProject['proj-a'].byModel['claude-sonnet-5'].costUsd).toBeGreaterThan(0);
122
+
123
+ expect(typeof result.generatedAt).toBe('number');
124
+ });
125
+
126
+ test('dashboard: rangeDays=0 (all time) includes every day with no prior-window bound, prevTotals stays zero', async () => {
127
+ await seedThreeDayFixture();
128
+
129
+ const result = await historyDashboard.computeDashboard({ rangeDays: 0 });
130
+ expect(result.days.length).toBe(3);
131
+ expect(result.prevTotals.promptCount).toBe(0);
132
+ expect(result.prevTotals.estimatedCostUsd).toBe(0);
133
+ });
134
+
135
+ test('dashboard: prevTotals aggregates the prior window distinct from the current window', async () => {
136
+ const d0 = today();
137
+ // Two 1-day windows: rangeDays=1 → current = [today, today], prior = [yesterday, yesterday].
138
+ const yesterday = daysAgo(1);
139
+ await historyRollup.appendRollupDays([
140
+ { date: yesterday, projectDir: 'proj-a', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 9, sessionCount: 1, activeMinutes: 20, v: 2 },
141
+ { date: yesterday, projectDir: historyRollup.FINALIZED_PROJECT_ID, modelId: historyRollup.FINALIZED_MODEL_ID, finalizedAt: 1, v: 2 },
142
+ { date: d0, projectDir: 'proj-a', modelId: historyRollup.TOTALS_MODEL_ID, promptCount: 2, sessionCount: 1, activeMinutes: 4, v: 2 },
143
+ ]);
144
+
145
+ const result = await historyDashboard.computeDashboard({ rangeDays: 1 });
146
+ expect(result.totals.promptCount).toBe(2);
147
+ expect(result.prevTotals.promptCount).toBe(9);
148
+ });
149
+
150
+ test('zero-scan guarantee: computeDashboard never reads PROJECTS_DIR / transcript files', async () => {
151
+ await seedThreeDayFixture();
152
+
153
+ const fsp = require('node:fs/promises');
154
+ const PROJECTS_DIR = path.join(tmpHome, '.claude', 'projects');
155
+ const readdirSpy = vi.spyOn(fsp, 'readdir');
156
+
157
+ await historyDashboard.computeDashboard({ rangeDays: 30 });
158
+
159
+ const scannedProjectsDir = readdirSpy.mock.calls.some((call) => String(call[0]).startsWith(PROJECTS_DIR));
160
+ expect(scannedProjectsDir).toBe(false);
161
+
162
+ readdirSpy.mockRestore();
163
+ });
@@ -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,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
+ });