claude-code-session-manager 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-DOVjN6Gc.js → TiptapBody-BYG18cfk.js} +1 -1
- package/dist/assets/{index-BXzDjHNE.css → index-BoOiJJOr.css} +1 -1
- package/dist/assets/index-D1v4VU6k.js +3218 -0
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/src/main/__tests__/epicMint.test.cjs +110 -0
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdLocationsArchived.test.cjs +152 -0
- package/src/main/__tests__/prdMigration.test.cjs +5 -0
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +38 -5
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +89 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +103 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +8 -0
- package/src/main/index.cjs +9 -0
- package/src/main/ipcSchemas.cjs +29 -1
- package/src/main/lib/epicMint.cjs +46 -12
- package/src/main/lib/prdLocations.cjs +40 -2
- package/src/main/projectPages.cjs +65 -0
- package/src/main/scheduler.cjs +148 -19
- package/src/preload/api.d.ts +37 -2
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-DPY5vv68.js +0 -3218
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-D1v4VU6k.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BoOiJJOr.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"dev:renderer": "vite",
|
|
29
29
|
"dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
|
|
30
30
|
"build": "vite build",
|
|
31
|
+
"build:project-pages": "node scripts/build-project-pages-renderer.mjs",
|
|
32
|
+
"build:project-pages-logic": "node scripts/build-project-pages-logic.mjs",
|
|
31
33
|
"typecheck": "tsc --noEmit",
|
|
32
34
|
"start": "electron .",
|
|
33
35
|
"postinstall": "node scripts/postinstall.cjs",
|
|
@@ -124,6 +126,7 @@
|
|
|
124
126
|
"@vitejs/plugin-react": "^4.3.3",
|
|
125
127
|
"autoprefixer": "^10.4.20",
|
|
126
128
|
"concurrently": "^9.1.0",
|
|
129
|
+
"esbuild": "^0.25.12",
|
|
127
130
|
"jsdom": "^29.1.1",
|
|
128
131
|
"postcss": "^8.4.49",
|
|
129
132
|
"react": "^18.3.1",
|
|
@@ -200,6 +200,116 @@ test('findJoinableEpic falls through to the similarity check when preferEpicId p
|
|
|
200
200
|
expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test('ensureEpic mints with status "proposed" by default', async () => {
|
|
204
|
+
const cwd = await mkCwd();
|
|
205
|
+
|
|
206
|
+
const minted = await ensureEpic(cwd, { goalText: 'default status epic' });
|
|
207
|
+
|
|
208
|
+
const index = readActiveIndex(cwd);
|
|
209
|
+
expect(index.sessions[minted.epicId].status).toBe('proposed');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('ensureEpic throws when a mint is requested with status "active" (born-proposed law), and appends an audit event', async () => {
|
|
213
|
+
const cwd = await mkCwd();
|
|
214
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
215
|
+
|
|
216
|
+
await expect(
|
|
217
|
+
ensureEpic(cwd, { goalText: 'should never mint active', status: 'active' }),
|
|
218
|
+
).rejects.toThrow(/born 'proposed'/);
|
|
219
|
+
|
|
220
|
+
const index = readActiveIndex(cwd);
|
|
221
|
+
expect(Object.keys(index.sessions)).toHaveLength(0);
|
|
222
|
+
|
|
223
|
+
const records = fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
|
|
224
|
+
.split('\n')
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.map((line) => JSON.parse(line))
|
|
227
|
+
.filter((r) => r.cwd === cwd);
|
|
228
|
+
expect(records).toHaveLength(1);
|
|
229
|
+
expect(records[0].kind).toBe('epic_mint_refused');
|
|
230
|
+
expect(records[0].status).toBe('active');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('ensureEpic still joins an existing active Epic via preferEpicId even when status:"active" is requested', async () => {
|
|
234
|
+
const cwd = await mkCwd();
|
|
235
|
+
const first = await ensureEpic(cwd, { goalText: 'already running epic' });
|
|
236
|
+
const index = readActiveIndex(cwd);
|
|
237
|
+
index.sessions[first.epicId].status = 'active';
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
240
|
+
JSON.stringify(index, null, 2),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId, status: 'active' });
|
|
244
|
+
|
|
245
|
+
expect(second.created).toBe(false);
|
|
246
|
+
expect(second.epicId).toBe(first.epicId);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('ensureEpic falls through to a "proposed" mint when explicit epicId is archived/completed, never throwing', async () => {
|
|
250
|
+
const cwd = await mkCwd();
|
|
251
|
+
const first = await ensureEpic(cwd, { goalText: 'completed epic' });
|
|
252
|
+
const index = readActiveIndex(cwd);
|
|
253
|
+
index.sessions[first.epicId].status = 'completed';
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
256
|
+
JSON.stringify(index, null, 2),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const second = await ensureEpic(cwd, { goalText: 'totally unrelated goal text here', epicId: first.epicId });
|
|
260
|
+
|
|
261
|
+
expect(second.created).toBe(true);
|
|
262
|
+
expect(second.epicId).not.toBe(first.epicId);
|
|
263
|
+
const after = readActiveIndex(cwd);
|
|
264
|
+
expect(after.sessions[second.epicId].status).toBe('proposed');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
function readAuditRecordsForCwd(auditLog, cwd) {
|
|
268
|
+
if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
|
|
269
|
+
return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
|
|
270
|
+
.split('\n')
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.map((line) => {
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(line);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
.filter((record) => record && record.cwd === cwd);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
test('mintIfMissing:false refusal appends an epic_mint_refused audit event', async () => {
|
|
283
|
+
const cwd = await mkCwd();
|
|
284
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
285
|
+
|
|
286
|
+
await expect(ensureEpic(cwd, { goalText: 'no epic yet', mintIfMissing: false })).rejects.toThrow();
|
|
287
|
+
|
|
288
|
+
const records = readAuditRecordsForCwd(auditLog, cwd);
|
|
289
|
+
expect(records).toHaveLength(1);
|
|
290
|
+
expect(records[0].kind).toBe('epic_mint_refused');
|
|
291
|
+
expect(records[0].cwd).toBe(cwd);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('not-open explicit-epicId fall-through appends an epic_mint_refused audit event', async () => {
|
|
295
|
+
const cwd = await mkCwd();
|
|
296
|
+
const first = await ensureEpic(cwd, { goalText: 'to be completed' });
|
|
297
|
+
const index = readActiveIndex(cwd);
|
|
298
|
+
index.sessions[first.epicId].status = 'completed';
|
|
299
|
+
fs.writeFileSync(
|
|
300
|
+
path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
301
|
+
JSON.stringify(index, null, 2),
|
|
302
|
+
);
|
|
303
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
304
|
+
|
|
305
|
+
await ensureEpic(cwd, { goalText: 'unrelated fresh goal xyz', epicId: first.epicId });
|
|
306
|
+
|
|
307
|
+
const records = readAuditRecordsForCwd(auditLog, cwd);
|
|
308
|
+
const refusal = records.find((r) => r.kind === 'epic_mint_refused');
|
|
309
|
+
expect(refusal).toBeDefined();
|
|
310
|
+
expect(refusal.epicId).toBe(first.epicId);
|
|
311
|
+
});
|
|
312
|
+
|
|
203
313
|
test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
|
|
204
314
|
const cwd = await mkCwd();
|
|
205
315
|
|
|
@@ -532,6 +532,11 @@ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionI
|
|
|
532
532
|
}
|
|
533
533
|
});
|
|
534
534
|
|
|
535
|
+
// Real-homedir-rooted like the chat-only-epic test above (see its comment at
|
|
536
|
+
// line ~449): validatePath's read boundary is home-dir-wide, and this
|
|
537
|
+
// exercises createPrd's real config.addAllowedRoot/writeTextAtomic path. Safe
|
|
538
|
+
// because the random suffix is never a registered project the live scheduler
|
|
539
|
+
// scans for PRDs, and it's removed in `finally`.
|
|
535
540
|
test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
|
|
536
541
|
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
|
|
537
542
|
config.addAllowedRoot(root);
|
|
@@ -555,6 +560,7 @@ test('createPrd resolves sourcePromptId from originClaudeSessionId when the call
|
|
|
555
560
|
}
|
|
556
561
|
});
|
|
557
562
|
|
|
563
|
+
// Same real-homedir rationale as the two tests above.
|
|
558
564
|
test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
|
|
559
565
|
const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
|
|
560
566
|
config.addAllowedRoot(root);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdLocationsArchived.test.cjs — unit tests for the archived-PRD discovery
|
|
3
|
+
* that backs `schedule:list-prds` including archived PRDs (PRD 926): an
|
|
4
|
+
* Epic's "PRDs" tab count must reflect its REAL historical PRD count, not
|
|
5
|
+
* just whatever is still sitting in its live `prds/` dir — a completed
|
|
6
|
+
* PRD's source .md moves to the sibling `prds-archived/` dir the instant its
|
|
7
|
+
* scheduler job finishes (scheduler.cjs's archiveCompletedPrd), which the
|
|
8
|
+
* old list-prds handler never read.
|
|
9
|
+
*
|
|
10
|
+
* Covers:
|
|
11
|
+
* - resolveArchivedPrdsDirs finds an Epic's own `prds-archived/` dir
|
|
12
|
+
* alongside the project's flat sibling one.
|
|
13
|
+
* - deriveEpicIdFromPrdPath resolves an epicId for a file under
|
|
14
|
+
* `prds-archived/`, not just the live `prds/` dir.
|
|
15
|
+
* - scheduler.cjs's candidateArchivedPrdsDirs + candidatePrdsDirs
|
|
16
|
+
* together surface both a still-pending PRD and an already-archived one
|
|
17
|
+
* for the same Epic, without double-counting.
|
|
18
|
+
*
|
|
19
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdLocationsArchived.test.cjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
import { test, expect, afterEach } from 'vitest';
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const fsp = require('node:fs/promises');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const {
|
|
30
|
+
resolveArchivedPrdsDirs,
|
|
31
|
+
resolvePrdsDirs,
|
|
32
|
+
resolveEpicPrdWriteDir,
|
|
33
|
+
deriveEpicIdFromPrdPath,
|
|
34
|
+
} = require('../lib/prdLocations.cjs');
|
|
35
|
+
const { candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus } = require('../scheduler.cjs');
|
|
36
|
+
|
|
37
|
+
const tmpDirs = [];
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
while (tmpDirs.length) {
|
|
40
|
+
const d = tmpDirs.pop();
|
|
41
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function mkEpicProject() {
|
|
46
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-cwd-'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('resolveArchivedPrdsDirs finds an Epic\'s own prds-archived/ dir for an active project', async () => {
|
|
50
|
+
const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-projects-'));
|
|
51
|
+
tmpDirs.push(projectsDir);
|
|
52
|
+
const projectCwd = await mkEpicProject();
|
|
53
|
+
tmpDirs.push(projectCwd);
|
|
54
|
+
|
|
55
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-1');
|
|
56
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
57
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
const projDir = path.join(projectsDir, 'epic-project');
|
|
60
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
61
|
+
const transcript = path.join(projDir, 'session1.jsonl');
|
|
62
|
+
fs.writeFileSync(transcript, `${JSON.stringify({ cwd: projectCwd })}\n`);
|
|
63
|
+
|
|
64
|
+
const dirs = resolveArchivedPrdsDirs(90, { projectsDir });
|
|
65
|
+
expect(dirs).toContain(path.resolve(epicArchiveDir));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('deriveEpicIdFromPrdPath resolves epicId for a file under prds-archived/, not just prds/', async () => {
|
|
69
|
+
const projectCwd = await mkEpicProject();
|
|
70
|
+
tmpDirs.push(projectCwd);
|
|
71
|
+
|
|
72
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-42');
|
|
73
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
74
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const archivedFile = path.join(epicArchiveDir, '917-example.md');
|
|
77
|
+
fs.writeFileSync(archivedFile, '---\ntitle: example\n---\n\n# Goal\n');
|
|
78
|
+
|
|
79
|
+
expect(deriveEpicIdFromPrdPath(archivedFile)).toBe('epic-42');
|
|
80
|
+
// Still works for the live dir too — no regression.
|
|
81
|
+
const liveFile = path.join(epicPrdsDir, '918-example.md');
|
|
82
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
83
|
+
fs.writeFileSync(liveFile, '---\ntitle: example\n---\n\n# Goal\n');
|
|
84
|
+
expect(deriveEpicIdFromPrdPath(liveFile)).toBe('epic-42');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('deriveEpicIdFromPrdPath returns null for an unrelated dir name', () => {
|
|
88
|
+
expect(deriveEpicIdFromPrdPath('/some/random/dir/917-example.md')).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('resolvePrdsDirs + resolveArchivedPrdsDirs together surface a pending PRD dir and an archived PRD dir for the same Epic, with no overlap', async () => {
|
|
92
|
+
const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-both-projects-'));
|
|
93
|
+
tmpDirs.push(projectsDir);
|
|
94
|
+
const projectCwd = await mkEpicProject();
|
|
95
|
+
tmpDirs.push(projectCwd);
|
|
96
|
+
|
|
97
|
+
const epicId = `test-epic-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
98
|
+
const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, epicId);
|
|
99
|
+
const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
|
|
100
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
101
|
+
fs.mkdirSync(epicArchiveDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const pendingSlug = `926-pending-${process.pid}`;
|
|
104
|
+
const archivedSlug = `917-archived-${process.pid}`;
|
|
105
|
+
fs.writeFileSync(path.join(epicPrdsDir, `${pendingSlug}.md`), `---\ntitle: pending\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
|
|
106
|
+
fs.writeFileSync(path.join(epicArchiveDir, `${archivedSlug}.md`), `---\ntitle: archived\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
|
|
107
|
+
|
|
108
|
+
const projDir = path.join(projectsDir, 'both-project');
|
|
109
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
110
|
+
fs.writeFileSync(path.join(projDir, 'session1.jsonl'), `${JSON.stringify({ cwd: projectCwd })}\n`);
|
|
111
|
+
|
|
112
|
+
const liveDirs = resolvePrdsDirs(90, { projectsDir });
|
|
113
|
+
const archivedDirs = resolveArchivedPrdsDirs(90, { projectsDir });
|
|
114
|
+
expect(liveDirs).toContain(epicPrdsDir);
|
|
115
|
+
expect(archivedDirs.map((d) => path.resolve(d))).toContain(path.resolve(epicArchiveDir));
|
|
116
|
+
|
|
117
|
+
// No overlap between the two dir sets for this Epic — an archived PRD's
|
|
118
|
+
// dir is never also scanned as a live one, and vice versa.
|
|
119
|
+
expect(liveDirs).not.toContain(epicArchiveDir);
|
|
120
|
+
expect(archivedDirs).not.toContain(epicPrdsDir);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('resolveArchivedPrdStatus prefers the live queue.json row over history', () => {
|
|
124
|
+
const liveStatusBySlug = new Map([['917-slug', 'failed']]);
|
|
125
|
+
const histBySlug = new Map([['917-slug', { status: 'completed' }]]);
|
|
126
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('resolveArchivedPrdStatus falls back to history.jsonl when there is no live row', () => {
|
|
130
|
+
const liveStatusBySlug = new Map();
|
|
131
|
+
const histBySlug = new Map([['917-slug', { status: 'failed' }]]);
|
|
132
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('resolveArchivedPrdStatus defaults to completed when neither source has the slug', () => {
|
|
136
|
+
expect(resolveArchivedPrdStatus('917-slug', new Map(), new Map())).toBe('completed');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('resolveArchivedPrdStatus normalizes any non-failed status (e.g. needs_review) to completed', () => {
|
|
140
|
+
const liveStatusBySlug = new Map([['917-slug', 'needs_review']]);
|
|
141
|
+
expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, new Map())).toBe('completed');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('scheduler.cjs exports candidateArchivedPrdsDirs alongside candidatePrdsDirs', () => {
|
|
145
|
+
expect(typeof candidatePrdsDirs).toBe('function');
|
|
146
|
+
expect(typeof candidateArchivedPrdsDirs).toBe('function');
|
|
147
|
+
// Both scan the real machine's projects — just assert they run without
|
|
148
|
+
// throwing and return arrays; per-Epic coverage is exercised above via
|
|
149
|
+
// the opts-injectable prdLocations functions directly.
|
|
150
|
+
expect(Array.isArray(candidatePrdsDirs())).toBe(true);
|
|
151
|
+
expect(Array.isArray(candidateArchivedPrdsDirs())).toBe(true);
|
|
152
|
+
});
|
|
@@ -105,6 +105,11 @@ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy d
|
|
|
105
105
|
|
|
106
106
|
test('migratePrds expands a tilde-prefixed cwd and moves the file', async () => {
|
|
107
107
|
const legacyDir = await mkLegacyDir();
|
|
108
|
+
// Deliberately rooted under the REAL os.homedir() (not os.tmpdir()) — the
|
|
109
|
+
// assertion under test is tilde-expansion relative to the actual home dir,
|
|
110
|
+
// so a real homedir path is load-bearing here, not incidental. Still safe:
|
|
111
|
+
// the random suffix is never a registered project the live scheduler scans
|
|
112
|
+
// for PRDs, and it's removed in afterEach via tmpDirs/fsp.rm.
|
|
108
113
|
const projectCwd = await fsp.mkdtemp(path.join(os.homedir(), '.sm-prdmigration-tilde-'));
|
|
109
114
|
tmpDirs.push(projectCwd);
|
|
110
115
|
const tildeCwd = path.join('~', path.relative(os.homedir(), projectCwd));
|
|
@@ -6,20 +6,53 @@
|
|
|
6
6
|
* `<cwd>/session-manager-operations/scheduler/prds/`) must still resolve,
|
|
7
7
|
* since findPrdDir's candidatePrdsDirs() searches the legacy dir first.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* os.homedir()
|
|
11
|
-
*
|
|
9
|
+
* HOME-isolated (PRD 812 incident fix): scheduler.cjs derives PRDS_DIR from
|
|
10
|
+
* os.homedir() at module load. This test used to write straight into the
|
|
11
|
+
* REAL `~/.claude/session-manager/scheduled-plans/prds/` — the live
|
|
12
|
+
* scheduler's `reconcile()` scans that dir every 60s and enqueued the
|
|
13
|
+
* fixture as a phantom job (job 812-test-legacy-only-1975929-233060) before
|
|
14
|
+
* this test's own cleanup ran. Now HOME is stubbed to a mkdtemp dir BEFORE
|
|
15
|
+
* scheduler.cjs is first required, so PRDS_DIR resolves under the temp dir
|
|
16
|
+
* and nothing here can reach the real scheduler root. The
|
|
17
|
+
* `PRDS_DIR.startsWith(tmpHome)` guard is the regression test for that
|
|
18
|
+
* incident: if module-load ordering ever changes so the real homedir gets
|
|
19
|
+
* snapshotted first, this throws loudly instead of silently poisoning the
|
|
20
|
+
* live queue again.
|
|
12
21
|
*
|
|
13
22
|
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-find-prd-dir.test.cjs
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
'use strict';
|
|
17
26
|
|
|
18
|
-
import { test, expect } from 'vitest';
|
|
27
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
19
28
|
const fs = require('node:fs');
|
|
20
29
|
const os = require('node:os');
|
|
21
30
|
const path = require('node:path');
|
|
22
|
-
|
|
31
|
+
|
|
32
|
+
let tmpHome;
|
|
33
|
+
let originalHome;
|
|
34
|
+
let findPrdDir;
|
|
35
|
+
let PRDS_DIR;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
originalHome = process.env.HOME;
|
|
39
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-find-prd-dir-'));
|
|
40
|
+
process.env.HOME = tmpHome;
|
|
41
|
+
|
|
42
|
+
// Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
|
|
43
|
+
// PRDS_DIR at module load, not lazily.
|
|
44
|
+
({ findPrdDir, PRDS_DIR } = require('../scheduler.cjs'));
|
|
45
|
+
|
|
46
|
+
// Hard guard: never let this test write into the real scheduler root.
|
|
47
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
48
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
process.env.HOME = originalHome;
|
|
54
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
55
|
+
});
|
|
23
56
|
|
|
24
57
|
test('findPrdDir resolves a slug that exists only in the legacy global PRDs dir', async () => {
|
|
25
58
|
const slug = `812-test-legacy-only-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-prd-missing-skip.test.cjs — regression test for PRD 812's
|
|
3
|
+
* failure-classification fix: a queued job whose PRD source has disappeared
|
|
4
|
+
* between enqueue and dispatch (e.g. a test fixture that leaked into the
|
|
5
|
+
* live queue and was cleaned up before the job ran) must be retired as a
|
|
6
|
+
* stale row (exitCode: 0, skipped: 'prd-missing'), not treated as a hard
|
|
7
|
+
* job failure (exitCode: -1). A genuinely unreadable/unparseable PRD (a
|
|
8
|
+
* real, non-ENOENT error) must still fail with exitCode: -1.
|
|
9
|
+
*
|
|
10
|
+
* HOME-isolated per the same incident: stub HOME to a mkdtemp dir before
|
|
11
|
+
* requiring scheduler.cjs, and never write into the real scheduler root.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-missing-skip.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let executeJob;
|
|
26
|
+
let PRDS_DIR;
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
originalHome = process.env.HOME;
|
|
30
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
|
|
31
|
+
process.env.HOME = tmpHome;
|
|
32
|
+
|
|
33
|
+
({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
|
+
|
|
35
|
+
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
36
|
+
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
process.env.HOME = originalHome;
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a job whose PRD source is gone everywhere is retired, not failed', async () => {
|
|
46
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-project-'));
|
|
47
|
+
// config.cjs's writeJsonSync (used for the job's meta.json) restricts
|
|
48
|
+
// writes to WRITE_PREFIXES, which includes `<home>/.claude` — so runDir
|
|
49
|
+
// must live under the stubbed tmpHome's .claude/, not the system tmpdir.
|
|
50
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
51
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-run-'));
|
|
52
|
+
try {
|
|
53
|
+
const slug = `812-test-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
54
|
+
const job = { slug, cwd: projectCwd };
|
|
55
|
+
|
|
56
|
+
const result = await executeJob(job, runDir, projectCwd, () => {});
|
|
57
|
+
|
|
58
|
+
expect(result.exitCode).toBe(0);
|
|
59
|
+
expect(result.skipped).toBe('prd-missing');
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
62
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a job whose PRD source exists but is unreadable for a non-ENOENT reason still fails', async () => {
|
|
67
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-toobig-project-'));
|
|
68
|
+
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
69
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-toobig-run-'));
|
|
70
|
+
try {
|
|
71
|
+
const slug = `812-test-toobig-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
72
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
73
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
74
|
+
// parsePrd rejects any PRD over 1MB with a plain Error (no .code) — this
|
|
75
|
+
// is the "found but unparseable" case, distinct from ENOENT, and must
|
|
76
|
+
// stay a real failure.
|
|
77
|
+
const oversized = '---\ntitle: too big\n---\n\n' + 'x'.repeat(1024 * 1024 + 1);
|
|
78
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), oversized, 'utf8');
|
|
79
|
+
|
|
80
|
+
const job = { slug, cwd: projectCwd };
|
|
81
|
+
const result = await executeJob(job, runDir, projectCwd, () => {});
|
|
82
|
+
|
|
83
|
+
expect(result.exitCode).toBe(-1);
|
|
84
|
+
expect(result.skipped).toBeUndefined();
|
|
85
|
+
} finally {
|
|
86
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
87
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reap-dead-running-jobs.test.cjs — regression cover for PRD 935
|
|
3
|
+
* (reapDeadRunningJobs runningSet desync).
|
|
4
|
+
*
|
|
5
|
+
* spawnJob()'s completion path can write terminal run artifacts to disk and
|
|
6
|
+
* have its child process fully exit, yet still throw between executeJob()
|
|
7
|
+
* resolving and the completion mutate() finishing (e.g. writeQueue's
|
|
8
|
+
* unreadable guard). That throw is swallowed by spawnJob()'s catch, but its
|
|
9
|
+
* `finally` unconditionally deletes the job's slug from the in-memory
|
|
10
|
+
* runningSet — leaving queue.json stuck at status:"running" with a dead pid
|
|
11
|
+
* while runningSet no longer names it. reapDeadRunningJobs() used to gate its
|
|
12
|
+
* entire body on `runningSet.size === 0`, so this exact state made it return
|
|
13
|
+
* before ever reading queue.json — permanently invisible to reconciliation.
|
|
14
|
+
*
|
|
15
|
+
* This test reproduces that desync directly: a project queue.json job row
|
|
16
|
+
* with status:"running" and a dead pid, while runningSet (freshly loaded,
|
|
17
|
+
* never populated) does not contain its slug. It asserts the job still gets
|
|
18
|
+
* reconciled to a terminal status.
|
|
19
|
+
*
|
|
20
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, since every
|
|
21
|
+
* path this code touches (queueStore's MACHINE_STATE_PATH, activeSessions'
|
|
22
|
+
* project scan root, scheduler.cjs's ROOT/RUNS_DIR, schedulerBatch's
|
|
23
|
+
* DEFAULT_PROJECT_CWD) is baked into a top-level const from os.homedir() at
|
|
24
|
+
* require time — this test must never be able to read or write real state.
|
|
25
|
+
*
|
|
26
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const assert = require('node:assert/strict');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const os = require('node:os');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
|
|
37
|
+
process.env.HOME = tmpHome;
|
|
38
|
+
|
|
39
|
+
const { reapDeadRunningJobs } = require('../scheduler.cjs');
|
|
40
|
+
|
|
41
|
+
function registerActiveProject(cwd) {
|
|
42
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
43
|
+
const slugDir = path.join(projectsDir, 'fake-project-slug');
|
|
44
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeProjectQueue(cwd, jobs) {
|
|
49
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
50
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
52
|
+
return path.join(stateDir, 'queue.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRunLog(runId, slug, lines) {
|
|
56
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
57
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running with a dead pid, even when runningSet does not name its slug (desync repro)', async () => {
|
|
62
|
+
const projectCwd = path.join(tmpHome, 'a-project');
|
|
63
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
64
|
+
registerActiveProject(projectCwd);
|
|
65
|
+
|
|
66
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
67
|
+
{
|
|
68
|
+
slug: 'desynced-job',
|
|
69
|
+
status: 'running',
|
|
70
|
+
cwd: projectCwd,
|
|
71
|
+
runId: 'run-desynced',
|
|
72
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
writeRunLog('run-desynced', 'desynced-job', [
|
|
76
|
+
'{"type":"result","subtype":"success","result":"done","is_error":false}',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// runningSet is a fresh module-level Set with nothing in it — this is the
|
|
80
|
+
// exact desynced state spawnJob()'s finally block can leave behind.
|
|
81
|
+
await reapDeadRunningJobs();
|
|
82
|
+
|
|
83
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
84
|
+
assert.equal(jobs.length, 1);
|
|
85
|
+
assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
|
|
86
|
+
assert.equal(jobs[0].exitCode, 0);
|
|
87
|
+
assert.equal(jobs[0].runtime, undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
|
|
91
|
+
const projectCwd = path.join(tmpHome, 'b-project');
|
|
92
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
93
|
+
registerActiveProject(projectCwd);
|
|
94
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
95
|
+
{ slug: 'already-done', status: 'completed', cwd: projectCwd, exitCode: 0 },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
await assert.doesNotReject(() => reapDeadRunningJobs());
|
|
99
|
+
|
|
100
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
101
|
+
assert.equal(jobs.length, 1);
|
|
102
|
+
assert.equal(jobs[0].status, 'completed');
|
|
103
|
+
});
|
|
@@ -6,6 +6,14 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
+
// HOME stubbed BEFORE requiring scheduler.cjs (PRD 812 fix): the last
|
|
10
|
+
// describe block below calls scheduler.reconcile(), which internally calls
|
|
11
|
+
// ensureDirs() → fs.mkdirSync on PRDS_DIR/RUNS_DIR — paths baked from
|
|
12
|
+
// os.homedir() at require time. Without this stub that mkdirSync lands in
|
|
13
|
+
// the real `~/.claude/session-manager/scheduled-plans/` tree.
|
|
14
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-home-'));
|
|
15
|
+
process.env.HOME = tmpHome;
|
|
16
|
+
|
|
9
17
|
const prdParser = require('../scheduler/prdParser.cjs');
|
|
10
18
|
const { pickForProject } = require('../lib/schedulerBatch.cjs');
|
|
11
19
|
|
package/src/main/index.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
28
28
|
const scheduler = require('./scheduler.cjs');
|
|
29
29
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
30
30
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
31
|
+
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
31
32
|
const chatRunner = require('./chatRunner.cjs');
|
|
32
33
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
33
34
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
@@ -58,6 +59,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
|
|
|
58
59
|
const memoryTool = require('./memoryTool.cjs');
|
|
59
60
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
60
61
|
const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
62
|
+
const { registerProjectPagesIpc } = require('./projectPages.cjs');
|
|
61
63
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
62
64
|
const agentMemory = require('./agentMemory.cjs');
|
|
63
65
|
const git = require('./git.cjs');
|
|
@@ -789,6 +791,7 @@ pluginInstall.registerPluginInstallHandlers();
|
|
|
789
791
|
memoryTool.registerMemoryHandlers();
|
|
790
792
|
registerMemoryAggregateIpc();
|
|
791
793
|
registerProjectBriefIpc();
|
|
794
|
+
registerProjectPagesIpc();
|
|
792
795
|
ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
|
|
793
796
|
const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
|
|
794
797
|
return { ok };
|
|
@@ -797,6 +800,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
|
|
|
797
800
|
const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
|
|
798
801
|
return { turns };
|
|
799
802
|
}));
|
|
803
|
+
// Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
|
|
804
|
+
// Kind allowlist enforced by schemas.auditLogAppend, not here.
|
|
805
|
+
ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
|
|
806
|
+
appendAuditEvent(kind, { cwd, epicId, source });
|
|
807
|
+
return { ok: true };
|
|
808
|
+
}));
|
|
800
809
|
agentMemory.registerAgentMemoryHandlers();
|
|
801
810
|
git.register(ipcMain);
|
|
802
811
|
filesIpc.registerFilesHandlers();
|