claude-code-session-manager 0.58.0 → 0.60.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-BtVPGBaq.js} +1 -1
- package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
- package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
- 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-epic-digest.test.cjs +9 -2
- 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/epicContextDigest.cjs +36 -0
- 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 +228 -49
- 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
|
@@ -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",
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
const { z } = require('zod');
|
|
9
9
|
const os = require('node:os');
|
|
10
10
|
const path = require('node:path');
|
|
11
|
-
const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema } = require('./lib/promptSessionSchema.cjs');
|
|
11
|
+
const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema, EpicIntakeSectionSchema } = require('./lib/promptSessionSchema.cjs');
|
|
12
12
|
|
|
13
13
|
// ──────────────────────────────────────────── PTY
|
|
14
14
|
const ptySpawn = z.object({
|
|
@@ -183,6 +183,23 @@ const transcriptUsageFor = z.object({
|
|
|
183
183
|
sessionIds: z.array(z.string().regex(SESSION_UUID_RE)).max(500),
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
// Paged read over a subscribed transcript's line-offset index. Bounded line
|
|
187
|
+
// numbers — a page is a scroll window, never an unbounded range request.
|
|
188
|
+
const transcriptPage = z.object({
|
|
189
|
+
tabId: z.string().min(1).max(128),
|
|
190
|
+
startLine: z.number().int().min(0),
|
|
191
|
+
endLine: z.number().int().min(0),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Single-line full-payload read via a classifier byte reference (expand-to-
|
|
195
|
+
// full path, PRD chat-typed-event-renderers). byteLength bound mirrors
|
|
196
|
+
// transcripts.cjs's MAX_REF_BYTES — one JSONL line, never a whole-file read.
|
|
197
|
+
const transcriptReadRef = z.object({
|
|
198
|
+
filePath: z.string().min(1).max(4096),
|
|
199
|
+
byteOffset: z.number().int().min(0),
|
|
200
|
+
byteLength: z.number().int().min(1).max(64 * 1024 * 1024),
|
|
201
|
+
});
|
|
202
|
+
|
|
186
203
|
// ──────────────────────────────────────────── Config
|
|
187
204
|
const configPath = z.object({ path: z.string().min(1).max(4096) });
|
|
188
205
|
|
|
@@ -285,6 +302,11 @@ const promptSessionsCreateEpic = z.object({
|
|
|
285
302
|
tag: EpicTagSchema.optional(),
|
|
286
303
|
agentType: z.string().min(1).max(256).optional(),
|
|
287
304
|
source: EpicSourceSchema.optional(),
|
|
305
|
+
// The full first-prompt body + its labeled sections (epicIntake.ts's
|
|
306
|
+
// composeEpicIntake) — both optional, since not every mint caller (e.g. a
|
|
307
|
+
// future scripted Epic) composes a full opening prompt.
|
|
308
|
+
openingPrompt: z.string().max(200000).optional(),
|
|
309
|
+
sections: z.array(EpicIntakeSectionSchema).max(200).optional(),
|
|
288
310
|
});
|
|
289
311
|
|
|
290
312
|
// ──────────────────────────────────────────── Sessions
|
|
@@ -439,7 +461,6 @@ const home = os.homedir();
|
|
|
439
461
|
const setConfigSchema = z.object({
|
|
440
462
|
enabled: z.boolean().optional(),
|
|
441
463
|
offsetMinutes: z.number().int().min(0).max(180).optional(),
|
|
442
|
-
concurrencyCap: z.number().int().min(1).max(20).optional(),
|
|
443
464
|
defaultCwd: z.string().max(4096).refine(
|
|
444
465
|
(s) => s === home || s.startsWith(home + path.sep),
|
|
445
466
|
'defaultCwd must be inside home directory'
|
|
@@ -457,7 +478,7 @@ const setConfigSchema = z.object({
|
|
|
457
478
|
// Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
|
|
458
479
|
// schema/channel rather than folded into setConfigSchema: it governs the shared
|
|
459
480
|
// process pool, not any one project's scheduler config, and its range (0-10, 0
|
|
460
|
-
// pauses new launches)
|
|
481
|
+
// pauses new launches) is the single concurrency control.
|
|
461
482
|
const setSessionSlotsSchema = z.object({
|
|
462
483
|
cap: z.number().int().min(0).max(10),
|
|
463
484
|
}).strict();
|
|
@@ -965,6 +986,8 @@ module.exports = {
|
|
|
965
986
|
transcriptTabId,
|
|
966
987
|
transcriptPath,
|
|
967
988
|
transcriptUsageFor,
|
|
989
|
+
transcriptPage,
|
|
990
|
+
transcriptReadRef,
|
|
968
991
|
configPath,
|
|
969
992
|
configWriteJson,
|
|
970
993
|
configWriteText,
|
|
@@ -92,3 +92,133 @@ test('a FAILED bare-named dep holds the dependent and reports an explicit reason
|
|
|
92
92
|
assert.match(reason, /depends-gate/);
|
|
93
93
|
assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Parallelism regression coverage.
|
|
98
|
+
//
|
|
99
|
+
// `parallelGroup` used to gate batch membership: the picker fired at most one
|
|
100
|
+
// group per tick and held every higher group while a lower one was in flight.
|
|
101
|
+
// PRD 832 made the number strictly unique per PRD, so every group became a
|
|
102
|
+
// singleton and the batch was always exactly ONE job — measured max
|
|
103
|
+
// concurrency 1 across 25 recorded runs against a 5-slot pool. These tests
|
|
104
|
+
// pin the corrected contract: dependsOn is the only barrier, parallelGroup is
|
|
105
|
+
// a priority hint.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
test('fires EVERY dependency-eligible job, not one per parallelGroup', () => {
|
|
109
|
+
const jobs = [
|
|
110
|
+
job('983-a', 'pending'),
|
|
111
|
+
job('984-b', 'pending'),
|
|
112
|
+
job('985-c', 'pending'),
|
|
113
|
+
job('986-d', 'pending'),
|
|
114
|
+
];
|
|
115
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
116
|
+
// Pre-fix this returned exactly ['983-a'] — one singleton group.
|
|
117
|
+
assert.deepEqual(batch.map((j) => j.slug), ['983-a', '984-b', '985-c', '986-d']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a higher-numbered job is NOT held behind an in-flight lower-numbered one', () => {
|
|
121
|
+
const jobs = [
|
|
122
|
+
job('979-running', 'running'),
|
|
123
|
+
job('988-independent', 'pending'),
|
|
124
|
+
];
|
|
125
|
+
const { batch } = pick(jobs, new Set(['979-running']), 5);
|
|
126
|
+
// Pre-fix: held by the running-gate ("g979 in flight, holding g988"), which
|
|
127
|
+
// is how the fix for this very bug ended up stuck behind the bug.
|
|
128
|
+
assert.deepEqual(batch.map((j) => j.slug), ['988-independent']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('dependency-blocked jobs are excluded while independent siblings fire together', () => {
|
|
132
|
+
const jobs = [
|
|
133
|
+
job('985-foundation', 'pending'),
|
|
134
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
135
|
+
job('987-independent', 'pending'),
|
|
136
|
+
];
|
|
137
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
138
|
+
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation', '987-independent']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a FAILED job holds its transitive dependents but not unrelated jobs', () => {
|
|
142
|
+
const jobs = [
|
|
143
|
+
job('980-broken', 'failed'),
|
|
144
|
+
job('981-direct', 'pending', { dependsOn: ['broken'] }),
|
|
145
|
+
job('982-transitive', 'pending', { dependsOn: ['direct'] }),
|
|
146
|
+
job('983-unrelated', 'pending'),
|
|
147
|
+
];
|
|
148
|
+
const { batch } = pick(jobs, new Set(), 5);
|
|
149
|
+
// Pre-fix the cross-group failure gate held 983 too, purely for having a
|
|
150
|
+
// higher number than the failure.
|
|
151
|
+
assert.deepEqual(batch.map((j) => j.slug), ['983-unrelated']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('parallelGroup orders the batch when eligible jobs exceed free slots', () => {
|
|
155
|
+
const jobs = [
|
|
156
|
+
job('990-c', 'pending'),
|
|
157
|
+
job('988-a', 'pending'),
|
|
158
|
+
job('989-b', 'pending'),
|
|
159
|
+
];
|
|
160
|
+
const { batch } = pick(jobs, new Set(), 2);
|
|
161
|
+
assert.deepEqual(batch.map((j) => j.slug), ['988-a', '989-b']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('zero free slots holds everything with an explicit reason', () => {
|
|
165
|
+
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
166
|
+
const { batch, reason } = pick(jobs, new Set(), 0);
|
|
167
|
+
assert.deepEqual(batch, []);
|
|
168
|
+
assert.match(reason, /no slots free/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Per-job hold records (PRD 990). The picker already knew exactly which dep
|
|
173
|
+
// held which row; it only ever reached console.log. These pin it as data.
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
test('reports a per-job hold record naming the blocking dep and its status', () => {
|
|
177
|
+
const jobs = [
|
|
178
|
+
job('985-foundation', 'pending'),
|
|
179
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
180
|
+
];
|
|
181
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
182
|
+
assert.deepEqual(batch.map((j) => j.slug), ['985-foundation']);
|
|
183
|
+
assert.deepEqual(holds, [
|
|
184
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'pending' },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('hold record carries a running dep status', () => {
|
|
189
|
+
const jobs = [
|
|
190
|
+
job('985-foundation', 'running'),
|
|
191
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
192
|
+
];
|
|
193
|
+
const { holds } = pick(jobs, new Set(['985-foundation']), 5);
|
|
194
|
+
assert.deepEqual(holds, [
|
|
195
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'running' },
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('hold record carries a failed dep status alongside the depends-gate reason', () => {
|
|
200
|
+
const jobs = [
|
|
201
|
+
job('985-foundation', 'failed'),
|
|
202
|
+
job('986-dependent', 'pending', { dependsOn: ['foundation'] }),
|
|
203
|
+
];
|
|
204
|
+
const { batch, reason, holds } = pick(jobs, new Set(), 5);
|
|
205
|
+
assert.deepEqual(batch, []);
|
|
206
|
+
assert.match(reason, /depends-gate/);
|
|
207
|
+
assert.deepEqual(holds, [
|
|
208
|
+
{ slug: '986-dependent', dep: 'foundation', depStatus: 'failed' },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('no holds when nothing is dependency-blocked', () => {
|
|
213
|
+
const jobs = [job('988-a', 'pending'), job('989-b', 'pending')];
|
|
214
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
215
|
+
assert.equal(batch.length, 2);
|
|
216
|
+
assert.deepEqual(holds, []);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('an idle queue reports no holds (empty must not read as blocked)', () => {
|
|
220
|
+
const jobs = [job('988-a', 'completed')];
|
|
221
|
+
const { batch, holds } = pick(jobs, new Set(), 5);
|
|
222
|
+
assert.deepEqual(batch, []);
|
|
223
|
+
assert.deepEqual(holds, []);
|
|
224
|
+
});
|