claude-code-session-manager 0.36.0 → 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-D5b7nejd.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__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -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-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -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/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 +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-_iBXLuvt.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CTTjT08J.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -189,7 +189,11 @@ single definition of "tracked to done" for both entry paths.
|
|
|
189
189
|
- **Failed / errored / `needs_review` / timed out / killed by the watchdog or supervisor /
|
|
190
190
|
overran its estimate badly** — STOP waiting and surface it now: which PRD, the failure
|
|
191
191
|
signal, the relevant log tail, and the likely cause (a stuck poll-loop or post-AC overrun
|
|
192
|
-
per `PRD_AUTHORING.md`). Don't silently retry forever.
|
|
192
|
+
per `PRD_AUTHORING.md`). Don't silently retry forever. For `needs_review`, the scheduler
|
|
193
|
+
auto-files a Root Cause Analysis into the target project's feedback inbox
|
|
194
|
+
(`rcaFeedbackHook`, filename `<date>-rca-<slug>-<runId>.md`) — reference that file in
|
|
195
|
+
your report rather than re-deriving the analysis, and let `/process-feedback` fold its
|
|
196
|
+
prevention hint back into future PRD authoring. A `rateLimited` exit-1 is the
|
|
193
197
|
scheduler's benign auto-pause (auto-resumes next window) — keep waiting, don't escalate.
|
|
194
198
|
- **All PRDs completed successfully** — go to step 7.
|
|
195
199
|
|
|
@@ -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
|
+
});
|