claude-code-session-manager 0.65.0 → 0.66.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-Bzg89D5Y.js +3 -0
- package/dist/assets/{History-DB-9-zwc.js → History-CNH9vA0A.js} +2 -2
- package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-CyTksPza.js} +3 -3
- package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-DXQVDNHn.js} +1 -1
- package/dist/assets/{Library-BYSB0dmY.js → Library-9E2UOIsm.js} +1 -1
- package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-CQWU_Yn5.js} +1 -1
- package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-DQgfpSef.js} +1 -1
- package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-r3qwDIj2.js} +2 -2
- package/dist/assets/{Memory-DzPGXT5J.js → Memory-BaxOpj-3.js} +6 -6
- package/dist/assets/{Panel-BX6UZ-W0.js → Panel-BhFD8Lqo.js} +1 -1
- package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-Cj-mODQQ.js} +3 -3
- package/dist/assets/{Plugins-CIuey9R3.js → Plugins-DEL3Fqng.js} +2 -2
- package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
- package/dist/assets/Scheduler-YOuZKkES.js +14 -0
- package/dist/assets/{ScopeSwitcher-DU7M_q5-.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
- package/dist/assets/{Settings-DEptXcEY.js → Settings-B0x4oflz.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
- package/dist/assets/{Skills-CGN56X1i.js → Skills-CN8R6AWn.js} +2 -2
- package/dist/assets/{SystemPrompt-DIEK7FpJ.js → SystemPrompt-DceChpAi.js} +1 -1
- package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-BKmz2W7B.js} +1 -1
- package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-W7n5SwPM.js} +1 -1
- package/dist/assets/{Toggle-D9sapSAv.js → Toggle-QuxlVHGI.js} +1 -1
- package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
- package/dist/assets/{index-BRwaw_1W.js → index-TejhHSzN.js} +526 -525
- package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-DNqx6BKJ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +41 -13
- 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__/flatPrdTickSweep.test.cjs +110 -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/agentLibrary.cjs +40 -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/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +649 -137
- 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
|
@@ -34,6 +34,25 @@ function parseTools(raw) {
|
|
|
34
34
|
/** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
|
|
35
35
|
const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
36
36
|
|
|
37
|
+
/** Sentinel `projects:` entry meaning "this agent's Action button shows in every project". */
|
|
38
|
+
const ALL_PROJECTS = '*';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Frontmatter values are single-line (see lib/prdFrontmatter.cjs) but an
|
|
42
|
+
* Action's opening instruction is genuinely multi-line prose (the builder
|
|
43
|
+
* action is `/builder\n\n<instruction>`). Encode newlines as a literal `\n`
|
|
44
|
+
* two-char sequence on the way out, decode on the way in — one place, so the
|
|
45
|
+
* round-trip is structural rather than something each caller remembers.
|
|
46
|
+
*/
|
|
47
|
+
function encodeLine(s) {
|
|
48
|
+
return String(s).replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeLine(s) {
|
|
52
|
+
if (!s) return '';
|
|
53
|
+
return String(s).replace(/\\([\\n])/g, (_m, c) => (c === 'n' ? '\n' : '\\'));
|
|
54
|
+
}
|
|
55
|
+
|
|
37
56
|
/**
|
|
38
57
|
* Serializes a persona's frontmatter + body back to `.md` file text.
|
|
39
58
|
* `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
|
|
@@ -41,14 +60,25 @@ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
41
60
|
* this persona is associated with, so Agent Library and Tag Library can each
|
|
42
61
|
* assign/remove the relationship from either side. Stored the same
|
|
43
62
|
* comma-list way as `tools` for one parser to cover both.
|
|
63
|
+
*
|
|
64
|
+
* `projects` / `action` / `actionLabel` are the same kind of extension, for
|
|
65
|
+
* the Sessions workspace's per-project **Action buttons**: `projects` is the
|
|
66
|
+
* list of project cwds (or the `*` sentinel = every project) whose Sessions
|
|
67
|
+
* toolbar shows a one-click button for this agent, `action` is the opening
|
|
68
|
+
* instruction that button sends into the new session, and `actionLabel`
|
|
69
|
+
* overrides the button's caption (defaults to the persona name). Claude Code
|
|
70
|
+
* ignores all three; only this app reads them.
|
|
44
71
|
*/
|
|
45
|
-
function serializePersona({ name, description, tools, model, color, tags, title, body }) {
|
|
72
|
+
function serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body }) {
|
|
46
73
|
const lines = ['---', `name: ${name}`];
|
|
47
74
|
if (description) lines.push(`description: ${description}`);
|
|
48
75
|
if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
|
|
49
76
|
if (model && model !== 'inherit') lines.push(`model: ${model}`);
|
|
50
77
|
if (color) lines.push(`color: ${color}`);
|
|
51
78
|
if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
|
|
79
|
+
if (projects && projects.length) lines.push(`projects: ${projects.join(', ')}`);
|
|
80
|
+
if (action) lines.push(`action: ${encodeLine(action)}`);
|
|
81
|
+
if (actionLabel) lines.push(`actionLabel: ${encodeLine(actionLabel)}`);
|
|
52
82
|
if (title) lines.push(`title: ${title}`);
|
|
53
83
|
lines.push('---', '');
|
|
54
84
|
return lines.join('\n') + (body || '').trim() + '\n';
|
|
@@ -67,6 +97,9 @@ async function savePersona({
|
|
|
67
97
|
model,
|
|
68
98
|
color,
|
|
69
99
|
tags,
|
|
100
|
+
projects,
|
|
101
|
+
action,
|
|
102
|
+
actionLabel,
|
|
70
103
|
title,
|
|
71
104
|
body,
|
|
72
105
|
globalDir = path.join(os.homedir(), '.claude', 'agents'),
|
|
@@ -77,7 +110,7 @@ async function savePersona({
|
|
|
77
110
|
throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
|
|
78
111
|
}
|
|
79
112
|
const target = validatePath(path.join(globalDir, `${name}.md`));
|
|
80
|
-
const text = serializePersona({ name, description, tools, model, color, tags, title, body });
|
|
113
|
+
const text = serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body });
|
|
81
114
|
await writeTextAtomic(target, text);
|
|
82
115
|
if (originalName && originalName !== name) {
|
|
83
116
|
const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
|
|
@@ -203,6 +236,9 @@ async function listPersonas({
|
|
|
203
236
|
model: fm.model || null,
|
|
204
237
|
color: fm.color || null,
|
|
205
238
|
tags: parseTools(fm.tags),
|
|
239
|
+
projects: parseTools(fm.projects),
|
|
240
|
+
action: decodeLine(fm.action) || null,
|
|
241
|
+
actionLabel: decodeLine(fm.actionLabel) || null,
|
|
206
242
|
title: fm.title || null,
|
|
207
243
|
path: real,
|
|
208
244
|
body: body.trim(),
|
|
@@ -220,5 +256,7 @@ module.exports = {
|
|
|
220
256
|
savePersona,
|
|
221
257
|
deletePersona,
|
|
222
258
|
removeOverride,
|
|
259
|
+
serializePersona,
|
|
223
260
|
PERSONA_NAME_RE,
|
|
261
|
+
ALL_PROJECTS,
|
|
224
262
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
26
26
|
const scheduler = require('./scheduler.cjs');
|
|
27
27
|
const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
|
|
28
28
|
const prdCreate = require('./lib/prdCreate.cjs');
|
|
29
|
+
const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
|
|
29
30
|
const { appendAuditEvent } = require('./lib/auditLog.cjs');
|
|
30
31
|
const chatRunner = require('./chatRunner.cjs');
|
|
31
32
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
@@ -37,6 +38,7 @@ const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
|
37
38
|
const adminHttp = createAdminHttp();
|
|
38
39
|
scheduler.registerAdminRoutes(adminHttp);
|
|
39
40
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
41
|
+
prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
|
|
40
42
|
chatRunner.registerAdminRoute(adminHttp);
|
|
41
43
|
const supervisor = require('./supervisor.cjs');
|
|
42
44
|
const watchers = require('./watchers.cjs');
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -327,6 +327,62 @@ const scheduleRetagPrd = z.object({
|
|
|
327
327
|
items: z.array(scheduleRetagItem).min(1).max(500),
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
// ──────────────────────────────────────────── Admin API — PRD lifecycle (PRD 1024)
|
|
331
|
+
//
|
|
332
|
+
// GET routes parse their input from URLSearchParams (all values arrive as
|
|
333
|
+
// strings), so numeric/array fields aren't accepted here — only slug/cwd/
|
|
334
|
+
// epicId/status, matching what a query string can actually carry.
|
|
335
|
+
const adminListPrdsQuery = z.object({
|
|
336
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
337
|
+
epicId: z.string().min(1).max(200).optional(),
|
|
338
|
+
status: z.string().min(1).max(40).optional(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const adminGetPrdQuery = z.object({
|
|
342
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
343
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Partial frontmatter patch for update-prd — same recognized-key set as
|
|
347
|
+
// prdFrontmatter.cjs's parsePrdFile/serializePrdFile round-trip pair. Every
|
|
348
|
+
// field optional; the route refuses an empty patch+no-body request via the
|
|
349
|
+
// top-level .refine below.
|
|
350
|
+
const adminPrdFrontmatterPatch = z.object({
|
|
351
|
+
title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
352
|
+
cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
353
|
+
estimateMinutes: z.number().int().min(1).max(100000).optional(),
|
|
354
|
+
parallelGroup: z.number().int().min(0).max(999999).optional(),
|
|
355
|
+
sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
356
|
+
sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
357
|
+
// 'discussion' deliberately excluded: matches prdFrontmatter.cjs's
|
|
358
|
+
// parsePrdFile, which never parses that value for a PRD's tag key (a
|
|
359
|
+
// discussion-tagged Epic never reaches PRD authoring).
|
|
360
|
+
tag: z.enum(['feature', 'bug', 'build']).optional(),
|
|
361
|
+
// Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
|
|
362
|
+
// — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
|
|
363
|
+
// adopt) are the two values this codebase writes today, but the field
|
|
364
|
+
// itself is descriptive, not a closed set.
|
|
365
|
+
createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
366
|
+
issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const adminUpdatePrd = z.object({
|
|
370
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
371
|
+
cwd: z.string().min(1).max(4096).optional(),
|
|
372
|
+
frontmatter: adminPrdFrontmatterPatch.optional(),
|
|
373
|
+
body: z.string().refine(
|
|
374
|
+
(s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
|
|
375
|
+
`body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
|
|
376
|
+
).optional(),
|
|
377
|
+
}).refine(
|
|
378
|
+
(it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined,
|
|
379
|
+
'at least one of frontmatter or body is required',
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const adminCancelJob = z.object({
|
|
383
|
+
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
384
|
+
});
|
|
385
|
+
|
|
330
386
|
// ──────────────────────────────────────────── Projects
|
|
331
387
|
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
332
388
|
|
|
@@ -812,6 +868,10 @@ module.exports = {
|
|
|
812
868
|
schedulerCreatePrd,
|
|
813
869
|
scheduleArchivePrd,
|
|
814
870
|
scheduleRetagPrd,
|
|
871
|
+
adminListPrdsQuery,
|
|
872
|
+
adminGetPrdQuery,
|
|
873
|
+
adminUpdatePrd,
|
|
874
|
+
adminCancelJob,
|
|
815
875
|
setConfigSchema,
|
|
816
876
|
setSessionSlotsSchema,
|
|
817
877
|
shellOpen,
|
|
@@ -151,17 +151,24 @@ function createAdminHttp() {
|
|
|
151
151
|
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// Route lookup is keyed by pathname only — req.url carries the raw query
|
|
155
|
+
// string too, which would otherwise never match a registered route (every
|
|
156
|
+
// route before PRD 1024 was query-free, so this was a latent gap, not a
|
|
157
|
+
// behavior change for them). `query` is handed to the route handler as a
|
|
158
|
+
// third argument (URLSearchParams) for routes that need it (e.g. GET
|
|
159
|
+
// /admin/scheduler/prds?cwd=&epicId=&status=).
|
|
160
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
161
|
+
if (req.method === 'GET' && urlObj.pathname === HEALTH_PATH) {
|
|
155
162
|
sendJson(res, 200, { ok: true });
|
|
156
163
|
return;
|
|
157
164
|
}
|
|
158
165
|
try {
|
|
159
|
-
const handler = routes.get(`${req.method} ${
|
|
166
|
+
const handler = routes.get(`${req.method} ${urlObj.pathname}`);
|
|
160
167
|
if (!handler) {
|
|
161
168
|
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
162
169
|
return;
|
|
163
170
|
}
|
|
164
|
-
await handler(req, res);
|
|
171
|
+
await handler(req, res, urlObj.searchParams);
|
|
165
172
|
} catch (e) {
|
|
166
173
|
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
167
174
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdAdminRoutes.cjs — admin HTTP routes closing the PRD-lifecycle capability
|
|
3
|
+
* gap (PRD 1024, step 1 of locking the PRD entity behind the scheduler
|
|
4
|
+
* service boundary — see that PRD's Goal). Before this file, the admin API
|
|
5
|
+
* exposed only create-prd/jobs/reset-job/send-prompt: there was no way to
|
|
6
|
+
* READ, LIST, EDIT, ARCHIVE, or CANCEL a PRD/job over HTTP, which is exactly
|
|
7
|
+
* why direct filesystem access to session-manager-operations/scheduler/ was
|
|
8
|
+
* still sanctioned. This file plumbs those operations to HTTP; it adds no
|
|
9
|
+
* enforcement/denial of direct access — that's the dependent PRD's job (see
|
|
10
|
+
* this PRD's "Out of scope").
|
|
11
|
+
*
|
|
12
|
+
* Every route here reuses an existing verified implementation rather than
|
|
13
|
+
* reimplementing it:
|
|
14
|
+
* - list/get/update/cancel delegate to scheduler.cjs's `remote` object
|
|
15
|
+
* (job-status-aware; remote already owns the PRD-dir search + symlink
|
|
16
|
+
* defense patterns these need)
|
|
17
|
+
* - archive/retag delegate to queueOps.cjs's archiveMany/retagMany
|
|
18
|
+
* directly (job-status-agnostic bulk file ops; already used by the
|
|
19
|
+
* renderer's schedule:archive-prd/schedule:retag-prd IPC handlers)
|
|
20
|
+
*
|
|
21
|
+
* Registered against the same injected localAdminHttp.cjs transport as
|
|
22
|
+
* scheduler.cjs's registerAdminRoutes and prdCreate.cjs's registerAdminRoute
|
|
23
|
+
* (see index.cjs) — auth, loopback-only binding, and the bearer-token
|
|
24
|
+
* lifecycle are all the transport's job, not this file's.
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const { schemas } = require('../ipcSchemas.cjs');
|
|
29
|
+
const { readBody, sendJson } = require('./localAdminHttp.cjs');
|
|
30
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
31
|
+
const queueOps = require('../queueOps.cjs');
|
|
32
|
+
|
|
33
|
+
function parseJsonBody(raw) {
|
|
34
|
+
try {
|
|
35
|
+
return { ok: true, value: raw ? JSON.parse(raw) : {} };
|
|
36
|
+
} catch {
|
|
37
|
+
return { ok: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* registerAdminRoute(adminHttp, remote) — `remote` is scheduler.cjs's
|
|
43
|
+
* `remote` object, injected (not required directly) so this file stays
|
|
44
|
+
* testable without booting Electron, matching prdCreate.cjs's and
|
|
45
|
+
* scheduler.cjs's own registerAdminRoute(s) pattern.
|
|
46
|
+
*/
|
|
47
|
+
function registerAdminRoute(adminHttp, remote) {
|
|
48
|
+
adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
|
|
49
|
+
let input;
|
|
50
|
+
try {
|
|
51
|
+
input = schemas.adminListPrdsQuery.parse(Object.fromEntries(query ?? []));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const prds = await remote.listPrds(input);
|
|
57
|
+
sendJson(res, 200, { ok: true, prds });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
|
|
61
|
+
let input;
|
|
62
|
+
try {
|
|
63
|
+
input = schemas.adminGetPrdQuery.parse(Object.fromEntries(query ?? []));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const result = await remote.getPrdParsed(input.slug, input.cwd);
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
sendJson(res, 404, result);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
sendJson(res, 200, result);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/update-prd', async (req, res) => {
|
|
77
|
+
const raw = await readBody(req);
|
|
78
|
+
const parsedBody = parseJsonBody(raw);
|
|
79
|
+
if (!parsedBody.ok) {
|
|
80
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let input;
|
|
84
|
+
try {
|
|
85
|
+
input = schemas.adminUpdatePrd.parse(parsedBody.value);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const result = await remote.updatePrd(input);
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
const status = /not found/i.test(result.error ?? '') ? 404 : /status is/i.test(result.error ?? '') ? 409 : 400;
|
|
93
|
+
sendJson(res, status, result);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
appendAuditEvent('admin_prd_update', { route: '/admin/scheduler/update-prd', slug: input.slug, cwd: input.cwd ?? null });
|
|
97
|
+
sendJson(res, 200, result);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/archive-prd', async (req, res) => {
|
|
101
|
+
const raw = await readBody(req);
|
|
102
|
+
const parsedBody = parseJsonBody(raw);
|
|
103
|
+
if (!parsedBody.ok) {
|
|
104
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let input;
|
|
108
|
+
try {
|
|
109
|
+
input = schemas.scheduleArchivePrd.parse(parsedBody.value);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = await queueOps.archiveMany(input.slugs);
|
|
115
|
+
// Audit only the slugs that actually archived — archiveMany's `results`
|
|
116
|
+
// array carries the real per-slug outcome; logging every requested slug
|
|
117
|
+
// regardless of whether it succeeded would misrepresent a partial-failure
|
|
118
|
+
// batch as a clean one in audit-log.jsonl.
|
|
119
|
+
for (const r of result.results ?? []) {
|
|
120
|
+
if (!r.ok) continue;
|
|
121
|
+
appendAuditEvent('admin_prd_archive', { route: '/admin/scheduler/archive-prd', slug: r.slug, cwd: null });
|
|
122
|
+
}
|
|
123
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/cancel-job', async (req, res) => {
|
|
127
|
+
const raw = await readBody(req);
|
|
128
|
+
const parsedBody = parseJsonBody(raw);
|
|
129
|
+
if (!parsedBody.ok) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let input;
|
|
134
|
+
try {
|
|
135
|
+
input = schemas.adminCancelJob.parse(parsedBody.value);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const result = await remote.cancelJob(input.slug);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
const status = /not found/i.test(result.error ?? '') ? 404 : 409;
|
|
143
|
+
sendJson(res, status, result);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
appendAuditEvent('admin_job_cancel', { route: '/admin/scheduler/cancel-job', slug: input.slug, cwd: result.cwd ?? null });
|
|
147
|
+
sendJson(res, 200, result);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
adminHttp.registerRoute('POST', '/admin/scheduler/retag-prd', async (req, res) => {
|
|
151
|
+
const raw = await readBody(req);
|
|
152
|
+
const parsedBody = parseJsonBody(raw);
|
|
153
|
+
if (!parsedBody.ok) {
|
|
154
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let input;
|
|
158
|
+
try {
|
|
159
|
+
input = schemas.scheduleRetagPrd.parse(parsedBody.value);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const result = await queueOps.retagMany(input.items);
|
|
165
|
+
// Audit only the items that actually retagged (see the archive-prd route
|
|
166
|
+
// above for why this must read result.results rather than the request).
|
|
167
|
+
for (const r of result.results ?? []) {
|
|
168
|
+
if (!r.ok) continue;
|
|
169
|
+
appendAuditEvent('admin_prd_retag', { route: '/admin/scheduler/retag-prd', slug: r.newSlug ?? r.slug, cwd: null });
|
|
170
|
+
}
|
|
171
|
+
sendJson(res, result.ok ? 200 : 500, result);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { registerAdminRoute };
|
|
@@ -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 };
|