claude-code-session-manager 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +32 -29
- package/src/main/files.cjs +20 -0
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +10 -63
- package/src/main/ipcSchemas.cjs +20 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +18 -199
- package/src/preload/index.cjs +12 -102
- package/dist/assets/index-BfiMRkln.js +0 -3225
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-readText-bounded.test.cjs — readText's optional { maxBytes } bounded
|
|
3
|
+
* prefix read. Regression coverage for perf-known-projects-bounded-cwd-read:
|
|
4
|
+
* resolveProjectCwd used to read whole (sometimes multi-MB) transcript files
|
|
5
|
+
* just to find the first line containing "cwd".
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/config-readText-bounded.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, afterEach } from 'vitest';
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const config = require('../config.cjs');
|
|
17
|
+
|
|
18
|
+
const tmpDirs = [];
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
while (tmpDirs.length) {
|
|
21
|
+
const d = tmpDirs.pop();
|
|
22
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function mkTmpFile(name, content) {
|
|
27
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-readtext-'));
|
|
28
|
+
config.addAllowedRoot(dir);
|
|
29
|
+
tmpDirs.push(dir);
|
|
30
|
+
const abs = path.join(dir, name);
|
|
31
|
+
await fsp.writeFile(abs, content, 'utf8');
|
|
32
|
+
return abs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('bounded read returns only the requested prefix and sets truncated', async () => {
|
|
36
|
+
const lines = Array.from({ length: 2000 }, (_, i) => `{"line":${i},"pad":"${'x'.repeat(50)}"}`).join('\n') + '\n';
|
|
37
|
+
const abs = await mkTmpFile('big.jsonl', lines);
|
|
38
|
+
const full = await config.readText(abs);
|
|
39
|
+
expect(full.truncated).toBe(false);
|
|
40
|
+
|
|
41
|
+
const bounded = await config.readText(abs, { maxBytes: 1024 });
|
|
42
|
+
expect(bounded.exists).toBe(true);
|
|
43
|
+
expect(bounded.truncated).toBe(true);
|
|
44
|
+
expect(bounded.text.length).toBeLessThan(full.text.length);
|
|
45
|
+
expect(Buffer.byteLength(bounded.text, 'utf8')).toBeLessThanOrEqual(1024);
|
|
46
|
+
// every line handed back must be a complete, parseable JSON line — no
|
|
47
|
+
// partial trailing fragment from the byte cut.
|
|
48
|
+
const returnedLines = bounded.text.split('\n').filter(Boolean);
|
|
49
|
+
for (const line of returnedLines) {
|
|
50
|
+
expect(() => JSON.parse(line)).not.toThrow();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('unbounded read (no opts) is byte-identical to before', async () => {
|
|
55
|
+
const content = 'line one\nline two\nline three\n';
|
|
56
|
+
const abs = await mkTmpFile('plain.txt', content);
|
|
57
|
+
const r = await config.readText(abs);
|
|
58
|
+
expect(r.exists).toBe(true);
|
|
59
|
+
expect(r.text).toBe(content);
|
|
60
|
+
expect(r.truncated).toBe(false);
|
|
61
|
+
expect(typeof r.mtimeMs).toBe('number');
|
|
62
|
+
expect(r.error).toBe(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('maxBytes larger than the file returns the whole file with truncated=false', async () => {
|
|
66
|
+
const content = 'short file\ncontent here\n';
|
|
67
|
+
const abs = await mkTmpFile('small.txt', content);
|
|
68
|
+
const r = await config.readText(abs, { maxBytes: 1024 * 1024 });
|
|
69
|
+
expect(r.exists).toBe(true);
|
|
70
|
+
expect(r.text).toBe(content);
|
|
71
|
+
expect(r.truncated).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('bounded read on a missing file behaves like the unbounded case', async () => {
|
|
75
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-readtext-missing-'));
|
|
76
|
+
config.addAllowedRoot(dir);
|
|
77
|
+
tmpDirs.push(dir);
|
|
78
|
+
const abs = path.join(dir, 'nope.txt');
|
|
79
|
+
const r = await config.readText(abs, { maxBytes: 1024 });
|
|
80
|
+
expect(r.exists).toBe(false);
|
|
81
|
+
expect(r.text).toBe('');
|
|
82
|
+
expect(r.truncated).toBe(false);
|
|
83
|
+
expect(r.error).toBe(null);
|
|
84
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heapSnapshot.test.cjs — unit tests for heapSnapshot.cjs's default-off
|
|
3
|
+
* gating, IPC/menu wiring, and the capture timeout bound.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/heapSnapshot.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const heapSnapshot = require('../heapSnapshot.cjs');
|
|
15
|
+
|
|
16
|
+
const ORIGINAL_FLAG = process.env.SM_HEAP_SNAPSHOT;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
delete process.env.SM_HEAP_SNAPSHOT;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
if (ORIGINAL_FLAG === undefined) delete process.env.SM_HEAP_SNAPSHOT;
|
|
24
|
+
else process.env.SM_HEAP_SNAPSHOT = ORIGINAL_FLAG;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('isEnabled is false by default (no env flag)', () => {
|
|
28
|
+
expect(heapSnapshot.isEnabled()).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('isEnabled is true only when SM_HEAP_SNAPSHOT=1', () => {
|
|
32
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
33
|
+
expect(heapSnapshot.isEnabled()).toBe(true);
|
|
34
|
+
process.env.SM_HEAP_SNAPSHOT = 'true';
|
|
35
|
+
expect(heapSnapshot.isEnabled()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('registerIpc registers nothing when disabled — default-off IPC surface', () => {
|
|
39
|
+
const calls = [];
|
|
40
|
+
const fakeIpcMain = { handle: (channel, listener) => calls.push({ channel, listener }) };
|
|
41
|
+
const registered = heapSnapshot.registerIpc({ ipcMain: fakeIpcMain, getWindow: () => null });
|
|
42
|
+
expect(registered).toBe(false);
|
|
43
|
+
expect(calls).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('registerIpc registers the channel when enabled', () => {
|
|
47
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
48
|
+
const calls = [];
|
|
49
|
+
const fakeIpcMain = { handle: (channel, listener) => calls.push({ channel, listener }) };
|
|
50
|
+
const registered = heapSnapshot.registerIpc({ ipcMain: fakeIpcMain, getWindow: () => null });
|
|
51
|
+
expect(registered).toBe(true);
|
|
52
|
+
expect(calls).toHaveLength(1);
|
|
53
|
+
expect(calls[0].channel).toBe(heapSnapshot.CHANNEL);
|
|
54
|
+
expect(calls[0].channel).toBe('diagnostics:heap-snapshot');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('buildMenuItem returns null when disabled — no menu entry added', () => {
|
|
58
|
+
expect(heapSnapshot.buildMenuItem(() => null)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('buildMenuItem returns a clickable item when enabled', () => {
|
|
62
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
63
|
+
const item = heapSnapshot.buildMenuItem(() => null);
|
|
64
|
+
expect(item).not.toBeNull();
|
|
65
|
+
expect(typeof item.click).toBe('function');
|
|
66
|
+
expect(item.label).toMatch(/heap snapshot/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('captureSnapshot rejects when disabled, without touching the window', async () => {
|
|
70
|
+
const win = { webContents: { takeHeapSnapshot: () => { throw new Error('should not be called'); } } };
|
|
71
|
+
await expect(heapSnapshot.captureSnapshot(win)).rejects.toThrow(/disabled/i);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('captureSnapshot rejects when no window is available', async () => {
|
|
75
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
76
|
+
await expect(heapSnapshot.captureSnapshot(null)).rejects.toThrow(/no renderer window/i);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('captureSnapshot writes the file and reports size/duration on success', async () => {
|
|
80
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
81
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-heap-test-'));
|
|
82
|
+
try {
|
|
83
|
+
const win = {
|
|
84
|
+
webContents: {
|
|
85
|
+
takeHeapSnapshot: async (filePath) => {
|
|
86
|
+
await fsp.writeFile(filePath, Buffer.alloc(1024, 1));
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const result = await heapSnapshot.captureSnapshot(win, { dir, now: new Date('2026-08-07T12:00:00.000Z') });
|
|
91
|
+
expect(result.filePath).toBe(path.join(dir, 'heap-2026-08-07T12-00-00-000Z.heapsnapshot'));
|
|
92
|
+
expect(result.bytes).toBe(1024);
|
|
93
|
+
expect(typeof result.ms).toBe('number');
|
|
94
|
+
const stat = await fsp.stat(result.filePath);
|
|
95
|
+
expect(stat.size).toBe(1024);
|
|
96
|
+
} finally {
|
|
97
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('captureSnapshot times out instead of hanging forever on a stuck write', async () => {
|
|
102
|
+
process.env.SM_HEAP_SNAPSHOT = '1';
|
|
103
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-heap-test-'));
|
|
104
|
+
try {
|
|
105
|
+
const win = {
|
|
106
|
+
webContents: {
|
|
107
|
+
// Never resolves — simulates a stuck/blocked renderer.
|
|
108
|
+
takeHeapSnapshot: () => new Promise(() => {}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
await expect(
|
|
112
|
+
heapSnapshot.captureSnapshot(win, { dir, timeoutMs: 20 })
|
|
113
|
+
).rejects.toThrow(/timed out/i);
|
|
114
|
+
} finally {
|
|
115
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('snapshotDir points under ~/.claude/session-manager', () => {
|
|
120
|
+
expect(heapSnapshot.snapshotDir()).toBe(path.join(os.homedir(), '.claude', 'session-manager'));
|
|
121
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* historyAggregatorIntraday.test.cjs — unit tests for
|
|
3
|
+
* historyAggregator.cjs's refreshIntradayToday() fast walk (PRD: make the
|
|
4
|
+
* periodic intraday refresh proportional to files actually modified today
|
|
5
|
+
* instead of stat-ing every jsonl file on every tick).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/historyAggregatorIntraday.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const fsp = require('node:fs/promises');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
let tmpHome;
|
|
19
|
+
let realHome;
|
|
20
|
+
let historyRollup;
|
|
21
|
+
let historyAggregator;
|
|
22
|
+
|
|
23
|
+
function encodeCwd(cwd) {
|
|
24
|
+
return cwd.replace(/\//g, '-');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function projectsDirFor(tmp) {
|
|
28
|
+
return path.join(tmp, '.claude', 'projects');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeTranscript(projectCwd, sessionId, lines, mtime) {
|
|
32
|
+
const encoded = encodeCwd(projectCwd);
|
|
33
|
+
const dir = path.join(projectsDirFor(tmpHome), encoded);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
const file = path.join(dir, `${sessionId}.jsonl`);
|
|
36
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8');
|
|
37
|
+
if (mtime) fs.utimesSync(file, mtime, mtime);
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function userLine(ts) {
|
|
42
|
+
return { role: 'user', ts };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assistantLine(ts, model, tokens = {}) {
|
|
46
|
+
return {
|
|
47
|
+
ts,
|
|
48
|
+
message: {
|
|
49
|
+
role: 'assistant',
|
|
50
|
+
model,
|
|
51
|
+
usage: {
|
|
52
|
+
input_tokens: tokens.in ?? 10,
|
|
53
|
+
output_tokens: tokens.out ?? 5,
|
|
54
|
+
cache_read_input_tokens: tokens.cacheR ?? 0,
|
|
55
|
+
cache_creation_input_tokens: tokens.cacheC ?? 0,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Same top-level-const-baking concern as historyRollup.test.cjs — see its
|
|
62
|
+
// comment. PROJECTS_DIR, ROLLUP_PATH, and config.cjs's allowedRoots are all
|
|
63
|
+
// computed from os.homedir() at first require.
|
|
64
|
+
const MODULES_TO_RELOAD = [
|
|
65
|
+
'../lib/historyRollup.cjs',
|
|
66
|
+
'../historyAggregator.cjs',
|
|
67
|
+
'../config.cjs',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function purgeRequireCache() {
|
|
71
|
+
for (const m of MODULES_TO_RELOAD) {
|
|
72
|
+
try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
realHome = process.env.HOME;
|
|
78
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'history-intraday-test-'));
|
|
79
|
+
process.env.HOME = tmpHome;
|
|
80
|
+
purgeRequireCache();
|
|
81
|
+
historyRollup = require('../lib/historyRollup.cjs');
|
|
82
|
+
historyAggregator = require('../historyAggregator.cjs');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
process.env.HOME = realHome;
|
|
87
|
+
purgeRequireCache();
|
|
88
|
+
// tmpHome is a dedicated tmpdir (never the real ~/.claude/projects — HOME
|
|
89
|
+
// is overridden above before any fixture file is written), but clean it up
|
|
90
|
+
// regardless: leaked fixture dirs under a real ~/.claude/projects have
|
|
91
|
+
// caused phantom-project bugs before (see CLAUDE.md's
|
|
92
|
+
// knownProjectAggregate.ts section) — always delete what a test created.
|
|
93
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function today() {
|
|
97
|
+
return new Date().toLocaleDateString('en-CA');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function todayAt(hour) {
|
|
101
|
+
const d = new Date();
|
|
102
|
+
d.setHours(hour, 0, 0, 0);
|
|
103
|
+
return d;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function yesterdayAt(hour) {
|
|
107
|
+
const d = new Date();
|
|
108
|
+
d.setDate(d.getDate() - 1);
|
|
109
|
+
d.setHours(hour, 0, 0, 0);
|
|
110
|
+
return d;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ground-truth reimplementation of the ORIGINAL (pre-fix) refreshIntradayToday:
|
|
115
|
+
* unconditional readdir-per-directory + sequential stat-per-file, no registry,
|
|
116
|
+
* no concurrency. Kept here (not in production code) purely so tests can
|
|
117
|
+
* assert the new fast-walk's output is byte-for-byte identical to what the
|
|
118
|
+
* old algorithm produced for the same fixture.
|
|
119
|
+
*/
|
|
120
|
+
async function legacyRefreshIntradayToday() {
|
|
121
|
+
const PROJECTS_DIR = projectsDirFor(tmpHome);
|
|
122
|
+
const t = today();
|
|
123
|
+
|
|
124
|
+
let projectDirs;
|
|
125
|
+
try {
|
|
126
|
+
projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const buckets = new Map();
|
|
132
|
+
for (const projEntry of projectDirs) {
|
|
133
|
+
if (!projEntry.isDirectory()) continue;
|
|
134
|
+
const encodedCwd = projEntry.name;
|
|
135
|
+
const projectDir = path.join(PROJECTS_DIR, encodedCwd);
|
|
136
|
+
|
|
137
|
+
let files;
|
|
138
|
+
try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
|
|
139
|
+
|
|
140
|
+
for (const fileEntry of files) {
|
|
141
|
+
if (!fileEntry.name.endsWith('.jsonl')) continue;
|
|
142
|
+
const filePath = path.join(projectDir, fileEntry.name);
|
|
143
|
+
|
|
144
|
+
let stat;
|
|
145
|
+
try { stat = await fsp.stat(filePath); } catch { continue; }
|
|
146
|
+
|
|
147
|
+
const mtimeDate = new Date(stat.mtimeMs).toLocaleDateString('en-CA');
|
|
148
|
+
if (mtimeDate < t) continue;
|
|
149
|
+
|
|
150
|
+
const { result: parsed } = await historyAggregator.parseJSONL(filePath, stat);
|
|
151
|
+
if (parsed.skipped) continue;
|
|
152
|
+
if (parsed.sessionDate !== t) continue;
|
|
153
|
+
|
|
154
|
+
const key = `${t}|${encodedCwd}`;
|
|
155
|
+
if (!buckets.has(key)) {
|
|
156
|
+
buckets.set(key, {
|
|
157
|
+
date: t,
|
|
158
|
+
encodedCwd,
|
|
159
|
+
promptCount: 0,
|
|
160
|
+
inputTokens: 0,
|
|
161
|
+
outputTokens: 0,
|
|
162
|
+
toolCallCount: 0,
|
|
163
|
+
sessionCount: 0,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const b = buckets.get(key);
|
|
167
|
+
b.promptCount += parsed.promptCount;
|
|
168
|
+
b.inputTokens += parsed.inputTokens;
|
|
169
|
+
b.outputTokens += parsed.outputTokens;
|
|
170
|
+
b.toolCallCount += parsed.toolCallCount;
|
|
171
|
+
b.sessionCount++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return Array.from(buckets.values()).sort((a, b) => a.encodedCwd.localeCompare(b.encodedCwd));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Read back today's rollup lines as a simplified, order-stable shape for comparison. */
|
|
178
|
+
async function readTodayRollupSimplified() {
|
|
179
|
+
const t = today();
|
|
180
|
+
const map = await historyRollup.readRollup(t, t);
|
|
181
|
+
const byProject = new Map();
|
|
182
|
+
for (const bucket of map.values()) {
|
|
183
|
+
if (bucket.projectDir === historyRollup.FINALIZED_PROJECT_ID) continue;
|
|
184
|
+
const key = bucket.projectDir;
|
|
185
|
+
let row = byProject.get(key);
|
|
186
|
+
if (!row) {
|
|
187
|
+
row = { date: t, encodedCwd: key, promptCount: 0, inputTokens: 0, outputTokens: 0, toolCallCount: 0, sessionCount: 0 };
|
|
188
|
+
byProject.set(key, row);
|
|
189
|
+
}
|
|
190
|
+
if (bucket.modelId === historyRollup.TOTALS_MODEL_ID) {
|
|
191
|
+
row.promptCount += bucket.promptCount || 0;
|
|
192
|
+
row.toolCallCount += bucket.toolCallCount || 0;
|
|
193
|
+
row.sessionCount += bucket.sessionCount || 0;
|
|
194
|
+
} else {
|
|
195
|
+
row.inputTokens += bucket.inputTokens || 0;
|
|
196
|
+
row.outputTokens += bucket.outputTokens || 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return Array.from(byProject.values()).sort((a, b) => a.encodedCwd.localeCompare(b.encodedCwd));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('refreshIntradayToday output matches the old unconditional-walk algorithm for the same fixture', async () => {
|
|
203
|
+
writeTranscript('/repo/alpha', 'sess-1', [
|
|
204
|
+
userLine(todayAt(9).toISOString()),
|
|
205
|
+
assistantLine(todayAt(9).toISOString(), 'claude-sonnet-5', { in: 100, out: 20 }),
|
|
206
|
+
], todayAt(9));
|
|
207
|
+
writeTranscript('/repo/beta', 'sess-2', [
|
|
208
|
+
userLine(todayAt(10).toISOString()),
|
|
209
|
+
assistantLine(todayAt(10).toISOString(), 'claude-opus-5', { in: 50, out: 10 }),
|
|
210
|
+
], todayAt(10));
|
|
211
|
+
// An old, untouched-today file mixed in — must be excluded from both.
|
|
212
|
+
writeTranscript('/repo/gamma', 'sess-3', [
|
|
213
|
+
userLine(yesterdayAt(9).toISOString()),
|
|
214
|
+
], yesterdayAt(9));
|
|
215
|
+
|
|
216
|
+
const expected = await legacyRefreshIntradayToday();
|
|
217
|
+
|
|
218
|
+
const result = await historyAggregator.refreshIntradayToday();
|
|
219
|
+
expect(result.projectsUpdated).toBe(2);
|
|
220
|
+
|
|
221
|
+
const actual = await readTodayRollupSimplified();
|
|
222
|
+
expect(actual).toEqual(expected.map((b) => ({
|
|
223
|
+
date: b.date,
|
|
224
|
+
encodedCwd: b.encodedCwd,
|
|
225
|
+
promptCount: b.promptCount,
|
|
226
|
+
inputTokens: b.inputTokens,
|
|
227
|
+
outputTokens: b.outputTokens,
|
|
228
|
+
toolCallCount: b.toolCallCount,
|
|
229
|
+
sessionCount: b.sessionCount,
|
|
230
|
+
})));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a file modified today, in a directory the fast path would skip re-enumerating, is still picked up', async () => {
|
|
234
|
+
// Session file whose FIRST event is today (this is what makes its
|
|
235
|
+
// sessionDate bucket "today" at all — sessionDate is derived from the
|
|
236
|
+
// file's first line, not its mtime). The first tick discovers it via a
|
|
237
|
+
// real readdir (new directory) and caches the directory's mtime + file
|
|
238
|
+
// list alongside it.
|
|
239
|
+
const file = writeTranscript('/repo/resumed', 'sess-old', [
|
|
240
|
+
userLine(todayAt(8).toISOString()),
|
|
241
|
+
assistantLine(todayAt(8).toISOString(), 'claude-sonnet-5', { in: 10, out: 2 }),
|
|
242
|
+
], todayAt(8));
|
|
243
|
+
|
|
244
|
+
const first = await historyAggregator.refreshIntradayToday();
|
|
245
|
+
expect(first.projectsUpdated).toBe(1);
|
|
246
|
+
|
|
247
|
+
// Now append to that SAME file later today, without creating or removing
|
|
248
|
+
// any directory entry — appending to an existing file does NOT change the
|
|
249
|
+
// parent directory's mtime (verified empirically; see the strategy comment
|
|
250
|
+
// above refreshIntradayToday). A naive "skip the whole directory when its
|
|
251
|
+
// mtime is unchanged" fast path would miss this update entirely; the real
|
|
252
|
+
// implementation must keep re-stat-ing every previously-seen file in the
|
|
253
|
+
// directory regardless.
|
|
254
|
+
const dirPath = path.dirname(file);
|
|
255
|
+
const dirStatBefore = fs.statSync(dirPath);
|
|
256
|
+
|
|
257
|
+
fs.appendFileSync(file, JSON.stringify(assistantLine(todayAt(11).toISOString(), 'claude-sonnet-5', { in: 30, out: 6 })) + '\n');
|
|
258
|
+
const todayStamp = todayAt(11);
|
|
259
|
+
fs.utimesSync(file, todayStamp, todayStamp);
|
|
260
|
+
|
|
261
|
+
const dirStatAfter = fs.statSync(dirPath);
|
|
262
|
+
// Sanity check on the very premise this test is exercising: the directory
|
|
263
|
+
// mtime genuinely did not move, so the second tick's fast path is forced to
|
|
264
|
+
// reuse (not re-readdir) this directory's cached file list.
|
|
265
|
+
expect(dirStatAfter.mtimeMs).toBe(dirStatBefore.mtimeMs);
|
|
266
|
+
|
|
267
|
+
const second = await historyAggregator.refreshIntradayToday();
|
|
268
|
+
expect(second.projectsUpdated).toBe(1);
|
|
269
|
+
|
|
270
|
+
const rows = await readTodayRollupSimplified();
|
|
271
|
+
expect(rows).toEqual([
|
|
272
|
+
{ date: today(), encodedCwd: encodeCwd('/repo/resumed'), promptCount: 1, inputTokens: 40, outputTokens: 8, toolCallCount: 0, sessionCount: 1 },
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a brand-new session file created after the first tick is discovered on the next tick', async () => {
|
|
277
|
+
writeTranscript('/repo/steady', 'sess-a', [
|
|
278
|
+
userLine(todayAt(8).toISOString()),
|
|
279
|
+
], todayAt(8));
|
|
280
|
+
|
|
281
|
+
const first = await historyAggregator.refreshIntradayToday();
|
|
282
|
+
expect(first.projectsUpdated).toBe(1);
|
|
283
|
+
|
|
284
|
+
// New file in the SAME directory — this DOES bump the directory's mtime
|
|
285
|
+
// (an entry was added), so the registry's cached file list must be
|
|
286
|
+
// refreshed via readdir on the next tick.
|
|
287
|
+
writeTranscript('/repo/steady', 'sess-b', [
|
|
288
|
+
userLine(todayAt(9).toISOString()),
|
|
289
|
+
], todayAt(9));
|
|
290
|
+
|
|
291
|
+
const second = await historyAggregator.refreshIntradayToday();
|
|
292
|
+
expect(second.projectsUpdated).toBe(1);
|
|
293
|
+
|
|
294
|
+
const rows = await readTodayRollupSimplified();
|
|
295
|
+
expect(rows[0].sessionCount).toBe(2);
|
|
296
|
+
expect(rows[0].promptCount).toBe(2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('mapWithConcurrency preserves input order and respects the concurrency bound', async () => {
|
|
300
|
+
let inFlight = 0;
|
|
301
|
+
let maxInFlight = 0;
|
|
302
|
+
const items = Array.from({ length: 50 }, (_, i) => i);
|
|
303
|
+
const results = await historyAggregator.mapWithConcurrency(items, 5, async (i) => {
|
|
304
|
+
inFlight++;
|
|
305
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
307
|
+
inFlight--;
|
|
308
|
+
return i * 2;
|
|
309
|
+
});
|
|
310
|
+
expect(results).toEqual(items.map((i) => i * 2));
|
|
311
|
+
expect(maxInFlight).toBeLessThanOrEqual(5);
|
|
312
|
+
expect(maxInFlight).toBeGreaterThan(1);
|
|
313
|
+
});
|