claude-code-session-manager 0.64.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-Dp3-_bjQ.js → History-CNH9vA0A.js} +2 -2
- package/dist/assets/{Hooks-DMQFgGDd.js → Hooks-CyTksPza.js} +3 -3
- package/dist/assets/{HostBilko-zNU1NtBh.js → HostBilko-DXQVDNHn.js} +1 -1
- package/dist/assets/{Library-BrODBbeL.js → Library-9E2UOIsm.js} +1 -1
- package/dist/assets/{ListDetail-C5vbA1gC.js → ListDetail-CQWU_Yn5.js} +1 -1
- package/dist/assets/{MarkdownEditor-CpzAEiiG.js → MarkdownEditor-DQgfpSef.js} +1 -1
- package/dist/assets/{McpServers-DWuw-flj.js → McpServers-r3qwDIj2.js} +2 -2
- package/dist/assets/{Memory-B4TC9SsJ.js → Memory-BaxOpj-3.js} +6 -6
- package/dist/assets/{Panel-Bap7G-pI.js → Panel-BhFD8Lqo.js} +1 -1
- package/dist/assets/{Permissions-BIpNWItH.js → Permissions-Cj-mODQQ.js} +3 -3
- package/dist/assets/{Plugins-DrwklmW4.js → Plugins-DEL3Fqng.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CCp_EhjV.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
- package/dist/assets/Scheduler-YOuZKkES.js +14 -0
- package/dist/assets/{ScopeSwitcher-BlQvpyLE.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
- package/dist/assets/{Settings-BzvmZvGa.js → Settings-B0x4oflz.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CGtwmCjm.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
- package/dist/assets/{Skills-Cauvbtk7.js → Skills-CN8R6AWn.js} +2 -2
- package/dist/assets/{SystemPrompt-BqzM76PV.js → SystemPrompt-DceChpAi.js} +1 -1
- package/dist/assets/{TagLibrary-DVKPjeRu.js → TagLibrary-BKmz2W7B.js} +1 -1
- package/dist/assets/{TiptapBody-CUWdBAYf.js → TiptapBody-W7n5SwPM.js} +1 -1
- package/dist/assets/{Toggle-Bos67yDJ.js → Toggle-QuxlVHGI.js} +1 -1
- package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
- package/dist/assets/{index-BfPVknuV.js → index-TejhHSzN.js} +526 -525
- package/dist/assets/{settingsSchema-DKuyaEK5.js → settingsSchema-DNqx6BKJ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +120 -0
- package/plugins/session-manager-dev/skills/builder/SKILL.md +13 -3
- 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-9KJ4fedH.js +0 -1
- package/dist/assets/Scheduler-DfoTegGG.js +0 -14
- /package/plugins/session-manager-dev/skills/builder/{4-report → 5-report}/SKILL.md +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flatPrdTickSweep.test.cjs — the retired flat `scheduler/prds/` dir must be
|
|
3
|
+
* TRULY inert, not just documented as such.
|
|
4
|
+
*
|
|
5
|
+
* Prior to this PRD, `runPrdMigration()`'s consolidation of the flat dir into
|
|
6
|
+
* `prds-archived/` ran ONLY at Electron boot (src/main/scheduler.cjs). But
|
|
7
|
+
* `prdLocations.cjs`'s `resolvePrdsDirs()` unconditionally keeps scanning the
|
|
8
|
+
* flat dir as a live PRD source, and `reconcile()` runs on every tick — so a
|
|
9
|
+
* PRD hand-written into the flat dir *after* boot sat there live and WOULD
|
|
10
|
+
* get reconciled into a pending job and executed (observed 2026-08-07:
|
|
11
|
+
* 1021/1022 PRDs in social-signals-trader executed once their status was
|
|
12
|
+
* repaired), contradicting the develop skill's documented claim that
|
|
13
|
+
* anything landed there is "auto-consolidated ... WITHOUT being executed."
|
|
14
|
+
*
|
|
15
|
+
* The fix: `consolidateAllFlatPrds()` now also runs at the START of every
|
|
16
|
+
* `reconcile()` call itself (not just one caller like tickQueue's poll — every
|
|
17
|
+
* caller of reconcile gets the guarantee), BEFORE reconcile scans the flat
|
|
18
|
+
* dir. A freshly hand-written PRD has no queue row yet (reconcile hasn't
|
|
19
|
+
* seen it), so it is never in LIVE_JOB_STATUSES — this sweep archives it
|
|
20
|
+
* before reconcile ever gets a chance to turn it into a job. This test
|
|
21
|
+
* proves that invariant directly against `consolidateAllFlatPrds`: a file
|
|
22
|
+
* with no matching queue job is swept into `prds-archived/` on the very next
|
|
23
|
+
* call, which prdLocations.cjs's `resolvePrdsDirs()` never scans as a live
|
|
24
|
+
* source — so it can never be reconciled into a job afterward.
|
|
25
|
+
*
|
|
26
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/flatPrdTickSweep.test.cjs
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
import { test, expect, afterEach } from 'vitest';
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const fsp = require('node:fs/promises');
|
|
34
|
+
const os = require('node:os');
|
|
35
|
+
const path = require('node:path');
|
|
36
|
+
const { consolidateAllFlatPrds } = require('../scheduler.cjs');
|
|
37
|
+
const { resolvePrdWriteDir, resolvePrdsDirs } = require('../lib/prdLocations.cjs');
|
|
38
|
+
|
|
39
|
+
const tmpDirs = [];
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
while (tmpDirs.length) {
|
|
42
|
+
const d = tmpDirs.pop();
|
|
43
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function newProjectRoot() {
|
|
48
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flat-tick-sweep-'));
|
|
49
|
+
tmpDirs.push(d);
|
|
50
|
+
return d;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('a PRD hand-written into the retired flat dir after boot is swept before it could ever be reconciled', async () => {
|
|
54
|
+
const cwd = newProjectRoot();
|
|
55
|
+
const flatDir = resolvePrdWriteDir(cwd);
|
|
56
|
+
await fsp.mkdir(flatDir, { recursive: true });
|
|
57
|
+
const filename = '999-hand-written-bypass.md';
|
|
58
|
+
await fsp.writeFile(
|
|
59
|
+
path.join(flatDir, filename),
|
|
60
|
+
`---\ntitle: Hand-written bypass\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n\nSomething.\n`,
|
|
61
|
+
'utf8',
|
|
62
|
+
);
|
|
63
|
+
// No queue.json at all yet — this is exactly the state of a PRD dropped on
|
|
64
|
+
// disk moments ago, before any reconcile() pass has scanned it into a job.
|
|
65
|
+
|
|
66
|
+
await consolidateAllFlatPrds([cwd]);
|
|
67
|
+
|
|
68
|
+
// Swept out of the live flat dir...
|
|
69
|
+
expect(fs.existsSync(path.join(flatDir, filename))).toBe(false);
|
|
70
|
+
// ...and into prds-archived/, which resolvePrdsDirs() never returns as a
|
|
71
|
+
// scan source — so no future reconcile() pass can find it and queue it.
|
|
72
|
+
const archivedPath = path.join(path.dirname(flatDir), 'prds-archived', filename);
|
|
73
|
+
expect(fs.existsSync(archivedPath)).toBe(true);
|
|
74
|
+
|
|
75
|
+
const liveDirs = resolvePrdsDirs(Infinity, { projectsDir: path.join(cwd, '.nonexistent') });
|
|
76
|
+
expect(liveDirs.every((d) => !d.includes('prds-archived'))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('consolidateAllFlatPrds still protects a PRD that already has a live queue job (PRD 992 invariant preserved)', async () => {
|
|
80
|
+
const cwd = newProjectRoot();
|
|
81
|
+
const flatDir = resolvePrdWriteDir(cwd);
|
|
82
|
+
await fsp.mkdir(flatDir, { recursive: true });
|
|
83
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
84
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
85
|
+
await fsp.writeFile(
|
|
86
|
+
path.join(flatDir, '998-live.md'),
|
|
87
|
+
`---\ntitle: Live\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n`,
|
|
88
|
+
'utf8',
|
|
89
|
+
);
|
|
90
|
+
await fsp.writeFile(
|
|
91
|
+
path.join(stateDir, 'queue.json'),
|
|
92
|
+
JSON.stringify({ jobs: [{ slug: '998-live', status: 'running' }] }),
|
|
93
|
+
'utf8',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await consolidateAllFlatPrds([cwd]);
|
|
97
|
+
|
|
98
|
+
expect(fs.existsSync(path.join(flatDir, '998-live.md'))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('consolidateAllFlatPrds never throws when a project errors — one bad cwd does not block the sweep of the rest', async () => {
|
|
102
|
+
const good = newProjectRoot();
|
|
103
|
+
const flatDir = resolvePrdWriteDir(good);
|
|
104
|
+
await fsp.mkdir(flatDir, { recursive: true });
|
|
105
|
+
await fsp.writeFile(path.join(flatDir, '997-ok.md'), `---\ntitle: Ok\ncwd: ${good}\nestimateMinutes: 5\n---\n\n# Goal\n`, 'utf8');
|
|
106
|
+
|
|
107
|
+
await expect(consolidateAllFlatPrds([null, good])).resolves.toBeUndefined();
|
|
108
|
+
|
|
109
|
+
expect(fs.existsSync(path.join(flatDir, '997-ok.md'))).toBe(false);
|
|
110
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdAdminRouteParity.test.cjs — capability-parity gate for PRD 1024 ("lock
|
|
3
|
+
* the PRD entity behind the scheduler service boundary, step 1").
|
|
4
|
+
*
|
|
5
|
+
* Enumerates every PRD/job mutation the codebase can perform (the full set
|
|
6
|
+
* of `schedule:*` IPC channels that read/write a PRD file or a queue job,
|
|
7
|
+
* plus create-prd and cancel-job which have no IPC twin) and asserts each
|
|
8
|
+
* one has a corresponding admin HTTP route registered by scheduler.cjs's
|
|
9
|
+
* registerAdminRoutes, prdCreate.cjs's registerAdminRoute, or
|
|
10
|
+
* prdAdminRoutes.cjs's registerAdminRoute. A future PRD-lifecycle capability
|
|
11
|
+
* added only as an IPC handler (filesystem-only from the admin API's point
|
|
12
|
+
* of view) fails this test until an admin route is added for it too — that
|
|
13
|
+
* is the whole point: the admin API must never fall behind what's actually
|
|
14
|
+
* possible against a PRD/job.
|
|
15
|
+
*
|
|
16
|
+
* Route registration is captured via a fake `adminHttp` stub (just
|
|
17
|
+
* `registerRoute`) — no real HTTP server or Electron boot needed, matching
|
|
18
|
+
* how prdCreate.test.cjs already verifies wiring.
|
|
19
|
+
*
|
|
20
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdAdminRouteParity.test.cjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import { test, expect } from 'vitest';
|
|
26
|
+
const scheduler = require('../scheduler.cjs');
|
|
27
|
+
const prdCreate = require('../lib/prdCreate.cjs');
|
|
28
|
+
const prdAdminRoutes = require('../lib/prdAdminRoutes.cjs');
|
|
29
|
+
|
|
30
|
+
// Every PRD-lifecycle capability the codebase can perform, mapped to the
|
|
31
|
+
// admin HTTP route that must exist for it. `ipcChannel` is informational
|
|
32
|
+
// (documents the renderer-facing twin, if any) — the assertion is only
|
|
33
|
+
// against `adminRoute`.
|
|
34
|
+
const REQUIRED_CAPABILITIES = [
|
|
35
|
+
{ capability: 'create a PRD', ipcChannel: null, adminRoute: 'POST /admin/scheduler/create-prd' },
|
|
36
|
+
{ capability: 'list PRDs (live + archived)', ipcChannel: 'schedule:list-prds', adminRoute: 'GET /admin/scheduler/prds' },
|
|
37
|
+
{ capability: 'read one PRD (body + frontmatter)', ipcChannel: 'schedule:read-prd', adminRoute: 'GET /admin/scheduler/prd' },
|
|
38
|
+
{ capability: 'edit a not-yet-running PRD', ipcChannel: 'schedule:write-prd', adminRoute: 'POST /admin/scheduler/update-prd' },
|
|
39
|
+
{ capability: 'archive a PRD', ipcChannel: 'schedule:archive-prd', adminRoute: 'POST /admin/scheduler/archive-prd' },
|
|
40
|
+
{ capability: 'retag a PRD (parallelGroup/estimateMinutes)', ipcChannel: 'schedule:retag-prd', adminRoute: 'POST /admin/scheduler/retag-prd' },
|
|
41
|
+
{ capability: 'list scheduler jobs', ipcChannel: null, adminRoute: 'GET /admin/scheduler/jobs' },
|
|
42
|
+
{ capability: 'reset a stuck job', ipcChannel: 'schedule:reset-job', adminRoute: 'POST /admin/scheduler/reset-job' },
|
|
43
|
+
{ capability: 'cancel a not-yet-terminal job', ipcChannel: null, adminRoute: 'POST /admin/scheduler/cancel-job' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function captureRegisteredRoutes() {
|
|
47
|
+
const registered = new Set();
|
|
48
|
+
const fakeAdminHttp = {
|
|
49
|
+
registerRoute(method, url) {
|
|
50
|
+
registered.add(`${method} ${url}`);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
scheduler.registerAdminRoutes(fakeAdminHttp, scheduler.remote);
|
|
54
|
+
prdCreate.registerAdminRoute(fakeAdminHttp, scheduler.remote);
|
|
55
|
+
prdAdminRoutes.registerAdminRoute(fakeAdminHttp, scheduler.remote);
|
|
56
|
+
return registered;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('every enumerated PRD/job mutation capability has a registered admin route', () => {
|
|
60
|
+
const registered = captureRegisteredRoutes();
|
|
61
|
+
const missing = REQUIRED_CAPABILITIES.filter((c) => !registered.has(c.adminRoute));
|
|
62
|
+
expect(missing).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each(REQUIRED_CAPABILITIES)('capability "$capability" → $adminRoute is registered', ({ adminRoute }) => {
|
|
66
|
+
const registered = captureRegisteredRoutes();
|
|
67
|
+
expect(registered.has(adminRoute)).toBe(true);
|
|
68
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdAdminRoutes.test.cjs — unit tests for PRD 1024's admin HTTP routes
|
|
3
|
+
* (list/get/update/archive/cancel/retag) and the scheduler.cjs `remote`
|
|
4
|
+
* methods they delegate to. Exercises real HTTP round-trips against a real
|
|
5
|
+
* (temp-HOME) filesystem, matching prdCreate.test.cjs's pattern for the
|
|
6
|
+
* create-prd route.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdAdminRoutes.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const fsp = require('node:fs/promises');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const http = require('node:http');
|
|
19
|
+
|
|
20
|
+
let tmpHome;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let scheduler;
|
|
23
|
+
let prdAdminRoutes;
|
|
24
|
+
let config;
|
|
25
|
+
let createAdminHttp;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-admin-prd-routes-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
|
|
32
|
+
// Load AFTER HOME is stubbed — scheduler.cjs/config.cjs snapshot
|
|
33
|
+
// os.homedir() into module-level constants at load time, not lazily.
|
|
34
|
+
scheduler = require('../scheduler.cjs');
|
|
35
|
+
prdAdminRoutes = require('../lib/prdAdminRoutes.cjs');
|
|
36
|
+
config = require('../config.cjs');
|
|
37
|
+
({ createAdminHttp } = require('../lib/localAdminHttp.cjs'));
|
|
38
|
+
|
|
39
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
40
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds, and
|
|
44
|
+
// prdLocations.cjs's resolvePrdsDirs) discover project cwds by scanning
|
|
45
|
+
// ~/.claude/projects/*/*.jsonl for a `cwd` field — fake one project
|
|
46
|
+
// transcript pointing at the real on-disk fixture cwd this file uses,
|
|
47
|
+
// same pattern as scheduler-verify-prd-path.test.cjs.
|
|
48
|
+
const projDir = path.join(tmpHome, '.claude', 'projects', 'fake-project-slug');
|
|
49
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(projDir, 'session.jsonl'),
|
|
52
|
+
`${JSON.stringify({ cwd: path.join(tmpHome, 'fake-project-cwd') })}\n`,
|
|
53
|
+
'utf8',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
process.env.HOME = originalHome;
|
|
59
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function projectCwd() {
|
|
63
|
+
const cwd = path.join(tmpHome, 'fake-project-cwd');
|
|
64
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
65
|
+
config.addAllowedRoot(cwd);
|
|
66
|
+
return cwd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function uniqueSlug(prefix) {
|
|
70
|
+
return `${prefix}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function writePrdFixture({ cwd, epicId, slug, extra = '' }) {
|
|
74
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
75
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
76
|
+
const filePath = path.join(prdsDir, `${slug}.md`);
|
|
77
|
+
const body = `---\ntitle: fixture title\ncwd: ${cwd}\nestimateMinutes: 10\n${extra}---\n\n# Goal\n\ndo the thing\n`;
|
|
78
|
+
await fsp.writeFile(filePath, body, 'utf8');
|
|
79
|
+
return filePath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const headers = {};
|
|
85
|
+
if (token !== undefined) headers.Authorization = `Bearer ${token}`;
|
|
86
|
+
let payload;
|
|
87
|
+
if (body !== undefined) {
|
|
88
|
+
payload = JSON.stringify(body);
|
|
89
|
+
headers['Content-Type'] = 'application/json';
|
|
90
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
91
|
+
}
|
|
92
|
+
const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
res.on('data', (c) => chunks.push(c));
|
|
95
|
+
res.on('end', () => {
|
|
96
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
97
|
+
let json = null;
|
|
98
|
+
try { json = JSON.parse(text); } catch { /* leave null */ }
|
|
99
|
+
resolve({ status: res.statusCode, json, text });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
req.on('error', reject);
|
|
103
|
+
if (payload) req.write(payload);
|
|
104
|
+
req.end();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function startAdmin() {
|
|
109
|
+
const prevE2e = process.env.SM_E2E;
|
|
110
|
+
process.env.SM_E2E = '1';
|
|
111
|
+
const admin = createAdminHttp();
|
|
112
|
+
prdAdminRoutes.registerAdminRoute(admin, scheduler.remote);
|
|
113
|
+
const { port, token } = await admin.start();
|
|
114
|
+
if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
|
|
115
|
+
return { admin, port, token };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test('GET /admin/scheduler/prds without token returns 401', async () => {
|
|
119
|
+
const { admin, port } = await startAdmin();
|
|
120
|
+
try {
|
|
121
|
+
const res = await request(port, { path: '/admin/scheduler/prds' });
|
|
122
|
+
expect(res.status).toBe(401);
|
|
123
|
+
} finally {
|
|
124
|
+
await admin.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('GET /admin/scheduler/prds lists a live PRD with status null (no queue row yet), filterable by cwd', async () => {
|
|
129
|
+
const cwd = projectCwd();
|
|
130
|
+
const epicId = 'epic-list';
|
|
131
|
+
const slug = uniqueSlug('list-prds');
|
|
132
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
133
|
+
|
|
134
|
+
const { admin, port, token } = await startAdmin();
|
|
135
|
+
try {
|
|
136
|
+
const res = await request(port, { path: `/admin/scheduler/prds?cwd=${encodeURIComponent(cwd)}`, token });
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
expect(res.json.ok).toBe(true);
|
|
139
|
+
const entry = res.json.prds.find((p) => p.slug === slug);
|
|
140
|
+
expect(entry).toBeTruthy();
|
|
141
|
+
expect(entry.status).toBe(null);
|
|
142
|
+
expect(entry.epicId).toBe(epicId);
|
|
143
|
+
} finally {
|
|
144
|
+
await admin.stop();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('GET /admin/scheduler/prd?slug=&cwd= returns parsed frontmatter + body', async () => {
|
|
149
|
+
const cwd = projectCwd();
|
|
150
|
+
const epicId = 'epic-get';
|
|
151
|
+
const slug = uniqueSlug('get-prd');
|
|
152
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
153
|
+
|
|
154
|
+
const { admin, port, token } = await startAdmin();
|
|
155
|
+
try {
|
|
156
|
+
const res = await request(port, { path: `/admin/scheduler/prd?slug=${slug}&cwd=${encodeURIComponent(cwd)}`, token });
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
expect(res.json.ok).toBe(true);
|
|
159
|
+
expect(res.json.frontmatter.title).toBe('fixture title');
|
|
160
|
+
expect(res.json.body).toMatch(/do the thing/);
|
|
161
|
+
} finally {
|
|
162
|
+
await admin.stop();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('GET /admin/scheduler/prd for a nonexistent slug returns 404', async () => {
|
|
167
|
+
const cwd = projectCwd();
|
|
168
|
+
const { admin, port, token } = await startAdmin();
|
|
169
|
+
try {
|
|
170
|
+
const res = await request(port, { path: `/admin/scheduler/prd?slug=${uniqueSlug('nope')}&cwd=${encodeURIComponent(cwd)}`, token });
|
|
171
|
+
expect(res.status).toBe(404);
|
|
172
|
+
expect(res.json.ok).toBe(false);
|
|
173
|
+
} finally {
|
|
174
|
+
await admin.stop();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('POST /admin/scheduler/update-prd edits a not-yet-queued PRD and preserves unrecognized frontmatter keys', async () => {
|
|
179
|
+
const cwd = projectCwd();
|
|
180
|
+
const epicId = 'epic-update';
|
|
181
|
+
const slug = uniqueSlug('update-prd');
|
|
182
|
+
await writePrdFixture({ cwd, epicId, slug, extra: 'dependsOn: [some-other-slug]\n' });
|
|
183
|
+
|
|
184
|
+
const { admin, port, token } = await startAdmin();
|
|
185
|
+
try {
|
|
186
|
+
const res = await request(port, {
|
|
187
|
+
method: 'POST', path: '/admin/scheduler/update-prd', token,
|
|
188
|
+
body: { slug, cwd, frontmatter: { title: 'edited title', estimateMinutes: 25 } },
|
|
189
|
+
});
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.json.ok).toBe(true);
|
|
192
|
+
|
|
193
|
+
const raw = await fsp.readFile(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds', `${slug}.md`), 'utf8');
|
|
194
|
+
expect(raw).toMatch(/title: edited title/);
|
|
195
|
+
expect(raw).toMatch(/estimateMinutes: 25/);
|
|
196
|
+
// Unrecognized key round-trips verbatim.
|
|
197
|
+
expect(raw).toMatch(/dependsOn: \[some-other-slug\]/);
|
|
198
|
+
} finally {
|
|
199
|
+
await admin.stop();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('POST /admin/scheduler/update-prd refuses a slug whose job is running, naming the current status', async () => {
|
|
204
|
+
const cwd = projectCwd();
|
|
205
|
+
const epicId = 'epic-update-running';
|
|
206
|
+
const slug = uniqueSlug('update-prd-running');
|
|
207
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
208
|
+
|
|
209
|
+
await scheduler.writeQueue({
|
|
210
|
+
jobs: [{ slug, title: 'x', cwd, status: 'running', runtime: {} }],
|
|
211
|
+
config: {},
|
|
212
|
+
paused: null,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { admin, port, token } = await startAdmin();
|
|
216
|
+
try {
|
|
217
|
+
const res = await request(port, {
|
|
218
|
+
method: 'POST', path: '/admin/scheduler/update-prd', token,
|
|
219
|
+
body: { slug, cwd, frontmatter: { title: 'should not land' } },
|
|
220
|
+
});
|
|
221
|
+
expect(res.status).toBe(409);
|
|
222
|
+
expect(res.json.ok).toBe(false);
|
|
223
|
+
expect(res.json.error).toMatch(/running/);
|
|
224
|
+
} finally {
|
|
225
|
+
await admin.stop();
|
|
226
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('POST /admin/scheduler/cancel-job cancels a pending job (no cancelled status exists — lands as failed)', async () => {
|
|
231
|
+
const cwd = projectCwd();
|
|
232
|
+
const slug = uniqueSlug('cancel-pending');
|
|
233
|
+
await scheduler.writeQueue({
|
|
234
|
+
jobs: [{ slug, title: 'x', cwd, status: 'pending' }],
|
|
235
|
+
config: {},
|
|
236
|
+
paused: null,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { admin, port, token } = await startAdmin();
|
|
240
|
+
try {
|
|
241
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
|
|
242
|
+
expect(res.status).toBe(200);
|
|
243
|
+
expect(res.json.ok).toBe(true);
|
|
244
|
+
expect(res.json.status).toBe('failed');
|
|
245
|
+
expect(res.json.wasRunning).toBe(false);
|
|
246
|
+
} finally {
|
|
247
|
+
await admin.stop();
|
|
248
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('POST /admin/scheduler/cancel-job refuses an already-terminal job', async () => {
|
|
253
|
+
const cwd = projectCwd();
|
|
254
|
+
const slug = uniqueSlug('cancel-terminal');
|
|
255
|
+
await scheduler.writeQueue({
|
|
256
|
+
jobs: [{ slug, title: 'x', cwd, status: 'completed' }],
|
|
257
|
+
config: {},
|
|
258
|
+
paused: null,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const { admin, port, token } = await startAdmin();
|
|
262
|
+
try {
|
|
263
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
|
|
264
|
+
expect(res.status).toBe(409);
|
|
265
|
+
expect(res.json.ok).toBe(false);
|
|
266
|
+
expect(res.json.error).toMatch(/terminal/);
|
|
267
|
+
} finally {
|
|
268
|
+
await admin.stop();
|
|
269
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('POST /admin/scheduler/archive-prd moves a PRD to prds-archived/', async () => {
|
|
274
|
+
const cwd = projectCwd();
|
|
275
|
+
const epicId = 'epic-archive';
|
|
276
|
+
const slug = uniqueSlug('archive-prd');
|
|
277
|
+
const filePath = await writePrdFixture({ cwd, epicId, slug });
|
|
278
|
+
|
|
279
|
+
const { admin, port, token } = await startAdmin();
|
|
280
|
+
try {
|
|
281
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug] } });
|
|
282
|
+
expect(res.status).toBe(200);
|
|
283
|
+
expect(res.json.ok).toBe(true);
|
|
284
|
+
expect(res.json.archived).toBe(1);
|
|
285
|
+
expect(fs.existsSync(filePath)).toBe(false);
|
|
286
|
+
} finally {
|
|
287
|
+
await admin.stop();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('POST /admin/scheduler/retag-prd rewrites estimateMinutes frontmatter', async () => {
|
|
292
|
+
const cwd = projectCwd();
|
|
293
|
+
const epicId = 'epic-retag';
|
|
294
|
+
const slug = uniqueSlug('retag-prd');
|
|
295
|
+
const filePath = await writePrdFixture({ cwd, epicId, slug });
|
|
296
|
+
|
|
297
|
+
const { admin, port, token } = await startAdmin();
|
|
298
|
+
try {
|
|
299
|
+
const res = await request(port, {
|
|
300
|
+
method: 'POST', path: '/admin/scheduler/retag-prd', token,
|
|
301
|
+
body: { items: [{ slug, estimateMinutes: 42 }] },
|
|
302
|
+
});
|
|
303
|
+
expect(res.status).toBe(200);
|
|
304
|
+
expect(res.json.ok).toBe(true);
|
|
305
|
+
expect(res.json.retagged).toBe(1);
|
|
306
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
307
|
+
expect(raw).toMatch(/estimateMinutes: 42/);
|
|
308
|
+
} finally {
|
|
309
|
+
await admin.stop();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
@@ -221,7 +221,7 @@ test('POST /admin/scheduler/create-prd without token returns 401', async () => {
|
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
-
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename,
|
|
224
|
+
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, enqueued:false}', async () => {
|
|
225
225
|
const prdsDir = await mkTmpPrdsDir();
|
|
226
226
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
227
227
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -230,7 +230,12 @@ test('POST /admin/scheduler/create-prd with a valid payload writes a file with f
|
|
|
230
230
|
method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
|
|
231
231
|
});
|
|
232
232
|
expect(res.status).toBe(200);
|
|
233
|
-
|
|
233
|
+
// Never a 'status' field, and never the literal 'queued' — this route
|
|
234
|
+
// only writes the PRD file; the queue row is derived by the next
|
|
235
|
+
// reconcile() pass, not created here. See prdCreate.cjs's createPrd.
|
|
236
|
+
expect(res.json.status).toBeUndefined();
|
|
237
|
+
expect(res.json.enqueued).toBe(false);
|
|
238
|
+
expect(typeof res.json.note).toBe('string');
|
|
234
239
|
expect(typeof res.json.nn).toBe('number');
|
|
235
240
|
expect(res.json.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
|
|
236
241
|
|
|
@@ -250,6 +250,23 @@ test('consolidateFlatPrds archives a file with no job at all', async () => {
|
|
|
250
250
|
expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '701-orphan.md'))).toBe(true);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
// PRD 1024: an UNRECOGNIZED/invalid status (the exact 2026-08-07 1021/1022
|
|
254
|
+
// incident shape) must protect the file, not archive it — reconcile() is
|
|
255
|
+
// what repairs such a row back to 'pending', and this consolidation must
|
|
256
|
+
// never race that repair by moving the row's only source out from under it.
|
|
257
|
+
for (const invalidStatus of ['queued', 'cancelled', 'bogus-status', '']) {
|
|
258
|
+
test(`consolidateFlatPrds does NOT archive a file whose job has the unrecognized status "${invalidStatus}"`, async () => {
|
|
259
|
+
const root = newRoot();
|
|
260
|
+
const flat = await makeFlatProject(root, ['950-invalid-status.md']);
|
|
261
|
+
await writeQueue(root, [{ slug: '950-invalid-status', status: invalidStatus }]);
|
|
262
|
+
|
|
263
|
+
const r = await consolidateFlatPrds(root);
|
|
264
|
+
|
|
265
|
+
expect(r.moved).toBe(0);
|
|
266
|
+
expect(fs.existsSync(path.join(flat, '950-invalid-status.md'))).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
253
270
|
test('consolidateFlatPrds mixed dir: only the live one survives', async () => {
|
|
254
271
|
const root = newRoot();
|
|
255
272
|
const flat = await makeFlatProject(root, ['800-live.md', '801-done.md', '802-none.md']);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdMigrationLegacyAdopt.test.cjs — legacyAdoptExistingPrds() (PRD-authoring-
|
|
3
|
+
* lockdown rollout migration): every PRD .md already on disk when the
|
|
4
|
+
* provenance gate ships must be stamped `createdVia: legacy-adopted` before
|
|
5
|
+
* reconcile() ever runs its quarantine check against it, so the rollout
|
|
6
|
+
* itself never quarantines pre-existing work.
|
|
7
|
+
*
|
|
8
|
+
* HOME-isolated (mirrors scheduler-reconcile-quarantine.test.cjs): stamping
|
|
9
|
+
* relies on resolvePrdsDirs(), which discovers projects via
|
|
10
|
+
* ~/.claude/projects transcripts.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 60 npx vitest run src/main/__tests__/prdMigrationLegacyAdopt.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
let tmpHome;
|
|
23
|
+
let originalHome;
|
|
24
|
+
let legacyAdoptExistingPrds;
|
|
25
|
+
let parsePrdFile;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-legacy-adopt-home-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
({ legacyAdoptExistingPrds } = require('../lib/prdMigration.cjs'));
|
|
32
|
+
({ parsePrdFile } = require('../lib/prdFrontmatter.cjs'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
process.env.HOME = originalHome;
|
|
37
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function registerActiveProject(cwd) {
|
|
41
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
42
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
43
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeFixtureProject(prefix) {
|
|
48
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
49
|
+
registerActiveProject(cwd);
|
|
50
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
51
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
52
|
+
return { cwd, prdsDir };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('stamps a pre-existing unstamped PRD as createdVia: legacy-adopted', async () => {
|
|
56
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-proj-');
|
|
57
|
+
try {
|
|
58
|
+
const prdPath = path.join(prdsDir, '01-old.md');
|
|
59
|
+
fs.writeFileSync(prdPath, `---\ntitle: Old PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nOld work.\n`, 'utf8');
|
|
60
|
+
|
|
61
|
+
const result = await legacyAdoptExistingPrds();
|
|
62
|
+
expect(result.stamped).toBeGreaterThanOrEqual(1);
|
|
63
|
+
expect(result.failed).toEqual([]);
|
|
64
|
+
|
|
65
|
+
const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
|
|
66
|
+
expect(frontmatter.createdVia).toBe('legacy-adopted');
|
|
67
|
+
expect(typeof frontmatter.issuedAt).toBe('string');
|
|
68
|
+
} finally {
|
|
69
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('is idempotent — never overwrites an already-stamped PRD', async () => {
|
|
74
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-idempotent-');
|
|
75
|
+
try {
|
|
76
|
+
const prdPath = path.join(prdsDir, '02-already-stamped.md');
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
prdPath,
|
|
79
|
+
`---\ntitle: Already stamped\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-01T00:00:00.000Z\n---\n\n# Goal\nDone.\n`,
|
|
80
|
+
'utf8',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await legacyAdoptExistingPrds();
|
|
84
|
+
|
|
85
|
+
const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
|
|
86
|
+
expect(frontmatter.createdVia).toBe('scheduler-api'); // unchanged
|
|
87
|
+
expect(frontmatter.issuedAt).toBe('2026-08-01T00:00:00.000Z'); // unchanged
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|