claude-code-session-manager 0.52.0 → 0.54.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-CQtxxn1C.js → TiptapBody-B91BqJWI.js} +1 -1
- package/dist/assets/{index-CKP60_3d.css → index-BEyUC3v9.css} +1 -1
- package/dist/assets/{index-hNso_ykJ.js → index-BYOscSQK.js} +869 -866
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/activeIndexMerge.test.cjs +235 -0
- package/src/main/__tests__/epicMint.test.cjs +113 -3
- package/src/main/__tests__/prdCreate.test.cjs +6 -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__/uniquePrdNumbers.test.cjs +8 -0
- package/src/main/index.cjs +9 -0
- package/src/main/ipcSchemas.cjs +54 -0
- package/src/main/lib/activeIndexMerge.cjs +168 -0
- package/src/main/lib/epicMint.cjs +63 -16
- package/src/main/promptSessionEvents.cjs +7 -24
- package/src/main/scheduler.cjs +41 -12
- package/src/preload/api.d.ts +28 -0
- package/src/preload/index.cjs +8 -0
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-BYOscSQK.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-BEyUC3v9.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.54.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",
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* activeIndexMerge.test.cjs — unit tests for the main-process read-merge-write
|
|
3
|
+
* of a cwd's prompt-sessions/active-index.json (lib/activeIndexMerge.cjs),
|
|
4
|
+
* moved here from the renderer (state/promptSessions.ts's old
|
|
5
|
+
* persistActiveIndex, commit 3d12e19) so the merge runs inside the same
|
|
6
|
+
* withPathLock instance epicMint.cjs's ensureEpic already serializes through.
|
|
7
|
+
*
|
|
8
|
+
* Covers the two holes a purely-renderer merge left open:
|
|
9
|
+
* - resurrection: a tombstoned id must never be written back by a later
|
|
10
|
+
* merge call whose memory contribution still thinks it's open.
|
|
11
|
+
* - TOCTOU: a mint (ensureEpic) interleaved with a merge call must survive,
|
|
12
|
+
* since both now go through the same lock.
|
|
13
|
+
*
|
|
14
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/activeIndexMerge.test.cjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
import { test, expect, afterEach } from 'vitest';
|
|
20
|
+
const fsp = require('node:fs/promises');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { mergeActiveIndex } = require('../lib/activeIndexMerge.cjs');
|
|
24
|
+
const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
|
|
25
|
+
const { appendResponseEventIfKnown } = require('../promptSessionEvents.cjs');
|
|
26
|
+
const config = require('../config.cjs');
|
|
27
|
+
|
|
28
|
+
const tmpDirs = [];
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
while (tmpDirs.length) {
|
|
31
|
+
const d = tmpDirs.pop();
|
|
32
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function mkCwd() {
|
|
37
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-activeindexmerge-cwd-'));
|
|
38
|
+
// config.cjs's readJson/writeJson restrict to registered roots (home dir by
|
|
39
|
+
// default) — register this tmp dir so the merge's config.readJson/writeJson
|
|
40
|
+
// calls don't reject it as outside the allowed boundary.
|
|
41
|
+
config.addAllowedRoot(cwd);
|
|
42
|
+
tmpDirs.push(cwd);
|
|
43
|
+
return cwd;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function session(id, overrides = {}) {
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
cwd: overrides.cwd || '/proj',
|
|
50
|
+
goalText: overrides.goalText || `goal for ${id}`,
|
|
51
|
+
claudeSessionId: overrides.claudeSessionId || `claude-${id}`,
|
|
52
|
+
status: overrides.status || 'active',
|
|
53
|
+
createdAt: overrides.createdAt || '2026-08-02T13:48:00.000Z',
|
|
54
|
+
completedAt: overrides.completedAt ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function promptEvent(id, promptSessionId, at) {
|
|
59
|
+
return { id, promptSessionId, kind: 'prompt', causedByEventId: null, at, text: 'x' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Incident 2026-08-02: two Epics created by another (already-running) window
|
|
63
|
+
// sat on disk. A second window booted empty and, on its first mutation,
|
|
64
|
+
// persisted its own (empty-of-those-two) contribution straight over the file.
|
|
65
|
+
// The main-side merge must still preserve every pre-existing on-disk Epic.
|
|
66
|
+
test('a fresh caller contributing one Epic preserves pre-existing on-disk Epics untouched', async () => {
|
|
67
|
+
const cwd = await mkCwd();
|
|
68
|
+
const other = session('psess-other');
|
|
69
|
+
await mergeActiveIndex(cwd, {
|
|
70
|
+
sessions: { [other.id]: other },
|
|
71
|
+
events: { [other.id]: [promptEvent('evt-1', other.id, other.createdAt)] },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const fresh = session('psess-fresh', { createdAt: '2026-08-02T13:50:00.000Z' });
|
|
75
|
+
await mergeActiveIndex(cwd, {
|
|
76
|
+
sessions: { [fresh.id]: fresh },
|
|
77
|
+
events: { [fresh.id]: [promptEvent('evt-2', fresh.id, fresh.createdAt)] },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const index = readActiveIndex(cwd);
|
|
81
|
+
expect(index.sessions[other.id]).toEqual(other);
|
|
82
|
+
expect(index.sessions[fresh.id]).toEqual(fresh);
|
|
83
|
+
expect(index.events[other.id]).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('memory wins over disk on id collision', async () => {
|
|
87
|
+
const cwd = await mkCwd();
|
|
88
|
+
const original = session('psess-1', { goalText: 'Fresh title' });
|
|
89
|
+
await mergeActiveIndex(cwd, { sessions: { [original.id]: original }, events: {} });
|
|
90
|
+
|
|
91
|
+
const updated = { ...original, goalText: 'Updated title' };
|
|
92
|
+
await mergeActiveIndex(cwd, { sessions: { [updated.id]: updated }, events: {} });
|
|
93
|
+
|
|
94
|
+
const index = readActiveIndex(cwd);
|
|
95
|
+
expect(index.sessions[original.id].goalText).toBe('Updated title');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('removedIds drops the row from the merged write and records a removal tombstone', async () => {
|
|
99
|
+
const cwd = await mkCwd();
|
|
100
|
+
const s = session('psess-1');
|
|
101
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
|
|
102
|
+
|
|
103
|
+
await mergeActiveIndex(cwd, { sessions: {}, events: {}, removedIds: [s.id] });
|
|
104
|
+
|
|
105
|
+
const index = readActiveIndex(cwd);
|
|
106
|
+
expect(index.sessions[s.id]).toBeUndefined();
|
|
107
|
+
expect(index.tombstones[s.id]).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The resurrection hole this PRD closes: two simulated windows. Window A
|
|
111
|
+
// completes (markCompleted) and persists the removal. Window B never
|
|
112
|
+
// hydrated that removal — its in-memory snapshot still holds the Epic as
|
|
113
|
+
// 'active' — and persists afterward. The tombstone must win: the Epic stays
|
|
114
|
+
// gone, not resurrected.
|
|
115
|
+
test('regression: a later merge whose memory still holds a removed Epic as active does not resurrect it', async () => {
|
|
116
|
+
const cwd = await mkCwd();
|
|
117
|
+
const s = session('psess-1', { status: 'active' });
|
|
118
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] } });
|
|
119
|
+
|
|
120
|
+
// Window A: markCompleted removes it.
|
|
121
|
+
await mergeActiveIndex(cwd, { sessions: {}, events: {}, removedIds: [s.id] });
|
|
122
|
+
expect(readActiveIndex(cwd).sessions[s.id]).toBeUndefined();
|
|
123
|
+
|
|
124
|
+
// Window B: stale in-memory snapshot still has it as 'active', persists
|
|
125
|
+
// its own (unrelated) mutation — a resurrection-prone merge.
|
|
126
|
+
const windowBOther = session('psess-2', { status: 'proposed' });
|
|
127
|
+
await mergeActiveIndex(cwd, {
|
|
128
|
+
sessions: { [s.id]: s, [windowBOther.id]: windowBOther },
|
|
129
|
+
events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const index = readActiveIndex(cwd);
|
|
133
|
+
expect(index.sessions[s.id]).toBeUndefined();
|
|
134
|
+
expect(index.events[s.id]).toBeUndefined();
|
|
135
|
+
expect(index.sessions[windowBOther.id]).toEqual(windowBOther);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('event union: disk-only events are appended and re-sorted chain-aware; memory-only events kept', async () => {
|
|
139
|
+
const cwd = await mkCwd();
|
|
140
|
+
const s = session('psess-1');
|
|
141
|
+
const e1 = promptEvent('e1', s.id, '2026-08-02T13:00:00.000Z');
|
|
142
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [e1] } });
|
|
143
|
+
|
|
144
|
+
// A scheduler job appended a response event directly to disk between our
|
|
145
|
+
// two merge calls (simulated by mutating the file out of band).
|
|
146
|
+
const midIndex = readActiveIndex(cwd);
|
|
147
|
+
const schedulerEvent = { id: 'e2', promptSessionId: s.id, kind: 'response', causedByEventId: 'e1', at: '2026-08-02T13:05:00.000Z', text: 'done' };
|
|
148
|
+
midIndex.events[s.id] = [e1, schedulerEvent];
|
|
149
|
+
await config.writeJson(path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'), midIndex, { writer: 'epics' });
|
|
150
|
+
|
|
151
|
+
// This caller's memory still only knows about e1 plus its own optimistic
|
|
152
|
+
// append e3 (not yet on disk).
|
|
153
|
+
const e3 = { id: 'e3', promptSessionId: s.id, kind: 'prd_created', causedByEventId: 'e2', at: '2026-08-02T13:10:00.000Z', prdSlug: 'x' };
|
|
154
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [e1, e3] } });
|
|
155
|
+
|
|
156
|
+
const finalEvents = readActiveIndex(cwd).events[s.id];
|
|
157
|
+
expect(finalEvents.map((e) => e.id)).toEqual(['e1', 'e2', 'e3']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Electron's structured-clone IPC transport (unlike JSON.parse) can produce
|
|
161
|
+
// an own-enumerable "__proto__" key on a cloned object. A plain-object merge
|
|
162
|
+
// target's bracket assignment with that key would re-link its own prototype
|
|
163
|
+
// instead of storing a normal entry, silently dropping/corrupting the Epic
|
|
164
|
+
// and potentially breaking every later Object.keys/JSON.stringify over it.
|
|
165
|
+
test('a "__proto__"-keyed session id merges as an ordinary entry, not a prototype pollution', async () => {
|
|
166
|
+
const cwd = await mkCwd();
|
|
167
|
+
const polluted = session('__proto__', { goalText: 'polluted' });
|
|
168
|
+
// Computed key, not `{ __proto__: polluted }` — the latter is special-cased
|
|
169
|
+
// by object-literal syntax to set the prototype rather than create an own
|
|
170
|
+
// enumerable "__proto__" property, which wouldn't exercise this hazard.
|
|
171
|
+
await mergeActiveIndex(cwd, { sessions: { ['__proto__']: polluted }, events: {} });
|
|
172
|
+
|
|
173
|
+
const index = readActiveIndex(cwd);
|
|
174
|
+
expect(Object.prototype.hasOwnProperty.call(index.sessions, '__proto__')).toBe(true);
|
|
175
|
+
expect(index.sessions.__proto__).toEqual(polluted);
|
|
176
|
+
expect(Object.getPrototypeOf({})).toBe(Object.prototype);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('falls back to writing memory state alone when the disk index is corrupt JSON', async () => {
|
|
180
|
+
const cwd = await mkCwd();
|
|
181
|
+
const filePath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
182
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
183
|
+
await fsp.writeFile(filePath, '{ not valid json', 'utf8');
|
|
184
|
+
|
|
185
|
+
const s = session('psess-1');
|
|
186
|
+
await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
|
|
187
|
+
|
|
188
|
+
const index = readActiveIndex(cwd);
|
|
189
|
+
expect(index.sessions[s.id]).toEqual(s);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// TOCTOU: a mint interleaved with a merge call must survive, since both now
|
|
193
|
+
// serialize through the same withPathLock (epicMint.cjs exports it; this
|
|
194
|
+
// module requires it via the same module — Node's require cache means one
|
|
195
|
+
// lock instance, not two independent maps that could still interleave).
|
|
196
|
+
test('mint-during-persist: an ensureEpic mint interleaved with a merge call survives', async () => {
|
|
197
|
+
const cwd = await mkCwd();
|
|
198
|
+
const existing = session('psess-existing');
|
|
199
|
+
await mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} });
|
|
200
|
+
|
|
201
|
+
const [mintResult] = await Promise.all([
|
|
202
|
+
ensureEpic(cwd, { goalText: 'minted during a concurrent merge' }),
|
|
203
|
+
mergeActiveIndex(cwd, { sessions: { [existing.id]: existing }, events: {} }),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const index = readActiveIndex(cwd);
|
|
207
|
+
expect(index.sessions[mintResult.epicId]).toBeDefined();
|
|
208
|
+
expect(index.sessions[existing.id]).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// The third writer: promptSessionEvents.cjs's appendResponseEventIfKnown
|
|
212
|
+
// (the scheduler's narrow 'scheduler'-delegation append) used to hold its
|
|
213
|
+
// OWN independent lock map — a merge call and a response-event append could
|
|
214
|
+
// still race past each other's stale read. It now imports activeIndexPath/
|
|
215
|
+
// withPathLock from epicMint.cjs (same module, same require-cache instance),
|
|
216
|
+
// so a response-event append interleaved with a merge call must survive too.
|
|
217
|
+
test('a scheduler response-event append interleaved with a merge call survives (third writer shares the same lock)', async () => {
|
|
218
|
+
const cwd = await mkCwd();
|
|
219
|
+
const s = session('psess-1', { status: 'active' });
|
|
220
|
+
await mergeActiveIndex(cwd, {
|
|
221
|
+
sessions: { [s.id]: s },
|
|
222
|
+
events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const [, appended] = await Promise.all([
|
|
226
|
+
mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: { [s.id]: [promptEvent('e1', s.id, s.createdAt)] } }),
|
|
227
|
+
appendResponseEventIfKnown(cwd, s.id, 'PRD finished'),
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
expect(appended).toBe(true);
|
|
231
|
+
const index = readActiveIndex(cwd);
|
|
232
|
+
expect(index.sessions[s.id]).toBeDefined();
|
|
233
|
+
expect(index.events[s.id]).toHaveLength(2);
|
|
234
|
+
expect(index.events[s.id][1].kind).toBe('response');
|
|
235
|
+
});
|
|
@@ -66,7 +66,7 @@ test('removeEpic deletes a minted Epic from both sessions and events maps', asyn
|
|
|
66
66
|
const minted = await ensureEpic(cwd, { goalText: 'to be rolled back' });
|
|
67
67
|
expect(readActiveIndex(cwd).sessions[minted.epicId]).toBeDefined();
|
|
68
68
|
|
|
69
|
-
const removed = removeEpic(cwd, minted.epicId);
|
|
69
|
+
const removed = await removeEpic(cwd, minted.epicId);
|
|
70
70
|
|
|
71
71
|
expect(removed).toBe(true);
|
|
72
72
|
const index = readActiveIndex(cwd);
|
|
@@ -78,7 +78,7 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
|
|
|
78
78
|
const cwd = await mkCwd();
|
|
79
79
|
await ensureEpic(cwd, { goalText: 'unrelated epic' });
|
|
80
80
|
|
|
81
|
-
const removed = removeEpic(cwd, 'nonexistent-epic-id');
|
|
81
|
+
const removed = await removeEpic(cwd, 'nonexistent-epic-id');
|
|
82
82
|
|
|
83
83
|
expect(removed).toBe(false);
|
|
84
84
|
expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
|
|
@@ -105,7 +105,7 @@ for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProper
|
|
|
105
105
|
|
|
106
106
|
test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
|
|
107
107
|
const cwd = await mkCwd();
|
|
108
|
-
const removed = removeEpic(cwd, '__proto__');
|
|
108
|
+
const removed = await removeEpic(cwd, '__proto__');
|
|
109
109
|
expect(removed).toBe(false);
|
|
110
110
|
});
|
|
111
111
|
|
|
@@ -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);
|
|
@@ -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
|
+
});
|
|
@@ -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,8 +28,10 @@ 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');
|
|
34
|
+
const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
|
|
33
35
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
34
36
|
const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
35
37
|
const adminHttp = createAdminHttp();
|
|
@@ -788,6 +790,7 @@ registerHistoryAggregatorHandlers();
|
|
|
788
790
|
registerHistoryDashboardHandlers();
|
|
789
791
|
pluginInstall.registerPluginInstallHandlers();
|
|
790
792
|
memoryTool.registerMemoryHandlers();
|
|
793
|
+
activeIndexMerge.registerActiveIndexMergeHandlers();
|
|
791
794
|
registerMemoryAggregateIpc();
|
|
792
795
|
registerProjectBriefIpc();
|
|
793
796
|
registerProjectPagesIpc();
|
|
@@ -799,6 +802,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
|
|
|
799
802
|
const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
|
|
800
803
|
return { turns };
|
|
801
804
|
}));
|
|
805
|
+
// Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
|
|
806
|
+
// Kind allowlist enforced by schemas.auditLogAppend, not here.
|
|
807
|
+
ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
|
|
808
|
+
appendAuditEvent(kind, { cwd, epicId, source });
|
|
809
|
+
return { ok: true };
|
|
810
|
+
}));
|
|
802
811
|
agentMemory.registerAgentMemoryHandlers();
|
|
803
812
|
git.register(ipcMain);
|
|
804
813
|
filesIpc.registerFilesHandlers();
|