claude-code-session-manager 0.65.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentLibrary-CiqimwWl.js +3 -0
- package/dist/assets/{History-DB-9-zwc.js → History-DbzjII2Z.js} +2 -2
- package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-C0PTplI_.js} +3 -3
- package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-vGc1FYLD.js} +1 -1
- package/dist/assets/{Library-BYSB0dmY.js → Library-4tU4rg2h.js} +1 -1
- package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-BGIvkCwZ.js} +1 -1
- package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-ChFdpCam.js} +1 -1
- package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-DsRWWpU3.js} +2 -2
- package/dist/assets/{Memory-DzPGXT5J.js → Memory-DmxoxxxY.js} +6 -6
- package/dist/assets/{Panel-BX6UZ-W0.js → Panel-KC-rv3jS.js} +1 -1
- package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-DrDjCRFl.js} +3 -3
- package/dist/assets/{Plugins-CIuey9R3.js → Plugins-KexSQwk0.js} +2 -2
- package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-Rg-jL94y.js} +1 -1
- package/dist/assets/SaveBar-BbEo8U2e.js +1 -0
- package/dist/assets/Scheduler-C4Ti9TCc.js +14 -0
- package/dist/assets/ScopeSwitcher-Di9BmYze.js +1 -0
- package/dist/assets/Settings-CxyhPuYQ.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-0q26RHrn.js} +1 -1
- package/dist/assets/Skills-BXkVyCo5.js +3 -0
- package/dist/assets/SystemPrompt-Bwe3NTbi.js +1 -0
- package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-CnwEf6EI.js} +1 -1
- package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-faCtLj9L.js} +1 -1
- package/dist/assets/{Toggle-D9sapSAv.js → Toggle-Bw7G_-RR.js} +1 -1
- package/dist/assets/{index-BRwaw_1W.js → index-B6dJ2CsU.js} +666 -665
- package/dist/assets/{index-LlWpj2VJ.css → index-CEnMgeQU.css} +1 -1
- package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-B5C9hZoS.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +73 -15
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
- package/src/main/__tests__/agentLibrary.test.cjs +40 -0
- package/src/main/__tests__/develop-skill-failure-modes.test.cjs +70 -0
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
- package/src/main/__tests__/health-per-project-stall.test.cjs +82 -0
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
- package/src/main/__tests__/prdCreate.test.cjs +7 -2
- package/src/main/__tests__/prdMigration.test.cjs +17 -0
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +108 -0
- package/src/main/agentLibrary.cjs +40 -2
- package/src/main/health.cjs +97 -2
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +60 -0
- package/src/main/lib/localAdminHttp.cjs +10 -3
- package/src/main/lib/prdAdminRoutes.cjs +175 -0
- package/src/main/lib/prdCreate.cjs +37 -2
- package/src/main/lib/prdFrontmatter.cjs +179 -1
- package/src/main/lib/prdMigration.cjs +82 -5
- package/src/main/lib/queueStore.cjs +41 -7
- package/src/main/lib/scheduleJobSchema.cjs +114 -0
- package/src/main/lib/scheduleJobTransitions.cjs +164 -0
- package/src/main/lib/schedulerConfig.cjs +7 -0
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +769 -138
- package/src/preload/api.d.ts +54 -2
- package/src/preload/index.cjs +12 -0
- package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
- package/dist/assets/Scheduler-X5y252Qw.js +0 -14
- package/dist/assets/ScopeSwitcher-DU7M_q5-.js +0 -1
- package/dist/assets/Settings-DEptXcEY.js +0 -3
- package/dist/assets/Skills-CGN56X1i.js +0 -3
- package/dist/assets/SystemPrompt-DIEK7FpJ.js +0 -1
|
@@ -57,6 +57,15 @@ function buildPrdBody(input) {
|
|
|
57
57
|
// filename prefix is the single source of truth for grouping; adding a
|
|
58
58
|
// second one here would let the two drift out of sync.
|
|
59
59
|
const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`, `estimateMinutes: ${estimateMinutes}`];
|
|
60
|
+
// Provenance stamp (PRD-authoring lockdown): every PRD written through this
|
|
61
|
+
// function — the sole in-process implementation behind both the admin
|
|
62
|
+
// HTTP create-prd route and the renderer's chat:create-prd IPC handler —
|
|
63
|
+
// carries proof it was authored via the API, not a bare Write/Edit tool
|
|
64
|
+
// call. scheduler.cjs's reconcile() refuses to run a discovered PRD with no
|
|
65
|
+
// `createdVia` (quarantines it instead); this line is why every
|
|
66
|
+
// API-created PRD is exempt. Stamped here, not left to a caller, so the
|
|
67
|
+
// guarantee can't be forgotten by a future route.
|
|
68
|
+
fmLines.push('createdVia: scheduler-api', `issuedAt: ${new Date().toISOString()}`);
|
|
60
69
|
// Optional, additive: traces this PRD back to the PromptTicket (PRD 748)
|
|
61
70
|
// that was classified 'develop' and spawned it (PRD 749).
|
|
62
71
|
if (sourcePromptId) fmLines.push(`sourcePromptId: ${sourcePromptId}`);
|
|
@@ -204,7 +213,24 @@ async function createPrd(input, remote) {
|
|
|
204
213
|
sourceTabId: input.sourceTabId ?? null,
|
|
205
214
|
});
|
|
206
215
|
|
|
207
|
-
|
|
216
|
+
// No `status` field here, and never one named 'queued' — that string was
|
|
217
|
+
// handed straight to callers as if it were a real ScheduleJob status, and
|
|
218
|
+
// is exactly how the 1021/1022 incident's invalid `"status": "queued"`
|
|
219
|
+
// rows got onto disk (this route's own response was the most likely
|
|
220
|
+
// source). A PRD is a queue row's SOURCE, not the row itself: writePrd
|
|
221
|
+
// only ever joins an Epic and writes the .md file — the queue row is
|
|
222
|
+
// created separately, by the next reconcile() pass. `enqueued: false` is
|
|
223
|
+
// true by construction (this function never touches queue.json), so no
|
|
224
|
+
// caller can copy a fake job status out of this response again.
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
nn,
|
|
228
|
+
filename: `${filenameSlug}.md`,
|
|
229
|
+
prdPath: writeResult.path ?? null,
|
|
230
|
+
epicId: writeResult.epicId ?? null,
|
|
231
|
+
enqueued: false,
|
|
232
|
+
note: 'PRD file written; the queue row is derived by the next scheduler reconcile pass, not created here',
|
|
233
|
+
};
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
/**
|
|
@@ -240,7 +266,16 @@ function registerAdminRoute(adminHttp, remote) {
|
|
|
240
266
|
return;
|
|
241
267
|
}
|
|
242
268
|
|
|
243
|
-
|
|
269
|
+
// Mirror createPrd()'s own honest shape verbatim — never re-add a
|
|
270
|
+
// 'status' field here. See createPrd's return above for why.
|
|
271
|
+
sendJson(res, 200, {
|
|
272
|
+
nn: result.nn,
|
|
273
|
+
filename: result.filename,
|
|
274
|
+
prdPath: result.prdPath ?? null,
|
|
275
|
+
epicId: result.epicId ?? null,
|
|
276
|
+
enqueued: false,
|
|
277
|
+
note: result.note,
|
|
278
|
+
});
|
|
244
279
|
});
|
|
245
280
|
}
|
|
246
281
|
|
|
@@ -48,4 +48,182 @@ function splitFrontmatter(raw) {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* parsePrdFile / serializePrdFile — CJS port of
|
|
53
|
+
* src/renderer/lib/prdFrontmatter.ts's round-trip parse+serialize pair, so
|
|
54
|
+
* main-process callers (the admin update-prd route, PRD 1024) get the same
|
|
55
|
+
* "unrecognized keys round-trip verbatim via `extras`, recognized keys emit
|
|
56
|
+
* in a stable order, unchanged values re-emit their original line verbatim"
|
|
57
|
+
* guarantee the renderer's PRD editor already relies on — an edit through
|
|
58
|
+
* either surface never reorders or drops a key it didn't touch. Recognized
|
|
59
|
+
* key set intentionally mirrors the TS module exactly (title, cwd,
|
|
60
|
+
* estimateMinutes, parallelGroup, sourcePromptId, sourceTabId, tag,
|
|
61
|
+
* createdVia, issuedAt); every other frontmatter key (e.g. dependsOn)
|
|
62
|
+
* round-trips via `extras`, same as the renderer's editor today.
|
|
63
|
+
*
|
|
64
|
+
* `createdVia`/`issuedAt` (PRD provenance-lockdown) are recognized here so
|
|
65
|
+
* the update-prd route (the only sanctioned way to stamp a PRD's provenance
|
|
66
|
+
* after creation — see prdCreate.cjs's buildPrdBody for the create-time
|
|
67
|
+
* stamp) round-trips them like any other known key rather than shunting them
|
|
68
|
+
* into `extras`.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'createdVia', 'issuedAt']);
|
|
72
|
+
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'createdVia', 'issuedAt'];
|
|
73
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
74
|
+
|
|
75
|
+
function indentOf(line) {
|
|
76
|
+
let n = 0;
|
|
77
|
+
for (const ch of line) {
|
|
78
|
+
if (ch === ' ') n++;
|
|
79
|
+
else if (ch === '\t') n += 2;
|
|
80
|
+
else break;
|
|
81
|
+
}
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseScalar(raw) {
|
|
86
|
+
const v = raw.trim();
|
|
87
|
+
if (v === '' || v === '~' || v === 'null') return null;
|
|
88
|
+
if (v === 'true') return true;
|
|
89
|
+
if (v === 'false') return false;
|
|
90
|
+
if (/^-?\d+$/.test(v)) return Number(v);
|
|
91
|
+
if (/^-?\d+\.\d+$/.test(v)) return Number(v);
|
|
92
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
93
|
+
return v.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function applyKey(fm, key, after) {
|
|
99
|
+
const v = parseScalar(after);
|
|
100
|
+
if (v === null) return;
|
|
101
|
+
switch (key) {
|
|
102
|
+
case 'estimateMinutes':
|
|
103
|
+
fm.estimateMinutes = typeof v === 'number' ? v : Number(v);
|
|
104
|
+
return;
|
|
105
|
+
case 'parallelGroup':
|
|
106
|
+
fm.parallelGroup = typeof v === 'number' ? v : Number(v);
|
|
107
|
+
return;
|
|
108
|
+
case 'title':
|
|
109
|
+
fm.title = String(v);
|
|
110
|
+
return;
|
|
111
|
+
case 'cwd':
|
|
112
|
+
fm.cwd = String(v);
|
|
113
|
+
return;
|
|
114
|
+
case 'sourcePromptId':
|
|
115
|
+
fm.sourcePromptId = String(v);
|
|
116
|
+
return;
|
|
117
|
+
case 'sourceTabId':
|
|
118
|
+
fm.sourceTabId = String(v);
|
|
119
|
+
return;
|
|
120
|
+
case 'tag':
|
|
121
|
+
// Matches the TS source's applyKey exactly: a 'discussion'-tagged
|
|
122
|
+
// ticket never reaches PRD authoring, so that value is never parsed
|
|
123
|
+
// here even if present in a hand-edited file — see this file's header
|
|
124
|
+
// comment.
|
|
125
|
+
if (v === 'feature' || v === 'bug' || v === 'build') fm.tag = v;
|
|
126
|
+
return;
|
|
127
|
+
case 'createdVia':
|
|
128
|
+
fm.createdVia = String(v);
|
|
129
|
+
return;
|
|
130
|
+
case 'issuedAt':
|
|
131
|
+
fm.issuedAt = String(v);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse a full PRD file into { frontmatter, body }, same shape as the TS module. */
|
|
137
|
+
function parsePrdFile(text) {
|
|
138
|
+
const m = text.match(FRONTMATTER_RE);
|
|
139
|
+
if (!m) return { frontmatter: {}, body: text };
|
|
140
|
+
const fmText = m[1];
|
|
141
|
+
const body = m[2] ?? '';
|
|
142
|
+
|
|
143
|
+
const lines = fmText.split(/\r?\n/);
|
|
144
|
+
const fm = {};
|
|
145
|
+
const extras = {};
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < lines.length;) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
if (!line.trim() || line.trimStart().startsWith('#')) { i++; continue; }
|
|
150
|
+
const ind = indentOf(line);
|
|
151
|
+
if (ind !== 0) { i++; continue; }
|
|
152
|
+
const colon = line.indexOf(':');
|
|
153
|
+
if (colon < 0) { i++; continue; }
|
|
154
|
+
const key = line.slice(0, colon).trim();
|
|
155
|
+
const after = line.slice(colon + 1);
|
|
156
|
+
let j = i + 1;
|
|
157
|
+
const band = [line];
|
|
158
|
+
while (j < lines.length) {
|
|
159
|
+
const nxt = lines[j];
|
|
160
|
+
if (nxt.trim() === '' || nxt.trimStart().startsWith('#')) { band.push(nxt); j++; continue; }
|
|
161
|
+
if (indentOf(nxt) === 0 && nxt.indexOf(':') >= 0) break;
|
|
162
|
+
band.push(nxt);
|
|
163
|
+
j++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (RECOGNIZED_KEYS.has(key)) {
|
|
167
|
+
applyKey(fm, key, after);
|
|
168
|
+
const parsed = parseScalar(after);
|
|
169
|
+
if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
|
|
170
|
+
if (!fm._raw) fm._raw = {};
|
|
171
|
+
fm._raw[key] = { line, parsed };
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
extras[key] = { lines: band };
|
|
175
|
+
}
|
|
176
|
+
i = j;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Object.keys(extras).length) fm.extras = extras;
|
|
180
|
+
return { frontmatter: fm, body };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function serializeScalar(v) {
|
|
184
|
+
if (v === null || v === undefined) return '';
|
|
185
|
+
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
|
186
|
+
const s = String(v);
|
|
187
|
+
const needsQuote =
|
|
188
|
+
s === '' ||
|
|
189
|
+
/^(true|false|null|~)$/.test(s) ||
|
|
190
|
+
/^-?\d+(\.\d+)?$/.test(s) ||
|
|
191
|
+
/^\s|\s$/.test(s) ||
|
|
192
|
+
s.startsWith("'") ||
|
|
193
|
+
s.startsWith('"') ||
|
|
194
|
+
s.includes('\n');
|
|
195
|
+
if (!needsQuote) return s;
|
|
196
|
+
if (s.includes("'")) return `"${s.replace(/"/g, '\\"')}"`;
|
|
197
|
+
return `'${s}'`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Serialize { frontmatter, body } back to a full file. Byte-identical round-trip when unchanged. */
|
|
201
|
+
function serializePrdFile(fm, body) {
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const key of EMIT_ORDER) {
|
|
204
|
+
const v = fm[key];
|
|
205
|
+
if (v === undefined || v === null || v === '') continue;
|
|
206
|
+
const cached = fm._raw?.[key];
|
|
207
|
+
if (cached && cached.parsed === v) {
|
|
208
|
+
out.push(cached.line);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (typeof v === 'number') {
|
|
212
|
+
out.push(`${key}: ${v}`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
out.push(`${key}: ${serializeScalar(v)}`);
|
|
216
|
+
}
|
|
217
|
+
if (fm.extras) {
|
|
218
|
+
for (const k of Object.keys(fm.extras)) {
|
|
219
|
+
const band = fm.extras[k];
|
|
220
|
+
if (!band || !band.lines.length) continue;
|
|
221
|
+
out.push(band.lines.join('\n'));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fmText = out.join('\n');
|
|
226
|
+
return `---\n${fmText}\n---\n${body}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { splitFrontmatter, parsePrdFile, serializePrdFile, RECOGNIZED_KEYS };
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const fsp = require('node:fs/promises');
|
|
18
18
|
const path = require('node:path');
|
|
19
|
-
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
20
|
-
const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
19
|
+
const { splitFrontmatter, parsePrdFile, serializePrdFile } = require('./prdFrontmatter.cjs');
|
|
20
|
+
const { resolvePrdWriteDir, resolvePrdsDirs } = require('./prdLocations.cjs');
|
|
21
21
|
const { projectQueuePath } = require('./queueStore.cjs');
|
|
22
22
|
const { expandHome } = require('./expandHome.cjs');
|
|
23
23
|
|
|
@@ -114,7 +114,21 @@ async function migratePrds(legacyPrdsDir) {
|
|
|
114
114
|
const LIVE_JOB_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
*
|
|
117
|
+
* Statuses that mean a job is genuinely finished and its source may be
|
|
118
|
+
* archived. Anything else — including an UNRECOGNIZED/invalid status string
|
|
119
|
+
* (e.g. the 2026-08-07 1021/1022 incident's `"status": "queued"`, which is
|
|
120
|
+
* outside ScheduleJobStatus entirely) — protects the file. reconcile() is
|
|
121
|
+
* what repairs a corrupted row back to `pending` (scheduleJobTransitions.cjs
|
|
122
|
+
* / the invalid-row-repair path); this consolidation must never race that
|
|
123
|
+
* repair by archiving the row's only source out from under it first. A slug
|
|
124
|
+
* with NO row at all (never queued, or already reaped from history) is not
|
|
125
|
+
* covered by this set and falls through to "archive" in liveSlugsForCwd.
|
|
126
|
+
*/
|
|
127
|
+
const TERMINAL_ARCHIVABLE_STATUSES = new Set(['completed', 'failed']);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Slugs with a live (i.e. not safely archivable) job in this project's own
|
|
131
|
+
* queue shard.
|
|
118
132
|
*
|
|
119
133
|
* Returns null when liveness cannot be determined (unreadable/unparseable
|
|
120
134
|
* queue.json) — the caller then FAILS CLOSED and archives nothing, since it
|
|
@@ -139,7 +153,7 @@ async function liveSlugsForCwd(cwd) {
|
|
|
139
153
|
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
140
154
|
const live = new Set();
|
|
141
155
|
for (const job of jobs) {
|
|
142
|
-
if (job && typeof job.slug === 'string' &&
|
|
156
|
+
if (job && typeof job.slug === 'string' && !TERMINAL_ARCHIVABLE_STATUSES.has(job.status)) {
|
|
143
157
|
live.add(job.slug);
|
|
144
158
|
}
|
|
145
159
|
}
|
|
@@ -199,4 +213,67 @@ async function consolidateFlatPrds(cwd, opts = {}) {
|
|
|
199
213
|
return { moved, failed, skipped };
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
|
|
216
|
+
/**
|
|
217
|
+
* legacyAdoptExistingPrds() — one-time (per file), idempotent rollout
|
|
218
|
+
* migration for the PRD-authoring-lockdown feature: every PRD source .md
|
|
219
|
+
* already on disk when this ships has no `createdVia` frontmatter (that
|
|
220
|
+
* field didn't exist yet), and reconcile()'s new provenance gate would
|
|
221
|
+
* otherwise quarantine every single one of them the very first boot after
|
|
222
|
+
* this lands. Runs at every boot (see scheduler.cjs's runPrdMigration,
|
|
223
|
+
* BEFORE reconcile() ever gets a chance to see these files) and stamps
|
|
224
|
+
* `createdVia: legacy-adopted` + `issuedAt: <this run's timestamp>` on any
|
|
225
|
+
* `.md` under a project's PRD dirs (flat + every Epic's, live only — never
|
|
226
|
+
* the retired prds-archived/ dirs, since an archived PRD isn't reconciled
|
|
227
|
+
* again and stamping it would just be needless churn) that doesn't already
|
|
228
|
+
* carry a `createdVia` value.
|
|
229
|
+
*
|
|
230
|
+
* Idempotent by construction: a file already stamped (by this migration on
|
|
231
|
+
* a prior boot, or by prdCreate.cjs at create time) is skipped outright, so
|
|
232
|
+
* a repeat run across every subsequent boot is a cheap scan-and-skip.
|
|
233
|
+
* Failures are per-file and non-fatal — one unreadable/unwritable PRD must
|
|
234
|
+
* never abort the sweep for every other project.
|
|
235
|
+
*/
|
|
236
|
+
async function legacyAdoptExistingPrds() {
|
|
237
|
+
let dirs;
|
|
238
|
+
try {
|
|
239
|
+
dirs = resolvePrdsDirs();
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { stamped: 0, failed: [{ file: '(resolvePrdsDirs)', reason: e?.message ?? 'failed to enumerate PRD dirs' }] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let stamped = 0;
|
|
245
|
+
const failed = [];
|
|
246
|
+
const issuedAt = new Date().toISOString();
|
|
247
|
+
|
|
248
|
+
for (const dir of dirs) {
|
|
249
|
+
let entries;
|
|
250
|
+
try {
|
|
251
|
+
entries = await fsp.readdir(dir);
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
for (const name of entries) {
|
|
256
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
257
|
+
const filePath = path.join(dir, name);
|
|
258
|
+
try {
|
|
259
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
260
|
+
const { frontmatter: fm, body } = parsePrdFile(raw);
|
|
261
|
+
if (fm.createdVia) continue; // already stamped — nothing to do
|
|
262
|
+
fm.createdVia = 'legacy-adopted';
|
|
263
|
+
fm.issuedAt = issuedAt;
|
|
264
|
+
const newRaw = serializePrdFile(fm, body);
|
|
265
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
266
|
+
await fsp.writeFile(tmp, newRaw, 'utf8');
|
|
267
|
+
await fsp.rename(tmp, filePath);
|
|
268
|
+
stamped += 1;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (e?.code === 'ENOENT') continue; // raced with a concurrent mover/archiver
|
|
271
|
+
failed.push({ file: filePath, reason: e?.message ?? 'stamp failed' });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { stamped, failed };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds, LIVE_JOB_STATUSES };
|
|
@@ -34,6 +34,7 @@ const path = require('node:path');
|
|
|
34
34
|
const os = require('node:os');
|
|
35
35
|
const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
|
|
36
36
|
const { assertOpsWrite } = require('./opsOwnership.cjs');
|
|
37
|
+
const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
|
|
37
38
|
|
|
38
39
|
const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
|
|
39
40
|
const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
|
|
@@ -110,9 +111,37 @@ function shapeMachine(raw) {
|
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
/**
|
|
115
|
+
* shapeJobs(raw, file) → { jobs, invalid }.
|
|
116
|
+
*
|
|
117
|
+
* Every row is validated against ScheduleJobSchema (see that module's header
|
|
118
|
+
* for why: a row with e.g. `status: 'queued'` — a value outside
|
|
119
|
+
* `ScheduleJobStatus` — silently vanished from every picker, which is the
|
|
120
|
+
* exact 2026-08-07 incident this validation exists to catch). A row that
|
|
121
|
+
* fails is quarantined into `invalid` (never dropped silently, never passed
|
|
122
|
+
* through as-is) and logged once per slug at error level naming the file,
|
|
123
|
+
* the slug, and the failing field. One bad row must not affect the others —
|
|
124
|
+
* this never throws.
|
|
125
|
+
*/
|
|
126
|
+
function shapeJobs(raw, file) {
|
|
114
127
|
const data = JSON.parse(raw);
|
|
115
|
-
|
|
128
|
+
const rows = Array.isArray(data.jobs) ? data.jobs : [];
|
|
129
|
+
const jobs = [];
|
|
130
|
+
const invalid = [];
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
const result = ScheduleJobSchema.safeParse(row);
|
|
133
|
+
if (result.success) {
|
|
134
|
+
jobs.push(result.data);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const issues = result.error.issues
|
|
138
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
139
|
+
.join('; ');
|
|
140
|
+
const slug = typeof row?.slug === 'string' ? row.slug : '(unknown slug)';
|
|
141
|
+
console.error(`[queueStore] quarantined invalid job row (file=${file || '(unknown)'}, slug=${slug}): ${issues}`);
|
|
142
|
+
invalid.push({ slug, file: file || null, issues, row });
|
|
143
|
+
}
|
|
144
|
+
return { jobs, invalid };
|
|
116
145
|
}
|
|
117
146
|
|
|
118
147
|
/**
|
|
@@ -125,7 +154,7 @@ function shapeJobs(raw) {
|
|
|
125
154
|
* consulted so writeSplit can persist "this project now has zero jobs".
|
|
126
155
|
*/
|
|
127
156
|
function readMergedSync(opts) {
|
|
128
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
157
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
|
|
129
158
|
const sourceCwds = [];
|
|
130
159
|
try {
|
|
131
160
|
Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
|
|
@@ -138,7 +167,9 @@ function readMergedSync(opts) {
|
|
|
138
167
|
for (const cwd of stateCwds(opts)) {
|
|
139
168
|
const file = projectQueuePath(cwd);
|
|
140
169
|
try {
|
|
141
|
-
|
|
170
|
+
const { jobs, invalid } = shapeJobs(fs.readFileSync(file, 'utf8'), file);
|
|
171
|
+
out.jobs.push(...jobs);
|
|
172
|
+
out.invalidJobs.push(...invalid);
|
|
142
173
|
sourceCwds.push(cwd);
|
|
143
174
|
} catch (e) {
|
|
144
175
|
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
@@ -152,7 +183,7 @@ function readMergedSync(opts) {
|
|
|
152
183
|
|
|
153
184
|
/** Async twin of readMergedSync for IPC hot paths. */
|
|
154
185
|
async function readMerged(opts) {
|
|
155
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
|
|
186
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
|
|
156
187
|
const sourceCwds = [];
|
|
157
188
|
try {
|
|
158
189
|
Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
|
|
@@ -165,7 +196,9 @@ async function readMerged(opts) {
|
|
|
165
196
|
for (const cwd of stateCwds(opts)) {
|
|
166
197
|
const file = projectQueuePath(cwd);
|
|
167
198
|
try {
|
|
168
|
-
|
|
199
|
+
const { jobs, invalid } = shapeJobs(await fsp.readFile(file, 'utf8'), file);
|
|
200
|
+
out.jobs.push(...jobs);
|
|
201
|
+
out.invalidJobs.push(...invalid);
|
|
169
202
|
sourceCwds.push(cwd);
|
|
170
203
|
} catch (e) {
|
|
171
204
|
if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
|
|
@@ -267,7 +300,7 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
|
|
|
267
300
|
for (const [cwd, jobs] of byCwd) {
|
|
268
301
|
const file = projectQueuePath(cwd);
|
|
269
302
|
let existing = [];
|
|
270
|
-
try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
|
|
303
|
+
try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
|
|
271
304
|
const have = new Set(existing.map((j) => j.slug));
|
|
272
305
|
const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
|
|
273
306
|
try {
|
|
@@ -293,6 +326,7 @@ module.exports = {
|
|
|
293
326
|
projectHistoryPath,
|
|
294
327
|
stateCwds,
|
|
295
328
|
bustCwdCache,
|
|
329
|
+
shapeJobs,
|
|
296
330
|
readMerged,
|
|
297
331
|
readMergedSync,
|
|
298
332
|
writeSplit,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical runtime schema for a ScheduleJob record, mirroring the TS
|
|
3
|
+
* `ScheduleJob` interface at src/preload/api.d.ts:375-422 field-for-field.
|
|
4
|
+
*
|
|
5
|
+
* Modelled directly on promptSessionSchema.cjs's "zod schema asserted at the
|
|
6
|
+
* main-process boundary" pattern: the Epic entity has had this guarantee
|
|
7
|
+
* since epicMint.cjs:243, but the Job entity had none — `shapeJobs` in
|
|
8
|
+
* queueStore.cjs was `JSON.parse` + `Array.isArray` with zero field checks,
|
|
9
|
+
* which is how two PRDs sat invisible for 4+ hours on 2026-08-07 with
|
|
10
|
+
* `"status": "queued"`, a value outside `ScheduleJobStatus`, silently
|
|
11
|
+
* skipped by every picker that filters on `status === 'pending'` exactly.
|
|
12
|
+
*
|
|
13
|
+
* `JOB_STATUSES` is the single source of truth for the status enum. The
|
|
14
|
+
* renderer can't import this .cjs file directly, so
|
|
15
|
+
* src/preload/api.d.ts's `ScheduleJobStatus` union and the renderer's
|
|
16
|
+
* `JobStatus`/`FilterStatus` mirrors are kept in sync against this array by
|
|
17
|
+
* src/main/__tests__/scheduleJobSchema.test.cjs.
|
|
18
|
+
*
|
|
19
|
+
* Plain CJS, no Electron dependency, so it's requirable from queueStore.cjs
|
|
20
|
+
* (which itself must load outside the Electron app, e.g. from watchdog
|
|
21
|
+
* scripts).
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { z } = require('zod');
|
|
26
|
+
|
|
27
|
+
const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined'];
|
|
28
|
+
|
|
29
|
+
const ScheduleJobStatusSchema = z.enum(JOB_STATUSES);
|
|
30
|
+
|
|
31
|
+
const ScheduleJobRuntimeSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
pid: z.number().optional(),
|
|
34
|
+
runId: z.string().optional(),
|
|
35
|
+
startedAt: z.string().nullable().optional(),
|
|
36
|
+
sessionId: z.string().optional(),
|
|
37
|
+
cwd: z.string().optional(),
|
|
38
|
+
})
|
|
39
|
+
.passthrough();
|
|
40
|
+
|
|
41
|
+
// One entry per accepted status transition (scheduleJobTransitions.cjs).
|
|
42
|
+
// Bounded to STATUS_HISTORY_CAP entries there; this schema just validates
|
|
43
|
+
// shape, not length, since the cap is enforced at write time.
|
|
44
|
+
const ScheduleJobStatusHistoryEntrySchema = z
|
|
45
|
+
.object({
|
|
46
|
+
from: z.string().nullable(),
|
|
47
|
+
to: z.string(),
|
|
48
|
+
reason: z.string().nullable(),
|
|
49
|
+
source: z.string().nullable(),
|
|
50
|
+
at: z.string(),
|
|
51
|
+
})
|
|
52
|
+
.passthrough();
|
|
53
|
+
|
|
54
|
+
// Only `slug` and `status` are required. Every other ScheduleJob field
|
|
55
|
+
// (src/preload/api.d.ts:375-422) is declared here for documentation and type
|
|
56
|
+
// checking when present, but kept optional: a job row moves through several
|
|
57
|
+
// legitimate partial shapes across its life (freshly minted with nulls,
|
|
58
|
+
// mid-run with runtime fields, terminal with exitCode/error) and plenty of
|
|
59
|
+
// call sites — including this repo's own test fixtures — construct a row
|
|
60
|
+
// with only the fields relevant to what they're exercising. The 2026-08-07
|
|
61
|
+
// incident this schema exists to catch was an invalid `status` value, not a
|
|
62
|
+
// missing optional field, so `status` is where the strictness belongs.
|
|
63
|
+
// Unknown extra fields are preserved (.passthrough()) rather than stripped —
|
|
64
|
+
// this schema's job is to gate on shape/status validity, not to be the sole
|
|
65
|
+
// place new ScheduleJob fields get declared before they're usable.
|
|
66
|
+
const ScheduleJobSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
slug: z.string(),
|
|
69
|
+
title: z.string().optional(),
|
|
70
|
+
cwd: z.string().nullable().optional(),
|
|
71
|
+
parallelGroup: z.number().optional(),
|
|
72
|
+
estimateMinutes: z.number().nullable().optional(),
|
|
73
|
+
bodyPreview: z.string().optional(),
|
|
74
|
+
status: ScheduleJobStatusSchema,
|
|
75
|
+
runId: z.string().nullable().optional(),
|
|
76
|
+
startedAt: z.string().nullable().optional(),
|
|
77
|
+
finishedAt: z.string().nullable().optional(),
|
|
78
|
+
exitCode: z.number().nullable().optional(),
|
|
79
|
+
error: z.string().nullable().optional(),
|
|
80
|
+
sessionId: z.string().optional(),
|
|
81
|
+
runtime: ScheduleJobRuntimeSchema.optional(),
|
|
82
|
+
verifierVerdict: z.string().optional(),
|
|
83
|
+
dependsOn: z.array(z.string()).optional(),
|
|
84
|
+
originSessionId: z.string().nullable().optional(),
|
|
85
|
+
sourceTabId: z.string().nullable().optional(),
|
|
86
|
+
sourcePromptId: z.string().nullable().optional(),
|
|
87
|
+
epicId: z.string().nullable().optional(),
|
|
88
|
+
statusHistory: z.array(ScheduleJobStatusHistoryEntrySchema).optional(),
|
|
89
|
+
})
|
|
90
|
+
.passthrough();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Throws a clear, descriptive error (not a raw ZodError dump) when `job`
|
|
94
|
+
* doesn't match ScheduleJobSchema. Mirrors assertValidPromptSession's shape.
|
|
95
|
+
*/
|
|
96
|
+
function assertValidScheduleJob(job) {
|
|
97
|
+
const result = ScheduleJobSchema.safeParse(job);
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
const issues = result.error.issues
|
|
100
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
101
|
+
.join('; ');
|
|
102
|
+
throw new Error(`assertValidScheduleJob: invalid ScheduleJob shape — ${issues}`);
|
|
103
|
+
}
|
|
104
|
+
return result.data;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
ScheduleJobSchema,
|
|
109
|
+
ScheduleJobRuntimeSchema,
|
|
110
|
+
ScheduleJobStatusSchema,
|
|
111
|
+
ScheduleJobStatusHistoryEntrySchema,
|
|
112
|
+
JOB_STATUSES,
|
|
113
|
+
assertValidScheduleJob,
|
|
114
|
+
};
|