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
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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docEdit.test.cjs — unit tests for docEdit.cjs's pure helpers (PRD 638).
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/docEdit.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect, describe } from 'vitest';
|
|
10
|
+
const { parseDocEdit } = require('../docEdit.cjs');
|
|
11
|
+
const { duplicateNameFor } = require('../files.cjs');
|
|
12
|
+
|
|
13
|
+
describe('parseDocEdit', () => {
|
|
14
|
+
test('valid JSON', () => {
|
|
15
|
+
const result = parseDocEdit('{"after":"rewritten text"}');
|
|
16
|
+
expect(result).toEqual({ ok: true, after: 'rewritten text' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('JSON embedded in prose', () => {
|
|
20
|
+
const result = parseDocEdit('Sure, here you go:\n{"after":"rewritten text"}\nHope that helps!');
|
|
21
|
+
expect(result).toEqual({ ok: true, after: 'rewritten text' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('missing after', () => {
|
|
25
|
+
const result = parseDocEdit('{"other":"value"}');
|
|
26
|
+
expect(result.ok).toBe(false);
|
|
27
|
+
expect(result.error).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('empty after', () => {
|
|
31
|
+
const result = parseDocEdit('{"after":""}');
|
|
32
|
+
expect(result.ok).toBe(false);
|
|
33
|
+
expect(result.error).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('non-string after', () => {
|
|
37
|
+
const result = parseDocEdit('{"after":42}');
|
|
38
|
+
expect(result.ok).toBe(false);
|
|
39
|
+
expect(result.error).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('over-length after', () => {
|
|
43
|
+
const result = parseDocEdit(JSON.stringify({ after: 'x'.repeat(16001) }));
|
|
44
|
+
expect(result.ok).toBe(false);
|
|
45
|
+
expect(result.error).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('no JSON object in output at all', () => {
|
|
49
|
+
const result = parseDocEdit('not json at all, just prose garbage');
|
|
50
|
+
expect(result.ok).toBe(false);
|
|
51
|
+
expect(result.error).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('empty/null input', () => {
|
|
55
|
+
expect(parseDocEdit('').ok).toBe(false);
|
|
56
|
+
expect(parseDocEdit(null).ok).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('duplicateNameFor', () => {
|
|
61
|
+
test('fresh name — no collision', () => {
|
|
62
|
+
const result = duplicateNameFor('/tmp/dir', 'notes.txt', () => false);
|
|
63
|
+
expect(result).toEqual({ ok: true, name: 'notes-copy.txt' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('collision on the first candidate falls through to -copy-2', () => {
|
|
67
|
+
const taken = new Set(['/tmp/dir/notes-copy.txt']);
|
|
68
|
+
const result = duplicateNameFor('/tmp/dir', 'notes.txt', (full) => taken.has(full));
|
|
69
|
+
expect(result).toEqual({ ok: true, name: 'notes-copy-2.txt' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('preserves extension-less basenames', () => {
|
|
73
|
+
const result = duplicateNameFor('/tmp/dir', 'README', () => false);
|
|
74
|
+
expect(result).toEqual({ ok: true, name: 'README-copy' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('cap exhausted after 20 attempts returns an error', () => {
|
|
78
|
+
const result = duplicateNameFor('/tmp/dir', 'notes.txt', () => true);
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.error).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -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
|
+
});
|