claude-code-session-manager 0.39.3 → 0.40.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-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-CR-WdMgm.css} +1 -1
- package/dist/assets/index-oMNYp73N.js +3194 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +34 -27
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/config.cjs +10 -0
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +28 -9
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +82 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/pty.cjs +9 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +333 -52
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +18 -3
- package/src/preload/index.cjs +2 -0
- package/dist/assets/index-BUuhV6vT.js +0 -3180
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-oMNYp73N.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-CR-WdMgm.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.40.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",
|
|
@@ -77,7 +77,7 @@ can't load skills.
|
|
|
77
77
|
do they actually share the same shape, or only look similar — confirm by reading, don't
|
|
78
78
|
assume: a wrong assumption here means an inaccurate PRD, discovered only after the executor
|
|
79
79
|
runs it), check existing tests for the area, and check whether a prior PRD already touched
|
|
80
|
-
this subsystem (`ls <cwd>/session-manager-operations/scheduler/prds/` for related slugs, in
|
|
80
|
+
this subsystem (`ls <cwd>/session-manager-operations/scheduler/epics/*/prds/` for related slugs, in
|
|
81
81
|
the target repo) — duplicating or contradicting a still-queued PRD is a real failure mode,
|
|
82
82
|
not a hypothetical one.
|
|
83
83
|
|
|
@@ -161,8 +161,9 @@ can't load skills.
|
|
|
161
161
|
- **Evolving chain (3-5 PRDs).** Use this shape when the request is naturally a sequence of
|
|
162
162
|
stages that build on each other rather than independent units — e.g. scaffold → core
|
|
163
163
|
behavior → edge-case/interaction hardening → integration wiring → UI validation pass. Each
|
|
164
|
-
PRD in the chain: gets
|
|
165
|
-
|
|
164
|
+
PRD in the chain: gets its own unique `NN` (numbers are strictly unique per project —
|
|
165
|
+
PRD 832, user decision 2026-07-31) plus a `dependsOn: [<previous-link-slug>]`
|
|
166
|
+
frontmatter line expressing the chain edge explicitly, and its
|
|
166
167
|
`# Implementation notes` states in one line what the *previous* link actually delivered
|
|
167
168
|
(file paths/functions it added, referencing its real landed state — not the plan for it,
|
|
168
169
|
since PRDs 1..k-1 may have adjusted scope during execution) and what this link is expected
|
|
@@ -184,11 +185,11 @@ can't load skills.
|
|
|
184
185
|
(`mcp__session-manager-scheduler__scheduler_create_prd`) over hand-writing the file. Its input
|
|
185
186
|
(`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
|
|
186
187
|
`outOfScope[]`) maps directly onto the sections below — pass them straight through. It
|
|
187
|
-
allocates
|
|
188
|
-
concurrent `/develop`/`/process-feedback` invocation
|
|
189
|
-
and embeds the standards pointer for you.
|
|
190
|
-
|
|
191
|
-
|
|
188
|
+
allocates a strictly-unique `NN` atomically (no read-then-write race against another
|
|
189
|
+
concurrent `/develop`/`/process-feedback` invocation, never reused across the project — PRD
|
|
190
|
+
832), derives and collision-checks the slug, and embeds the standards pointer for you.
|
|
191
|
+
`parallelGroup` is DEPRECATED and ignored — express ordering with the `dependsOn` input
|
|
192
|
+
(slugs that must complete first); independent PRDs simply omit it and may run in parallel.
|
|
192
193
|
|
|
193
194
|
**Fallback — only when the tool errors with "app not running" / admin API unreachable**
|
|
194
195
|
(the session-manager Electron app must be running for this MCP tool to work; if it isn't,
|
|
@@ -197,12 +198,12 @@ can't load skills.
|
|
|
197
198
|
and collides). PRDs are stored per-project, so `NN` allocation for a given PRD only needs
|
|
198
199
|
*that project's own* prds directory scanned — not every project's:
|
|
199
200
|
```bash
|
|
200
|
-
ls <cwd>/session-manager-operations/scheduler/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
|
|
201
|
+
ls <cwd>/session-manager-operations/scheduler/epics/*/prds/ <cwd>/session-manager-operations/scheduler/prds-archived/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
|
|
201
202
|
```
|
|
202
203
|
(`<cwd>` is the target repo's absolute path — the same one this PRD's `cwd` field will use.)
|
|
203
|
-
The last line is the current max within that project. Then:
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
The last line is the current max within that project. Then: **always next free `NN` =
|
|
205
|
+
max+1** — never reuse a sibling's number (unique-per-project rule, PRD 832); express
|
|
206
|
+
ordering with `dependsOn: [<slug>]` frontmatter instead. This manual path has a
|
|
206
207
|
small, accepted race (two concurrent authors could compute the same "next free" `NN`) —
|
|
207
208
|
cosmetic (two unrelated groups end up sharing a number) rather than destructive, and only
|
|
208
209
|
reachable when the atomic tool path above isn't available. Record each cross-PRD dependency
|
|
@@ -218,23 +219,29 @@ can't load skills.
|
|
|
218
219
|
system that runs `claude -p <prd-body> --dangerously-skip-permissions` jobs around 5-hour
|
|
219
220
|
token-window resets, with auto-pause on rate-limit and auto-resume.
|
|
220
221
|
|
|
221
|
-
**Canonical location — non-negotiable.**
|
|
222
|
+
**Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
|
|
223
|
+
PRD domain model in the project CLAUDE.md). PRDs MUST be written to, inside the target repo:
|
|
222
224
|
```
|
|
223
|
-
<cwd>/session-manager-operations/scheduler/prds/<NN>-<kebab-slug>.md
|
|
225
|
+
<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
|
|
224
226
|
```
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
227
|
+
Get `<epic-id>`'s prds/ directory by minting (or joining) an Epic first:
|
|
228
|
+
```bash
|
|
229
|
+
node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<one-line goal for this work>" [feature|bug|discussion]
|
|
230
|
+
```
|
|
231
|
+
The last stdout line is the exact directory to write into. Reuse-by-goal makes it idempotent —
|
|
232
|
+
all PRDs of one /develop ask share one Epic. (The MCP `scheduler_create_prd` tool does all of
|
|
233
|
+
this automatically; the mint step is only for the manual-write fallback.)
|
|
234
|
+
|
|
235
|
+
**Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
|
|
236
|
+
old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
|
|
237
|
+
the scheduler; the legacy flat `session-manager-operations/scheduler/prds/` dir is RETIRED —
|
|
238
|
+
anything written there is auto-consolidated into `prds-archived/` at the next app boot
|
|
239
|
+
without being executed. PRD *source* files are per-project and per-Epic, resolved at runtime
|
|
240
|
+
via `src/main/lib/prdLocations.cjs`.
|
|
241
|
+
|
|
242
|
+
**Filename rules.** `NN` is the PRD's unique per-project number (always next free =
|
|
243
|
+
max+1 per the `ls` command above; ordering via `dependsOn` frontmatter, never via shared
|
|
244
|
+
numbers). `<kebab-slug>` is a short, descriptive kebab-case identifier
|
|
238
245
|
(e.g. `voice-commands-send-cancel`, `ticker-velocity-mcp`), kept under 60 chars. Verify your
|
|
239
246
|
chosen filename doesn't already exist before writing.
|
|
240
247
|
|
|
@@ -8,7 +8,9 @@ const os = require('node:os');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
|
|
10
10
|
const { activeProjectCwds } = require('./activeSessions.cjs');
|
|
11
|
-
const { resolvePrdWriteDir } = require('../../src/main/lib/prdLocations.cjs');
|
|
11
|
+
const { resolvePrdWriteDir, listEpicPrdDirs } = require('../../src/main/lib/prdLocations.cjs');
|
|
12
|
+
const { ensureEpic, appendPrdCreatedEvent } = require('../../src/main/lib/epicMint.cjs');
|
|
13
|
+
const { projectQueuePath } = require('../../src/main/lib/queueStore.cjs');
|
|
12
14
|
|
|
13
15
|
// Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
|
|
14
16
|
const DEFAULT_HEARTBEAT_PATH = path.join(
|
|
@@ -294,7 +296,7 @@ function hasOpenFeedback(cwd) {
|
|
|
294
296
|
*/
|
|
295
297
|
function emitFeedbackPRD(cwd, {
|
|
296
298
|
prdsDir,
|
|
297
|
-
queuePath
|
|
299
|
+
queuePath,
|
|
298
300
|
skillPath,
|
|
299
301
|
standardsPath,
|
|
300
302
|
pluginCacheRoot = PLUGIN_CACHE_ROOT,
|
|
@@ -306,8 +308,18 @@ function emitFeedbackPRD(cwd, {
|
|
|
306
308
|
// PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
|
|
307
309
|
// prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
|
|
308
310
|
// global dir into `<cwd>/session-manager-operations/scheduler/prds`).
|
|
311
|
+
let epicId = null;
|
|
309
312
|
if (prdsDir === undefined) {
|
|
310
|
-
|
|
313
|
+
// Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
|
|
314
|
+
// one standing "Inbound feedback" Epic per project (reuseByGoal) so
|
|
315
|
+
// recurring ticks chain into a single Epic rather than minting one each.
|
|
316
|
+
const epic = ensureEpic(cwd, {
|
|
317
|
+
goalText: 'Inbound feedback processing',
|
|
318
|
+
tag: 'discussion',
|
|
319
|
+
reuseByGoal: true,
|
|
320
|
+
});
|
|
321
|
+
epicId = epic.epicId;
|
|
322
|
+
prdsDir = epic.prdDir;
|
|
311
323
|
}
|
|
312
324
|
const skillPathExplicit = skillPath !== undefined;
|
|
313
325
|
const standardsPathExplicit = standardsPath !== undefined;
|
|
@@ -319,11 +331,20 @@ function emitFeedbackPRD(cwd, {
|
|
|
319
331
|
}
|
|
320
332
|
const project = slugify(path.basename(cwd));
|
|
321
333
|
|
|
322
|
-
// De-dup: check queue
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
334
|
+
// De-dup: check the queue for a pending/running feedback job for this
|
|
335
|
+
// project. Federated state (2026-07-31): the project's own shard is the
|
|
336
|
+
// system of record; the legacy global path is still consulted as a fallback
|
|
337
|
+
// during transition. An explicit queuePath override (tests) wins verbatim.
|
|
338
|
+
const queuePaths = queuePath !== undefined
|
|
339
|
+
? [queuePath]
|
|
340
|
+
: [projectQueuePath(cwd), DEFAULT_QUEUE_PATH];
|
|
341
|
+
const queueState = { jobs: [] };
|
|
342
|
+
for (const qp of queuePaths) {
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(fs.readFileSync(qp, 'utf8'));
|
|
345
|
+
queueState.jobs.push(...(Array.isArray(parsed.jobs) ? parsed.jobs : []));
|
|
346
|
+
} catch { /* file may not exist */ }
|
|
347
|
+
}
|
|
327
348
|
|
|
328
349
|
// project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
|
|
329
350
|
const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
|
|
@@ -341,18 +362,28 @@ function emitFeedbackPRD(cwd, {
|
|
|
341
362
|
// watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
|
|
342
363
|
const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
|
|
343
364
|
let maxNN = 0;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
365
|
+
// NN numbering and the on-disk dedup are project-global, so scan every PRD
|
|
366
|
+
// location the project has: the write dir, the legacy flat dir, and every
|
|
367
|
+
// Epic's prds/ dir. (An explicit prdsDir override — tests — scans only it.)
|
|
368
|
+
const scanDirs = new Set([prdsDir]);
|
|
369
|
+
if (epicId !== null) {
|
|
370
|
+
try { scanDirs.add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
|
|
371
|
+
for (const d of listEpicPrdDirs(cwd)) scanDirs.add(d);
|
|
372
|
+
}
|
|
373
|
+
for (const dir of scanDirs) {
|
|
374
|
+
let prdFiles = [];
|
|
375
|
+
try {
|
|
376
|
+
prdFiles = fs.readdirSync(dir);
|
|
377
|
+
} catch { continue; /* dir may not exist yet */ }
|
|
378
|
+
for (const f of prdFiles) {
|
|
379
|
+
if (prdDupRe.test(f)) {
|
|
380
|
+
return { emitted: false, reason: 'duplicate' };
|
|
381
|
+
}
|
|
382
|
+
const m = f.match(/^(\d+)-/);
|
|
383
|
+
if (m) {
|
|
384
|
+
const n = parseInt(m[1], 10);
|
|
385
|
+
if (n > maxNN) maxNN = n;
|
|
386
|
+
}
|
|
356
387
|
}
|
|
357
388
|
}
|
|
358
389
|
const nn = String(maxNN + 1).padStart(2, '0');
|
|
@@ -440,6 +471,11 @@ function emitFeedbackPRD(cwd, {
|
|
|
440
471
|
fs.mkdirSync(prdsDir, { recursive: true });
|
|
441
472
|
fs.writeFileSync(tmpPath, body + '\n', 'utf8');
|
|
442
473
|
fs.renameSync(tmpPath, prdPath);
|
|
474
|
+
if (epicId !== null) {
|
|
475
|
+
// Trace the dispatch on the Epic's event chain (prompt → prd_created →
|
|
476
|
+
// ...) so the Epic conversation shows the PRD it spawned.
|
|
477
|
+
try { appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
|
|
478
|
+
}
|
|
443
479
|
} catch (e) {
|
|
444
480
|
try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
445
481
|
throw e;
|
|
@@ -622,7 +658,7 @@ function maybeRelaunchApp({
|
|
|
622
658
|
function sweep({
|
|
623
659
|
projectsDir,
|
|
624
660
|
prdsDir,
|
|
625
|
-
queuePath
|
|
661
|
+
queuePath,
|
|
626
662
|
skillPath,
|
|
627
663
|
standardsPath,
|
|
628
664
|
} = {}) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-prd-migration.test.cjs — stranded-PRD-migration health signal
|
|
3
|
+
* (behind two real 2026-07-31 incidents: a single stranded PRD causing an
|
|
4
|
+
* instant ENOENT job failure, and 223 stranded legacy PRDs + a missing
|
|
5
|
+
* history.jsonl resurrecting 189 completed jobs). Exercises
|
|
6
|
+
* evaluatePrdMigrationHealth() directly since it's pure — no need to spawn
|
|
7
|
+
* the full check() (which shells out to npm run typecheck) to cover this.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/health-prd-migration.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect } from 'vitest';
|
|
15
|
+
const { evaluatePrdMigrationHealth } = require('../health.cjs');
|
|
16
|
+
|
|
17
|
+
const LEGACY_DIR = '/home/user/.claude/session-manager/scheduled-plans/prds';
|
|
18
|
+
|
|
19
|
+
test('reports ok when nothing is stranded', () => {
|
|
20
|
+
const result = evaluatePrdMigrationHealth({ moved: 3, skipped: 0, unresolved: [] }, LEGACY_DIR);
|
|
21
|
+
expect(result.ok).toBe(true);
|
|
22
|
+
expect(result.strandedCount).toBe(0);
|
|
23
|
+
expect(result.legacyDir).toBe(LEGACY_DIR);
|
|
24
|
+
expect(result.unresolved).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('reports RED with count and legacy dir path when files are stranded', () => {
|
|
28
|
+
const unresolved = [
|
|
29
|
+
{ file: '01-stuck.md', reason: 'no cwd in frontmatter' },
|
|
30
|
+
{ file: '02-stuck.md', reason: 'cwd does not exist on disk' },
|
|
31
|
+
];
|
|
32
|
+
const result = evaluatePrdMigrationHealth({ moved: 0, skipped: 0, unresolved }, LEGACY_DIR);
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
expect(result.strandedCount).toBe(2);
|
|
35
|
+
expect(result.legacyDir).toBe(LEGACY_DIR);
|
|
36
|
+
expect(result.unresolved).toEqual(unresolved);
|
|
37
|
+
});
|
|
@@ -307,7 +307,7 @@ test('POST /admin/scheduler/create-prd rejects a title containing a newline (fro
|
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
-
test('
|
|
310
|
+
test('PRD 832: an explicit parallelGroup colliding with an existing file is ignored — a fresh unique NN is allocated and nothing is clobbered', async () => {
|
|
311
311
|
const prdsDir = await mkTmpPrdsDir();
|
|
312
312
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
313
313
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -317,17 +317,18 @@ test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that
|
|
|
317
317
|
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
318
318
|
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
319
319
|
});
|
|
320
|
-
expect(res.status).toBe(
|
|
321
|
-
|
|
320
|
+
expect(res.status).toBe(200);
|
|
321
|
+
// Allocator scans past the existing 777 — unique per project, never reused.
|
|
322
|
+
expect(res.json.nn).toBe(778);
|
|
323
|
+
expect(res.json.filename).toBe('778-my-explicit-slug.md');
|
|
322
324
|
const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
|
|
323
|
-
// existing PRD file must not be overwritten
|
|
324
325
|
expect(stillThere).toBe('ORIGINAL CONTENT\n');
|
|
325
326
|
} finally {
|
|
326
327
|
await admin.stop();
|
|
327
328
|
}
|
|
328
329
|
});
|
|
329
330
|
|
|
330
|
-
test('
|
|
331
|
+
test('PRD 832: explicit parallelGroup input is deprecated and ignored — NN always comes from the allocator', async () => {
|
|
331
332
|
const prdsDir = await mkTmpPrdsDir();
|
|
332
333
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
333
334
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -337,9 +338,9 @@ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup i
|
|
|
337
338
|
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
338
339
|
});
|
|
339
340
|
expect(res.status).toBe(200);
|
|
340
|
-
expect(res.json.nn).toBe(
|
|
341
|
-
expect(res.json.filename).toBe('
|
|
342
|
-
expect(fs.existsSync(path.join(prdsDir, '
|
|
341
|
+
expect(res.json.nn).toBe(1);
|
|
342
|
+
expect(res.json.filename).toBe('1-my-explicit-slug.md');
|
|
343
|
+
expect(fs.existsSync(path.join(prdsDir, '1-my-explicit-slug.md'))).toBeTruthy();
|
|
343
344
|
} finally {
|
|
344
345
|
await admin.stop();
|
|
345
346
|
}
|
|
@@ -385,17 +386,55 @@ test('createPrd with a valid payload allocates a group, writes through remote.wr
|
|
|
385
386
|
expect(written).toMatch(/# Goal/);
|
|
386
387
|
});
|
|
387
388
|
|
|
388
|
-
test('createPrd returns {ok:false, status:409} without clobbering
|
|
389
|
+
test('createPrd returns {ok:false, status:409} without clobbering when the destination already exists', async () => {
|
|
389
390
|
const prdsDir = await mkTmpPrdsDir();
|
|
391
|
+
// The existence guard survives PRD 832: whatever NN the allocator picks,
|
|
392
|
+
// a pre-existing file at that exact destination must 409, never clobber.
|
|
390
393
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
391
|
-
|
|
394
|
+
const realRead = remote.readPrd.bind(remote);
|
|
395
|
+
remote.readPrd = async (slug) => (slug.endsWith('my-explicit-slug') ? { ok: true, text: 'ORIGINAL' } : realRead(slug));
|
|
396
|
+
const writes = [];
|
|
397
|
+
const realWrite = remote.writePrd.bind(remote);
|
|
398
|
+
remote.writePrd = async (...a) => { writes.push(a[0]); return realWrite(...a); };
|
|
392
399
|
|
|
393
|
-
const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug'
|
|
400
|
+
const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug' }), remote);
|
|
394
401
|
|
|
395
402
|
expect(result.ok).toBe(false);
|
|
396
403
|
expect(result.status).toBe(409);
|
|
397
|
-
|
|
398
|
-
|
|
404
|
+
expect(writes).toEqual([]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ──────────────────────────────────────────── PRD 825: Epic PRD dir write grant + `~` cwd normalization
|
|
408
|
+
|
|
409
|
+
test('validateWrite allows writes under <root>/session-manager-operations/scheduler/epics/<id>/prds/', async () => {
|
|
410
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-scheduler-epics-'));
|
|
411
|
+
config.addAllowedRoot(root);
|
|
412
|
+
const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'epic-abc123', 'prds');
|
|
413
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
414
|
+
const filePath = path.join(prdsDir, '1-do-thing.md');
|
|
415
|
+
|
|
416
|
+
await config.writeTextAtomic(filePath, 'PRD body\n');
|
|
417
|
+
|
|
418
|
+
expect(fs.existsSync(filePath)).toBeTruthy();
|
|
419
|
+
expect(await fsp.readFile(filePath, 'utf8')).toBe('PRD body\n');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('createPrd normalizes a `~`-prefixed cwd via expandHome before calling remote', async () => {
|
|
423
|
+
const receivedCwds = [];
|
|
424
|
+
const remote = {
|
|
425
|
+
async allocateParallelGroup(cwd) { receivedCwds.push(cwd); return 42; },
|
|
426
|
+
async readPrd(slug, cwd) { receivedCwds.push(cwd); return { ok: false }; },
|
|
427
|
+
async writePrd(slug, body, cwd) { receivedCwds.push(cwd); return { ok: true, bytesWritten: body.length }; },
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const result = await createPrd(validCreateBody({ cwd: '~' }), remote);
|
|
431
|
+
|
|
432
|
+
expect(result.ok).toBe(true);
|
|
433
|
+
expect(receivedCwds.length).toBeGreaterThan(0);
|
|
434
|
+
for (const cwd of receivedCwds) {
|
|
435
|
+
expect(cwd.startsWith('~')).toBe(false);
|
|
436
|
+
expect(cwd).toBe(os.homedir());
|
|
437
|
+
}
|
|
399
438
|
});
|
|
400
439
|
|
|
401
440
|
test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
|
|
@@ -405,10 +444,10 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
|
|
|
405
444
|
try {
|
|
406
445
|
const res = await request(port, {
|
|
407
446
|
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
408
|
-
body: validCreateBody({ slug: 'from-a-ticket',
|
|
447
|
+
body: validCreateBody({ slug: 'from-a-ticket', sourcePromptId: 'ticket-xyz-789' }),
|
|
409
448
|
});
|
|
410
449
|
expect(res.status).toBe(200);
|
|
411
|
-
const written = fs.readFileSync(path.join(prdsDir,
|
|
450
|
+
const written = fs.readFileSync(path.join(prdsDir, `${res.json.nn}-from-a-ticket.md`), 'utf8');
|
|
412
451
|
expect(written).toMatch(/sourcePromptId: ticket-xyz-789/);
|
|
413
452
|
} finally {
|
|
414
453
|
await admin.stop();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdSourcePromptIdBackfill.test.cjs — PRD 830: a PRD file's location under
|
|
3
|
+
* `.../scheduler/epics/<epicId>/prds/<slug>.md` already IS its Epic
|
|
4
|
+
* membership, so ingest should stamp `sourcePromptId = <epicId>` whenever
|
|
5
|
+
* frontmatter doesn't supply one — instead of leaving it null forever.
|
|
6
|
+
*
|
|
7
|
+
* Covers:
|
|
8
|
+
* - deriveEpicIdFromPrdPath: pure path-shape extraction (prdLocations.cjs)
|
|
9
|
+
* - parsePrdRaw: dir-derived stamping + frontmatter-wins precedence
|
|
10
|
+
* - reconcileSourcePromptId: pending-only backfill (scheduler.cjs)
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdSourcePromptIdBackfill.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect } from 'vitest';
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { deriveEpicIdFromPrdPath, resolveEpicPrdWriteDir } = require('../lib/prdLocations.cjs');
|
|
23
|
+
const { parsePrdRaw, _resetCache } = require('../scheduler/prdParser.cjs');
|
|
24
|
+
const { reconcileSourcePromptId } = require('../scheduler.cjs');
|
|
25
|
+
|
|
26
|
+
async function mkProjectCwd() {
|
|
27
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-backfill-'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------- deriveEpicIdFromPrdPath ----------
|
|
31
|
+
|
|
32
|
+
test('deriveEpicIdFromPrdPath extracts the epic id from a well-formed epic PRD path', async () => {
|
|
33
|
+
const cwd = await mkProjectCwd();
|
|
34
|
+
const epicId = 'some-epic-abc123';
|
|
35
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
36
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
37
|
+
const filePath = path.join(dir, '900-example.md');
|
|
38
|
+
expect(deriveEpicIdFromPrdPath(filePath)).toBe(epicId);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('deriveEpicIdFromPrdPath returns null for the retired flat prds/ layout', () => {
|
|
42
|
+
const filePath = '/home/user/Projects/foo/session-manager-operations/scheduler/prds/900-example.md';
|
|
43
|
+
expect(deriveEpicIdFromPrdPath(filePath)).toBe(null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('deriveEpicIdFromPrdPath returns null for an unrelated path', () => {
|
|
47
|
+
expect(deriveEpicIdFromPrdPath('/tmp/whatever/900-example.md')).toBe(null);
|
|
48
|
+
expect(deriveEpicIdFromPrdPath(null)).toBe(null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------- parsePrdRaw: dir-derived stamping + frontmatter-wins ----------
|
|
52
|
+
|
|
53
|
+
test('parsePrdRaw derives sourcePromptId from the parent epic dir when frontmatter omits it', async () => {
|
|
54
|
+
_resetCache();
|
|
55
|
+
const cwd = await mkProjectCwd();
|
|
56
|
+
const epicId = 'derive-me-epic-def456';
|
|
57
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
58
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
59
|
+
const filePath = path.join(dir, '901-hand-authored.md');
|
|
60
|
+
await fsp.writeFile(filePath, [
|
|
61
|
+
'---',
|
|
62
|
+
'title: Hand-authored, no sourcePromptId',
|
|
63
|
+
`cwd: ${cwd}`,
|
|
64
|
+
'---',
|
|
65
|
+
'# Goal',
|
|
66
|
+
'',
|
|
67
|
+
'Do the thing.',
|
|
68
|
+
'',
|
|
69
|
+
].join('\n'));
|
|
70
|
+
|
|
71
|
+
const parsed = await parsePrdRaw(filePath);
|
|
72
|
+
expect(parsed.sourcePromptId).toBe(epicId);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('parsePrdRaw: explicit frontmatter sourcePromptId wins over the dir-derived epic id', async () => {
|
|
76
|
+
_resetCache();
|
|
77
|
+
const cwd = await mkProjectCwd();
|
|
78
|
+
const epicId = 'dir-epic-ghi789';
|
|
79
|
+
const dir = resolveEpicPrdWriteDir(cwd, epicId);
|
|
80
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
81
|
+
const filePath = path.join(dir, '902-explicit.md');
|
|
82
|
+
await fsp.writeFile(filePath, [
|
|
83
|
+
'---',
|
|
84
|
+
'title: Explicit sourcePromptId',
|
|
85
|
+
`cwd: ${cwd}`,
|
|
86
|
+
'sourcePromptId: ticket-explicit-999',
|
|
87
|
+
'---',
|
|
88
|
+
'# Goal',
|
|
89
|
+
'',
|
|
90
|
+
'Do the thing.',
|
|
91
|
+
'',
|
|
92
|
+
].join('\n'));
|
|
93
|
+
|
|
94
|
+
const parsed = await parsePrdRaw(filePath);
|
|
95
|
+
expect(parsed.sourcePromptId).toBe('ticket-explicit-999');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------- reconcileSourcePromptId: pending-only backfill ----------
|
|
99
|
+
|
|
100
|
+
test('reconcileSourcePromptId backfills a pending job whose sourcePromptId is null', () => {
|
|
101
|
+
const job = { slug: 'x', status: 'pending', sourcePromptId: null };
|
|
102
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe('epic-abc');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('reconcileSourcePromptId leaves a running job untouched even if its sourcePromptId is null', () => {
|
|
106
|
+
const job = { slug: 'x', status: 'running', sourcePromptId: null };
|
|
107
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('reconcileSourcePromptId leaves a completed job untouched even if its sourcePromptId is null', () => {
|
|
111
|
+
const job = { slug: 'x', status: 'completed', sourcePromptId: null };
|
|
112
|
+
expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('reconcileSourcePromptId keeps a pending job\'s existing value if the fresh parse yields null', () => {
|
|
116
|
+
const job = { slug: 'x', status: 'pending', sourcePromptId: 'already-set' };
|
|
117
|
+
expect(reconcileSourcePromptId(job, null)).toBe('already-set');
|
|
118
|
+
});
|
|
@@ -146,11 +146,13 @@ test('readHistory respects limit and returns [] when file is absent', async () =
|
|
|
146
146
|
const empty = await queueHistory.readHistory({ limit: 5 });
|
|
147
147
|
expect(empty).toEqual([]);
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// Realistic append order: oldest finishedAt first (append-only file grows
|
|
150
|
+
// forward in time), so s7 is both last-appended and newest.
|
|
151
|
+
const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - (7 - i) * 60_000).toISOString() }));
|
|
150
152
|
await queueHistory.appendHistory(entries);
|
|
151
153
|
const read = await queueHistory.readHistory({ limit: 3 });
|
|
152
154
|
expect(read.length).toBe(3);
|
|
153
|
-
//
|
|
155
|
+
// Federated readHistory (2026-07-31) orders by finishedAt newest-first.
|
|
154
156
|
expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
|
|
155
157
|
});
|
|
156
158
|
|
|
@@ -165,8 +167,11 @@ test('readHistory tail-reads correctly when the file is larger than one chunk',
|
|
|
165
167
|
}));
|
|
166
168
|
await queueHistory.appendHistory(bigEntries);
|
|
167
169
|
const read = await queueHistory.readHistory({ limit: 5 });
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
+
// The tail read only touches the file's end; whatever was read comes back
|
|
171
|
+
// ordered by finishedAt newest-first.
|
|
172
|
+
expect(read.length).toBe(5);
|
|
173
|
+
const t = (j) => Date.parse(j.finishedAt);
|
|
174
|
+
for (let i = 1; i < read.length; i++) expect(t(read[i - 1])).toBeGreaterThanOrEqual(t(read[i]));
|
|
170
175
|
});
|
|
171
176
|
|
|
172
177
|
// ---------- selectHistoryJobs merge (scheduler.cjs) ----------
|
|
@@ -140,3 +140,15 @@ test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async ()
|
|
|
140
140
|
const result = await autoArchiveCompleted({}, { nowMs: NOW });
|
|
141
141
|
expect(result.archived).toBe(0);
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
// ---------- archiving never strands a runnable queue job (scheduler PRD:
|
|
145
|
+
// archived-prd-skip — job 822-epics-nav-rename fired ENOENT after its PRD
|
|
146
|
+
// was archived out from under a still-queued job) ----------
|
|
147
|
+
|
|
148
|
+
test('selectAutoArchivable never selects a slug whose job is still pending or running, so auto-archive can never leave a runnable job pointing at an archived PRD', () => {
|
|
149
|
+
const pending = old({ slug: '13-pending', status: 'pending', finishedAt: null });
|
|
150
|
+
const running = old({ slug: '14-running', status: 'running', finishedAt: null });
|
|
151
|
+
const completed = old({ slug: '15-done', status: 'completed' });
|
|
152
|
+
const slugs = selectAutoArchivable([pending, running, completed], { nowMs: NOW });
|
|
153
|
+
expect(slugs).toEqual(['15-done']);
|
|
154
|
+
});
|