claude-code-session-manager 0.58.0 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-BPnfPdLW.js} +1076 -1081
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +26 -3
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicMint.cjs +6 -1
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +16 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/scheduler.cjs +226 -47
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +75 -9
- package/src/preload/index.cjs +3 -0
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* scheduler-effective-concurrency.test.cjs — unit tests for the
|
|
3
3
|
* effectiveConcurrency field on buildScheduleStatePayload.
|
|
4
4
|
*
|
|
5
|
+
* The scheduler's private `concurrencyCap` is retired: the machine-wide
|
|
6
|
+
* sessionSlots pool is the ONLY concurrency limit, so effectiveConcurrency
|
|
7
|
+
* now reports the pool (and its SM_SESSION_SLOTS override), not a config
|
|
8
|
+
* field. See lib/sessionSlots.cjs's charter — caps belong to Session-Manager,
|
|
9
|
+
* not to each consumer.
|
|
10
|
+
*
|
|
5
11
|
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-effective-concurrency.test.cjs
|
|
6
12
|
*/
|
|
7
13
|
|
|
@@ -9,23 +15,26 @@
|
|
|
9
15
|
|
|
10
16
|
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
17
|
const { buildScheduleStatePayload } = require('../scheduler.cjs');
|
|
18
|
+
const sessionSlots = require('../lib/sessionSlots.cjs');
|
|
12
19
|
|
|
13
|
-
const ENV_KEY = '
|
|
20
|
+
const ENV_KEY = 'SM_SESSION_SLOTS';
|
|
14
21
|
let savedEnv;
|
|
15
22
|
|
|
16
23
|
beforeEach(() => {
|
|
17
24
|
savedEnv = process.env[ENV_KEY];
|
|
18
25
|
delete process.env[ENV_KEY];
|
|
26
|
+
sessionSlots.__resetForTests();
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
afterEach(() => {
|
|
22
30
|
if (savedEnv === undefined) delete process.env[ENV_KEY];
|
|
23
31
|
else process.env[ENV_KEY] = savedEnv;
|
|
32
|
+
sessionSlots.__resetForTests();
|
|
24
33
|
});
|
|
25
34
|
|
|
26
|
-
function fakeState(
|
|
35
|
+
function fakeState() {
|
|
27
36
|
return {
|
|
28
|
-
config: {
|
|
37
|
+
config: {},
|
|
29
38
|
jobs: [],
|
|
30
39
|
scheduledFor: null,
|
|
31
40
|
lastRunAt: null,
|
|
@@ -33,19 +42,30 @@ function fakeState(concurrencyCap) {
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
test('effectiveConcurrency
|
|
37
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
38
|
-
expect(payload.effectiveConcurrency).
|
|
45
|
+
test('effectiveConcurrency reports the pool when no env override is set', () => {
|
|
46
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
47
|
+
expect(payload.effectiveConcurrency.source).toBe('pool');
|
|
48
|
+
expect(payload.effectiveConcurrency.cap).toBe(sessionSlots.totalSlots());
|
|
49
|
+
expect(payload.effectiveConcurrency.free).toBe(sessionSlots.available());
|
|
39
50
|
});
|
|
40
51
|
|
|
41
|
-
test('effectiveConcurrency is env-sourced
|
|
52
|
+
test('effectiveConcurrency is env-sourced when SM_SESSION_SLOTS is set', () => {
|
|
42
53
|
process.env[ENV_KEY] = '7';
|
|
43
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
44
|
-
expect(payload.effectiveConcurrency).toEqual({ cap: 7, source: 'env' });
|
|
54
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
55
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 7, free: 7, source: 'env' });
|
|
45
56
|
});
|
|
46
57
|
|
|
47
|
-
test('effectiveConcurrency clamps env var into [
|
|
58
|
+
test('effectiveConcurrency clamps the env var into the pool range [0, 10]', () => {
|
|
48
59
|
process.env[ENV_KEY] = '999';
|
|
49
|
-
const payload = buildScheduleStatePayload(fakeState(
|
|
50
|
-
expect(payload.effectiveConcurrency).toEqual({ cap:
|
|
60
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
61
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 10, free: 10, source: 'env' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('free is net of held slots — a chat run shrinks it without changing cap', () => {
|
|
65
|
+
process.env[ENV_KEY] = '5';
|
|
66
|
+
const token = sessionSlots.acquire('chat:test-tab');
|
|
67
|
+
expect(token).toBeTruthy();
|
|
68
|
+
const payload = buildScheduleStatePayload(fakeState());
|
|
69
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 5, free: 4, source: 'env' });
|
|
70
|
+
sessionSlots.release(token);
|
|
51
71
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-heal-refusal.test.cjs — PRD 983.
|
|
3
|
+
*
|
|
4
|
+
* Guards the needs_review → completed self-heal in reverifyNeedsReview.
|
|
5
|
+
*
|
|
6
|
+
* Incident being regression-tested: job 972 (2026-08-03) ran 34 s, edited zero
|
|
7
|
+
* files, exited 0. verifyRun correctly raised `no_verdict_sentinel` /
|
|
8
|
+
* downgradeTo 'needs_review'. One minute later the self-heal pass promoted it
|
|
9
|
+
* to `completed` and the false green shipped. Cause: the rescan recomputes
|
|
10
|
+
* `committedDuringRun` from `committedInWindow`, a repo-wide
|
|
11
|
+
* `git log --all --since --until` with no author/message/slug filter — a
|
|
12
|
+
* CONCURRENT job's commit inside the window was credited to this job, which
|
|
13
|
+
* suppressed the no_verdict_sentinel check on re-verify and let the verdict
|
|
14
|
+
* conclude `clean`.
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-heal-refusal.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import { test, expect } from 'vitest';
|
|
22
|
+
const { healRefusalReason } = require('../scheduler.cjs');
|
|
23
|
+
|
|
24
|
+
const CLEAN = { verdict: 'clean', reason: 'no issues detected' };
|
|
25
|
+
|
|
26
|
+
test('REGRESSION (PRD 983): a no_verdict_sentinel job with no landedCommit is NOT healed, even on a clean re-verify', () => {
|
|
27
|
+
const job = { slug: '972-notify', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
|
|
28
|
+
const refusal = healRefusalReason(job, CLEAN, true);
|
|
29
|
+
expect(refusal).toBeTruthy();
|
|
30
|
+
expect(refusal).toContain('no job-attributable commit');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a no_verdict_sentinel job WITH its own landedCommit is allowed to heal', () => {
|
|
34
|
+
const job = { slug: '900-real', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'abc1234' };
|
|
35
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('other verdict classes are untouched — transcript_errors still heals on a clean re-verify', () => {
|
|
39
|
+
const job = { slug: '901-transcript', verifierVerdict: 'transcript_errors', landedCommit: null };
|
|
40
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('verify_unavailable (the other pre-sentinel-heal class) is untouched', () => {
|
|
44
|
+
const job = { slug: '902-unavailable', verifierVerdict: 'verify_unavailable', landedCommit: null };
|
|
45
|
+
expect(healRefusalReason(job, CLEAN, true)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a non-completed-equivalent re-verify verdict is left to the existing path, not refused here', () => {
|
|
49
|
+
const job = { slug: '903-still-bad', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
|
|
50
|
+
expect(healRefusalReason(job, { verdict: 'transcript_errors', reason: 'x' }, false)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an honest no-op exemption verdict still heals when the job recorded its own commit', () => {
|
|
54
|
+
const job = { slug: '904-shipped', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'deadbee' };
|
|
55
|
+
expect(healRefusalReason(job, { verdict: 'pass_no_commit_already_shipped', reason: 'x' }, false)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('null/missing inputs never throw and never refuse', () => {
|
|
59
|
+
expect(healRefusalReason(null, CLEAN, true)).toBeNull();
|
|
60
|
+
expect(healRefusalReason({ slug: 'x' }, null, true)).toBeNull();
|
|
61
|
+
});
|
|
@@ -116,6 +116,7 @@ test('routes into the known PromptSession event chain and never falls back to a
|
|
|
116
116
|
'/some/cwd',
|
|
117
117
|
'psess-1',
|
|
118
118
|
expect.stringContaining('814-notify'),
|
|
119
|
+
{ prdSlug: '814-notify', outcome: 'completed', validation: 'unvalidated' },
|
|
119
120
|
);
|
|
120
121
|
expect(sendPrompt).not.toHaveBeenCalled();
|
|
121
122
|
});
|
|
@@ -186,3 +187,76 @@ test('falls back to the tab-external-ticket path when appendResponseEvent itself
|
|
|
186
187
|
|
|
187
188
|
expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
|
|
188
189
|
});
|
|
190
|
+
|
|
191
|
+
// ─── PRD 985: real path resolution ───────────────────────────────────────────
|
|
192
|
+
// Every test above injects a stubbed `parsePrdRaw`, which is exactly why the
|
|
193
|
+
// bug PRD 985 fixes shipped green: notifyOriginatingTab resolved its PRD via
|
|
194
|
+
// prdPathForJob → the RETIRED flat `scheduler/prds/` dir (today only
|
|
195
|
+
// `.reserved-NNN` stubs, no PRDs), and archiveCompletedPrd renames the file
|
|
196
|
+
// into `prds-archived/` immediately BEFORE notify runs anyway. `prd` was
|
|
197
|
+
// therefore always null on the completed path and no response event was ever
|
|
198
|
+
// appended. These two tests use the REAL parsePrdRaw against a temp tree so
|
|
199
|
+
// the resolution logic itself is covered, not mocked away.
|
|
200
|
+
|
|
201
|
+
const fs = require('node:fs');
|
|
202
|
+
const os = require('node:os');
|
|
203
|
+
const nodePath = require('node:path');
|
|
204
|
+
|
|
205
|
+
function makeEpicTree({ epicId, slug, archived }) {
|
|
206
|
+
const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-'));
|
|
207
|
+
const dir = nodePath.join(
|
|
208
|
+
cwd, 'session-manager-operations', 'scheduler', 'epics', epicId,
|
|
209
|
+
archived ? 'prds-archived' : 'prds',
|
|
210
|
+
);
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
nodePath.join(dir, `${slug}.md`),
|
|
214
|
+
`---\ntitle: notify 985\ncwd: ${cwd}\nsourcePromptId: ${epicId}\n---\n\nbody\n`,
|
|
215
|
+
'utf8',
|
|
216
|
+
);
|
|
217
|
+
return cwd;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test('PRD 985: an ARCHIVED-only PRD still routes its check-in to the authoring Epic', async () => {
|
|
221
|
+
const epicId = 'psess-985-archived';
|
|
222
|
+
const slug = '985-archived-twin';
|
|
223
|
+
const cwd = makeEpicTree({ epicId, slug, archived: true });
|
|
224
|
+
|
|
225
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
226
|
+
const appendTranscriptTurn = vi.fn(async () => {});
|
|
227
|
+
const sendPrompt = vi.fn();
|
|
228
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
229
|
+
|
|
230
|
+
// NOTE: no parsePrdRaw override — the real one runs against the temp tree.
|
|
231
|
+
await notifyOriginatingTab(
|
|
232
|
+
{ slug, status: 'completed', cwd, epicId: null },
|
|
233
|
+
{ loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(appendResponseEvent).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(appendResponseEvent).toHaveBeenCalledWith(
|
|
238
|
+
cwd, epicId, expect.stringContaining(slug),
|
|
239
|
+
expect.objectContaining({ prdSlug: slug, outcome: 'completed' }),
|
|
240
|
+
);
|
|
241
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, epicId, expect.anything());
|
|
242
|
+
expect(sendPrompt).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('PRD 985: a PRD missing entirely still routes via the job row\'s own epicId', async () => {
|
|
246
|
+
const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-none-'));
|
|
247
|
+
const appendResponseEvent = vi.fn(async () => true);
|
|
248
|
+
const appendTranscriptTurn = vi.fn(async () => {});
|
|
249
|
+
const sendPrompt = vi.fn();
|
|
250
|
+
const loadSessions = vi.fn(async () => ({ tabs: [] }));
|
|
251
|
+
|
|
252
|
+
await notifyOriginatingTab(
|
|
253
|
+
{ slug: '985-no-prd-on-disk', status: 'failed', cwd, epicId: 'psess-985-from-queue-row' },
|
|
254
|
+
{ loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(appendResponseEvent).toHaveBeenCalledWith(
|
|
258
|
+
cwd, 'psess-985-from-queue-row', expect.stringContaining('985-no-prd-on-disk'),
|
|
259
|
+
expect.objectContaining({ prdSlug: '985-no-prd-on-disk', outcome: 'failed' }),
|
|
260
|
+
);
|
|
261
|
+
expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, 'psess-985-from-queue-row', expect.anything());
|
|
262
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-doFlush-array.test.cjs — unit tests for transcripts.cjs's
|
|
3
|
+
* doFlush()/readDelta() handling of classifyLine's array-of-events shape.
|
|
4
|
+
*
|
|
5
|
+
* Covers: one line producing several events all land in the getBuffer()
|
|
6
|
+
* drain flattened (not nested per line), a session with far more than the
|
|
7
|
+
* old 500-entry ring cap still returns every event via the line-offset
|
|
8
|
+
* index (the cap is gone), and each event's byte `ref` correctly points
|
|
9
|
+
* back at its own line in the transcript file on disk.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-doFlush-array.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { subscribe, getBuffer, closeTab, transcriptPath } = require('../transcripts.cjs');
|
|
23
|
+
|
|
24
|
+
let cwd;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-doflush-test-'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function writeTranscript(cwdArg, sessionId, lines) {
|
|
35
|
+
const filePath = transcriptPath(cwdArg, sessionId);
|
|
36
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
37
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('a single multi-block line flattens into multiple buffered events', async () => {
|
|
42
|
+
const sessionId = 'sess-multi';
|
|
43
|
+
const tabId = 'tab-multi';
|
|
44
|
+
writeTranscript(cwd, sessionId, [
|
|
45
|
+
{
|
|
46
|
+
type: 'assistant',
|
|
47
|
+
message: {
|
|
48
|
+
content: [
|
|
49
|
+
{ type: 'text', text: 'doing two things' },
|
|
50
|
+
{ type: 'tool_use', name: 'Bash', id: 'tu-1', input: { command: 'ls' } },
|
|
51
|
+
{ type: 'tool_use', name: 'Bash', id: 'tu-2', input: { command: 'pwd' } },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
58
|
+
expect(res.ok).toBe(true);
|
|
59
|
+
const buf = await getBuffer(tabId);
|
|
60
|
+
expect(buf).toHaveLength(3);
|
|
61
|
+
expect(buf.map((e) => e.kind)).toEqual(['assistant', 'tool_use', 'tool_use']);
|
|
62
|
+
closeTab(tabId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a session with far more than the old 500-entry cap still returns every event — the ring cap is gone', async () => {
|
|
66
|
+
const sessionId = 'sess-cap';
|
|
67
|
+
const tabId = 'tab-cap';
|
|
68
|
+
// 200 lines * 3 events each = 600 events > the old 500 cap.
|
|
69
|
+
const lines = Array.from({ length: 200 }, (_, i) => ({
|
|
70
|
+
type: 'assistant',
|
|
71
|
+
message: {
|
|
72
|
+
content: [
|
|
73
|
+
{ type: 'text', text: `line ${i}` },
|
|
74
|
+
{ type: 'tool_use', name: 'Bash', id: `tu-${i}-a`, input: { command: 'ls' } },
|
|
75
|
+
{ type: 'tool_use', name: 'Bash', id: `tu-${i}-b`, input: { command: 'pwd' } },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
writeTranscript(cwd, sessionId, lines);
|
|
80
|
+
|
|
81
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
82
|
+
expect(res.ok).toBe(true);
|
|
83
|
+
const buf = await getBuffer(tabId);
|
|
84
|
+
expect(buf).toHaveLength(600);
|
|
85
|
+
// Nothing evicted — both the genuine first and last events survive.
|
|
86
|
+
expect(buf[0].data).toBe('line 0');
|
|
87
|
+
expect(buf[buf.length - 1].data.id).toBe('tu-199-b');
|
|
88
|
+
closeTab(tabId);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('each event ref points at its own line — re-reading those bytes reproduces that exact line', async () => {
|
|
92
|
+
const sessionId = 'sess-ref';
|
|
93
|
+
const tabId = 'tab-ref';
|
|
94
|
+
const filePath = writeTranscript(cwd, sessionId, [
|
|
95
|
+
{ type: 'user', message: { content: 'first line — some unicode: héllo 🎉' } },
|
|
96
|
+
{ type: 'assistant', message: { content: 'second line' } },
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
100
|
+
expect(res.ok).toBe(true);
|
|
101
|
+
const buf = await getBuffer(tabId);
|
|
102
|
+
expect(buf).toHaveLength(2);
|
|
103
|
+
|
|
104
|
+
const fd = fs.openSync(filePath, 'r');
|
|
105
|
+
try {
|
|
106
|
+
for (const ev of buf) {
|
|
107
|
+
expect(ev.ref).toBeTruthy();
|
|
108
|
+
expect(ev.ref.filePath).toBe(filePath);
|
|
109
|
+
const readBuf = Buffer.alloc(ev.ref.byteLength);
|
|
110
|
+
fs.readSync(fd, readBuf, 0, ev.ref.byteLength, ev.ref.byteOffset);
|
|
111
|
+
const parsed = JSON.parse(readBuf.toString('utf8'));
|
|
112
|
+
expect(parsed.type).toBe(ev.kind);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
fs.closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
closeTab(tabId);
|
|
118
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-paged-reads.test.cjs — unit tests for the line-offset index +
|
|
3
|
+
* paged-read API that replaced transcripts.cjs's 500-entry ring buffer
|
|
4
|
+
* (PRD 979). Covers:
|
|
5
|
+
* - scrolling to the top of a >500-event session returns the genuine first
|
|
6
|
+
* event via readPage/pageEvents, not a truncated window
|
|
7
|
+
* - paging is a bounded positional read, never a whole-file materialization
|
|
8
|
+
* - bounded memory: indexing a huge number of lines (plus one multi-MB
|
|
9
|
+
* line) keeps the persisted index far smaller than the raw content
|
|
10
|
+
* - inode-change rotation and truncation invalidate and rebuild the index
|
|
11
|
+
* - a partial trailing line is never indexed until it completes
|
|
12
|
+
* - a malformed JSON line still occupies its index slot (line numbering
|
|
13
|
+
* stays correct)
|
|
14
|
+
* - release()/re-subscribe fast-resume preserves the index, not just offset
|
|
15
|
+
* - paging never re-emits to OTEL (only the live doFlush ingest path does)
|
|
16
|
+
*
|
|
17
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-paged-reads.test.cjs
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const { subscribe, release, closeTab, transcriptPath, pageEvents } = require('../transcripts.cjs');
|
|
29
|
+
const otel = require('../otel.cjs');
|
|
30
|
+
|
|
31
|
+
let cwd;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-paged-test-'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function writeTranscript(cwdArg, sessionId, lines) {
|
|
42
|
+
const filePath = transcriptPath(cwdArg, sessionId);
|
|
43
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
45
|
+
return filePath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('paging to line 0 of a >500-event session returns the genuine first event', async () => {
|
|
49
|
+
const sessionId = 'sess-page-top';
|
|
50
|
+
const tabId = 'tab-page-top';
|
|
51
|
+
const lines = Array.from({ length: 800 }, (_, i) => ({
|
|
52
|
+
type: 'user',
|
|
53
|
+
message: { content: [{ type: 'text', text: `event number ${i}` }] },
|
|
54
|
+
}));
|
|
55
|
+
writeTranscript(cwd, sessionId, lines);
|
|
56
|
+
|
|
57
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
58
|
+
expect(res.ok).toBe(true);
|
|
59
|
+
|
|
60
|
+
const top = await pageEvents(tabId, 0, 0);
|
|
61
|
+
expect(top.totalLines).toBe(800);
|
|
62
|
+
expect(top.events).toHaveLength(1);
|
|
63
|
+
expect(top.events[0].data).toBe('event number 0');
|
|
64
|
+
expect(top.events[0].lineNumber).toBe(0);
|
|
65
|
+
|
|
66
|
+
const bottom = await pageEvents(tabId, 799, 799);
|
|
67
|
+
expect(bottom.events[0].data).toBe('event number 799');
|
|
68
|
+
|
|
69
|
+
closeTab(tabId);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('bounded memory: indexing many lines plus one multi-MB line keeps the index far smaller than the raw content', async () => {
|
|
73
|
+
const sessionId = 'sess-bounded';
|
|
74
|
+
const tabId = 'tab-bounded';
|
|
75
|
+
const NUM_LINES = 20000;
|
|
76
|
+
const bigLineIndex = 10000;
|
|
77
|
+
const bigText = 'x'.repeat(5 * 1024 * 1024); // 5MB single line
|
|
78
|
+
const lines = Array.from({ length: NUM_LINES }, (_, i) => {
|
|
79
|
+
if (i === bigLineIndex) {
|
|
80
|
+
return { type: 'user', message: { content: [{ type: 'text', text: bigText }] } };
|
|
81
|
+
}
|
|
82
|
+
return { type: 'user', message: { content: [{ type: 'text', text: `line ${i}` }] } };
|
|
83
|
+
});
|
|
84
|
+
writeTranscript(cwd, sessionId, lines);
|
|
85
|
+
|
|
86
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
87
|
+
expect(res.ok).toBe(true);
|
|
88
|
+
|
|
89
|
+
const sub = require('../transcripts.cjs').__getSubForTest?.(tabId);
|
|
90
|
+
// Fall back to reading the module's internal state via pageEvents' totalLines
|
|
91
|
+
// if the internal accessor isn't exported; either way, assert the ceiling on
|
|
92
|
+
// what actually persists: the index itself, not the file content.
|
|
93
|
+
const { totalLines } = await pageEvents(tabId, 0, 0);
|
|
94
|
+
expect(totalLines).toBe(NUM_LINES);
|
|
95
|
+
|
|
96
|
+
// Each index entry is two small integers (byteOffset, byteLength). Even a
|
|
97
|
+
// generous 64 bytes/entry ceiling for NUM_LINES entries is three orders of
|
|
98
|
+
// magnitude below the 5MB+ raw content of the one huge line — proving the
|
|
99
|
+
// index never holds the line's actual text.
|
|
100
|
+
const CEILING_BYTES = NUM_LINES * 64;
|
|
101
|
+
expect(CEILING_BYTES).toBeLessThan(bigText.length);
|
|
102
|
+
if (sub) {
|
|
103
|
+
const approxIndexBytes = sub.lineIndex.length * 16; // 2 numbers/entry
|
|
104
|
+
expect(approxIndexBytes).toBeLessThan(CEILING_BYTES);
|
|
105
|
+
// The big line's text must not appear anywhere in the index entries.
|
|
106
|
+
for (const entry of sub.lineIndex) {
|
|
107
|
+
expect(Object.keys(entry).sort()).toEqual(['byteLength', 'byteOffset']);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
closeTab(tabId);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('inode-change rotation invalidates and rebuilds the index — no stale offsets served', async () => {
|
|
115
|
+
const sessionId = 'sess-rotate';
|
|
116
|
+
const tabId = 'tab-rotate';
|
|
117
|
+
const filePath = writeTranscript(cwd, sessionId, [
|
|
118
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'old-a' }] } },
|
|
119
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'old-b' }] } },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
123
|
+
expect(res.ok).toBe(true);
|
|
124
|
+
let page = await pageEvents(tabId, 0, 1);
|
|
125
|
+
expect(page.totalLines).toBe(2);
|
|
126
|
+
|
|
127
|
+
// Simulate log rotation: unlink + recreate (new inode) with different content.
|
|
128
|
+
fs.unlinkSync(filePath);
|
|
129
|
+
fs.writeFileSync(filePath, [{ type: 'user', message: { content: [{ type: 'text', text: 'new-a' }] } }].map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
130
|
+
|
|
131
|
+
// The chokidar watcher on the live sub picks up the rotation and rebuilds
|
|
132
|
+
// the index via readDelta's inode-change reset — poll (bounded) rather
|
|
133
|
+
// than assume a fixed debounce delay.
|
|
134
|
+
const deadline = Date.now() + 5000;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
page = await pageEvents(tabId, 0, 0);
|
|
137
|
+
if (page.totalLines === 1 && page.events[0]?.data === 'new-a') break;
|
|
138
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
139
|
+
}
|
|
140
|
+
expect(page.totalLines).toBe(1);
|
|
141
|
+
expect(page.events[0].data).toBe('new-a');
|
|
142
|
+
closeTab(tabId);
|
|
143
|
+
}, 10000);
|
|
144
|
+
|
|
145
|
+
test('a partial trailing line is not indexed until it completes', async () => {
|
|
146
|
+
const sessionId = 'sess-partial';
|
|
147
|
+
const tabId = 'tab-partial';
|
|
148
|
+
const filePath = transcriptPath(cwd, sessionId);
|
|
149
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
150
|
+
const complete = JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'first' }] } });
|
|
151
|
+
// No trailing newline — this line is "still being written".
|
|
152
|
+
fs.writeFileSync(filePath, complete + '\n' + '{"type":"user","message":{"content":[{"type":"text","text":"unfinishe');
|
|
153
|
+
|
|
154
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
155
|
+
expect(res.ok).toBe(true);
|
|
156
|
+
let page = await pageEvents(tabId, 0, 10);
|
|
157
|
+
expect(page.totalLines).toBe(1);
|
|
158
|
+
expect(page.events[0].data).toBe('first');
|
|
159
|
+
|
|
160
|
+
closeTab(tabId);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a malformed JSON line still occupies its index slot — line numbering stays correct', async () => {
|
|
164
|
+
const sessionId = 'sess-malformed';
|
|
165
|
+
const tabId = 'tab-malformed';
|
|
166
|
+
const filePath = transcriptPath(cwd, sessionId);
|
|
167
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
168
|
+
const body = [
|
|
169
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'ok-0' }] } }),
|
|
170
|
+
'not valid json {{{',
|
|
171
|
+
JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'ok-2' }] } }),
|
|
172
|
+
].join('\n') + '\n';
|
|
173
|
+
fs.writeFileSync(filePath, body);
|
|
174
|
+
|
|
175
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
176
|
+
expect(res.ok).toBe(true);
|
|
177
|
+
const page = await pageEvents(tabId, 0, 2);
|
|
178
|
+
expect(page.totalLines).toBe(3);
|
|
179
|
+
// Malformed line 1 produces no event, but line 2's numbering is unaffected.
|
|
180
|
+
expect(page.events.map((e) => e.lineNumber)).toEqual([0, 2]);
|
|
181
|
+
expect(page.events[1].data).toBe('ok-2');
|
|
182
|
+
|
|
183
|
+
closeTab(tabId);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('release()/re-subscribe fast-resume preserves the index rather than re-reading from byte 0', async () => {
|
|
187
|
+
const sessionId = 'sess-resume';
|
|
188
|
+
const tabId = 'tab-resume';
|
|
189
|
+
writeTranscript(cwd, sessionId, [
|
|
190
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'a' }] } },
|
|
191
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'b' }] } },
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
195
|
+
expect(res.ok).toBe(true);
|
|
196
|
+
const before = await pageEvents(tabId, 0, 1);
|
|
197
|
+
expect(before.totalLines).toBe(2);
|
|
198
|
+
|
|
199
|
+
release(tabId); // view-switch away — sub stays cached
|
|
200
|
+
// Re-subscribing the same tab should promote it back from the LRU cache
|
|
201
|
+
// rather than re-reading the transcript from scratch.
|
|
202
|
+
const res2 = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
203
|
+
expect(res2.ok).toBe(true);
|
|
204
|
+
const after = await pageEvents(tabId, 0, 1);
|
|
205
|
+
expect(after.totalLines).toBe(2);
|
|
206
|
+
expect(after.events.map((e) => e.data)).toEqual(['a', 'b']);
|
|
207
|
+
|
|
208
|
+
closeTab(tabId);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('paging never re-emits to OTEL — only the live doFlush ingest path records', async () => {
|
|
212
|
+
const sessionId = 'sess-otel';
|
|
213
|
+
const tabId = 'tab-otel';
|
|
214
|
+
writeTranscript(cwd, sessionId, [
|
|
215
|
+
{ type: 'user', message: { content: 'one' } },
|
|
216
|
+
{ type: 'user', message: { content: 'two' } },
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const spy = vi.spyOn(otel, 'recordTranscriptEvent');
|
|
220
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
221
|
+
expect(res.ok).toBe(true);
|
|
222
|
+
const callsAfterSubscribe = spy.mock.calls.length;
|
|
223
|
+
expect(callsAfterSubscribe).toBeGreaterThan(0); // initial drain recorded both events
|
|
224
|
+
|
|
225
|
+
// Page over the same range repeatedly — must not add more OTEL calls.
|
|
226
|
+
await pageEvents(tabId, 0, 1);
|
|
227
|
+
await pageEvents(tabId, 0, 1);
|
|
228
|
+
await pageEvents(tabId, 0, 0);
|
|
229
|
+
expect(spy.mock.calls.length).toBe(callsAfterSubscribe);
|
|
230
|
+
|
|
231
|
+
spy.mockRestore();
|
|
232
|
+
closeTab(tabId);
|
|
233
|
+
});
|
|
@@ -95,12 +95,17 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
95
95
|
expect(res.reason).toContain('854-base');
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// Contract change: parallelGroup no longer gates batch membership at all
|
|
99
|
+
// (it was a wave number; PRD 832 made it unique per PRD, which silently
|
|
100
|
+
// reduced every batch to one job). Legacy same-NN rows are simply eligible
|
|
101
|
+
// like anything else with no blocking dependsOn — the 828 row is no longer
|
|
102
|
+
// held back merely for carrying a higher number.
|
|
103
|
+
it('legacy same-NN pending jobs are eligible alongside higher-numbered ones', () => {
|
|
99
104
|
const a = job('827-one', 'pending', 827);
|
|
100
105
|
const b = job('827-two', 'pending', 827);
|
|
101
106
|
const later = job('828-three', 'pending', 828);
|
|
102
107
|
const res = pickForProject([a, b, later], new Set(), 3);
|
|
103
|
-
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two']);
|
|
108
|
+
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
|
|
104
109
|
});
|
|
105
110
|
});
|
|
106
111
|
|
package/src/main/health.cjs
CHANGED
|
@@ -78,7 +78,11 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
78
78
|
const pending = jobs.filter((j) => j.status === 'pending');
|
|
79
79
|
const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
|
|
80
80
|
const config = queueState.config || {};
|
|
81
|
-
|
|
81
|
+
// The scheduler's private concurrencyCap is retired — the machine-wide
|
|
82
|
+
// sessionSlots pool is the only limit. Read it lazily so this stays a pure
|
|
83
|
+
// function of its args when a caller supplies slotCap explicitly.
|
|
84
|
+
const concurrencyCap = queueState.slotCap
|
|
85
|
+
?? (() => { try { return require('./lib/sessionSlots.cjs').totalSlots(); } catch { return Infinity; } })();
|
|
82
86
|
|
|
83
87
|
if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
|
|
84
88
|
if (queueState.paused) return { stalled: false, reason: 'paused' };
|
package/src/main/index.cjs
CHANGED
|
@@ -1021,7 +1021,7 @@ app.whenReady().then(async () => {
|
|
|
1021
1021
|
"img-src 'self' data: blob: smfile:",
|
|
1022
1022
|
"font-src 'self' data: https://fonts.gstatic.com",
|
|
1023
1023
|
// schemastore.org is used by Monaco for JSON schema validation
|
|
1024
|
-
// (settings.json
|
|
1024
|
+
// (settings.json — see App.tsx::installMonacoSchemas).
|
|
1025
1025
|
// The json.schemastore.org URL redirects to www.schemastore.org, so both
|
|
1026
1026
|
// hosts must be in the allowlist or CSP blocks the redirect.
|
|
1027
1027
|
"connect-src 'self' https://api.anthropic.com https://registry.npmjs.org https://json.schemastore.org https://www.schemastore.org",
|