claude-code-session-manager 0.37.1 → 0.37.2

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.
Files changed (37) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-uVGdpAGF.js} +498 -490
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/watchdogHelpers.cjs +296 -166
  11. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  12. package/src/main/__tests__/docEdit.test.cjs +164 -2
  13. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  14. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  15. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  16. package/src/main/browserAgentServer.cjs +11 -10
  17. package/src/main/chatRunner.cjs +21 -2
  18. package/src/main/docEdit.cjs +125 -5
  19. package/src/main/health.cjs +15 -0
  20. package/src/main/index.cjs +12 -6
  21. package/src/main/ipcSchemas.cjs +14 -0
  22. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  23. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  24. package/src/main/lib/localAdminHttp.cjs +157 -0
  25. package/src/main/lib/personaImportHealth.cjs +161 -0
  26. package/src/main/lib/prdCreate.cjs +107 -17
  27. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  28. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  29. package/src/main/scheduler.cjs +198 -54
  30. package/src/main/templates/PRD_AUTHORING.md +4 -4
  31. package/src/main/transcripts.cjs +1 -85
  32. package/src/preload/api.d.ts +12 -1
  33. package/src/preload/index.cjs +6 -0
  34. package/dist/assets/index-CTTjT08J.css +0 -32
  35. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  36. package/src/main/__tests__/adminServer.test.cjs +0 -380
  37. package/src/main/adminServer.cjs +0 -242
@@ -6,10 +6,27 @@
6
6
 
7
7
  'use strict';
8
8
 
9
- import { test, expect, describe } from 'vitest';
10
- const { parseDocEdit } = require('../docEdit.cjs');
9
+ import { test, expect, describe, beforeEach, vi } from 'vitest';
10
+
11
+ // docEdit.cjs requires chatRunner.cjs at module scope and calls `chatRunner.run(...)`
12
+ // as a property access, so patching `run` on the already-required (cached) module
13
+ // object — before docEdit.cjs's own require resolves to the same singleton — is
14
+ // enough to intercept it, mirroring the exchanges.cjs patch pattern in chatRunner.spec.ts.
15
+ const chatRunnerModule = require('../chatRunner.cjs');
16
+ const runCalls = [];
17
+ beforeEach(() => { runCalls.length = 0; });
18
+ chatRunnerModule.run = (opts) => { runCalls.push(opts); };
19
+
20
+ const { parseDocEdit, editPrompt, truncateDocumentText, MAX_DOC_CONTEXT, docEditViaSession, attachWindow } = require('../docEdit.cjs');
11
21
  const { duplicateNameFor } = require('../files.cjs');
12
22
 
23
+ const broadcasts = [];
24
+ attachWindow({
25
+ isDestroyed: () => false,
26
+ webContents: { isDestroyed: () => false, send: (channel, payload) => broadcasts.push({ channel, payload }) },
27
+ });
28
+ beforeEach(() => { broadcasts.length = 0; });
29
+
13
30
  describe('parseDocEdit', () => {
14
31
  test('valid JSON', () => {
15
32
  const result = parseDocEdit('{"after":"rewritten text"}');
@@ -57,6 +74,151 @@ describe('parseDocEdit', () => {
57
74
  });
58
75
  });
59
76
 
77
+ describe('editPrompt', () => {
78
+ test('omits the document block when documentText is absent', () => {
79
+ const prompt = editPrompt('selected text', 'make it concise');
80
+ expect(prompt).not.toMatch(/<document_/);
81
+ });
82
+
83
+ test('includes a nonce-tagged document block when documentText is provided', () => {
84
+ const prompt = editPrompt('selected text', 'make it concise', 'the whole document');
85
+ expect(prompt).toMatch(/<document_[0-9a-f]+>/);
86
+ expect(prompt).toContain('the whole document');
87
+ });
88
+
89
+ test('truncates an oversized documentText before embedding it', () => {
90
+ const huge = 'x'.repeat(MAX_DOC_CONTEXT + 5000);
91
+ const prompt = editPrompt('selected text', 'make it concise', huge);
92
+ expect(prompt).toContain('[...document truncated for length...]');
93
+ expect(prompt.length).toBeLessThan(huge.length + 2000);
94
+ });
95
+ });
96
+
97
+ describe('truncateDocumentText', () => {
98
+ test('passes short text through unchanged', () => {
99
+ expect(truncateDocumentText('short')).toBe('short');
100
+ });
101
+
102
+ test('truncates to the documented head+tail scheme rather than dropping or erroring', () => {
103
+ const head = 'H'.repeat(40000);
104
+ const tail = 'T'.repeat(20000);
105
+ const middle = 'M'.repeat(10000);
106
+ const result = truncateDocumentText(head + middle + tail);
107
+ expect(result).toBe(`${head}\n\n[...document truncated for length...]\n\n${tail}`);
108
+ expect(result).not.toContain('M');
109
+ });
110
+ });
111
+
112
+ describe('docEditViaSession', () => {
113
+ test('builds its prompt via the shared editPrompt and calls chatRunner.run with resume+silent', () => {
114
+ docEditViaSession({
115
+ tabId: 'tab-1',
116
+ sessionId: 'chat-session-1',
117
+ cwd: '/home/user/project',
118
+ before: 'old text',
119
+ instruction: 'make it punchier',
120
+ documentText: 'the whole document',
121
+ requestId: 'req-1',
122
+ });
123
+
124
+ expect(runCalls).toHaveLength(1);
125
+ const call = runCalls[0];
126
+ expect(call.tabId).toBe('tab-1');
127
+ expect(call.sessionId).toBe('chat-session-1');
128
+ expect(call.cwd).toBe('/home/user/project');
129
+ expect(call.resume).toBe(true);
130
+ expect(call.silent).toBe(true);
131
+ expect(typeof call.onSilentResult).toBe('function');
132
+
133
+ // The prompt is the shared editPrompt's output (nonce-tagged, so compare
134
+ // structurally rather than byte-for-byte) prefixed with the anti-injection
135
+ // system framing — not a second, forked prompt builder.
136
+ expect(call.prompt).toContain('Rewrite the SELECTION below per the INSTRUCTION');
137
+ expect(call.prompt).toMatch(/<selection_[0-9a-f]+>\nold text\n<\/selection_[0-9a-f]+>/);
138
+ expect(call.prompt).toContain('deterministic document-rewrite assistant');
139
+ });
140
+
141
+ test('onSilentResult parses the reply and broadcasts docedit:session-result', () => {
142
+ docEditViaSession({
143
+ tabId: 'tab-2',
144
+ sessionId: 'chat-session-2',
145
+ cwd: '/home/user/project',
146
+ before: 'old text',
147
+ instruction: 'shorten it',
148
+ requestId: 'req-2',
149
+ });
150
+
151
+ const { onSilentResult } = runCalls[runCalls.length - 1];
152
+ onSilentResult('{"after":"new text"}');
153
+ expect(broadcasts).toHaveLength(1);
154
+ expect(broadcasts[0]).toEqual({
155
+ channel: 'docedit:session-result',
156
+ payload: { tabId: 'tab-2', requestId: 'req-2', ok: true, after: 'new text' },
157
+ });
158
+ });
159
+
160
+ test('onSilentResult reports a parse failure as ok:false', () => {
161
+ docEditViaSession({
162
+ tabId: 'tab-3',
163
+ sessionId: 'chat-session-3',
164
+ cwd: '/home/user/project',
165
+ before: 'old text',
166
+ instruction: 'shorten it',
167
+ requestId: 'req-3',
168
+ });
169
+
170
+ const { onSilentResult } = runCalls[runCalls.length - 1];
171
+ onSilentResult('not json at all');
172
+ expect(broadcasts).toHaveLength(1);
173
+ expect(broadcasts[0].payload.ok).toBe(false);
174
+ expect(broadcasts[0].payload.error).toBeTruthy();
175
+ });
176
+
177
+ test('splits off the stop-signal protocol and reports it as a clarification request', () => {
178
+ docEditViaSession({
179
+ tabId: 'tab-4',
180
+ sessionId: 'chat-session-4',
181
+ cwd: '/home/user/project',
182
+ before: 'old text',
183
+ instruction: 'shorten it',
184
+ requestId: 'req-4',
185
+ });
186
+
187
+ const { onSilentResult } = runCalls[runCalls.length - 1];
188
+ onSilentResult('<<<SM_NEEDS_INPUT>>>\n{"questions":["which section?"]}');
189
+ expect(broadcasts).toHaveLength(1);
190
+ expect(broadcasts[0].payload.ok).toBe(false);
191
+ expect(broadcasts[0].payload.error).toContain('which section?');
192
+ });
193
+
194
+ test('times out and broadcasts a failure if chatRunner never calls onSilentResult (error/timeout/cancel/collision on the silent lane)', () => {
195
+ vi.useFakeTimers();
196
+ try {
197
+ docEditViaSession({
198
+ tabId: 'tab-5',
199
+ sessionId: 'chat-session-5',
200
+ cwd: '/home/user/project',
201
+ before: 'old text',
202
+ instruction: 'shorten it',
203
+ requestId: 'req-5',
204
+ });
205
+
206
+ // chatRunner.run() is a no-op stub in this test (never invokes
207
+ // onSilentResult) — nothing should broadcast until the bound elapses.
208
+ expect(broadcasts).toHaveLength(0);
209
+
210
+ vi.advanceTimersByTime(120_000);
211
+ expect(broadcasts).toHaveLength(1);
212
+ expect(broadcasts[0]).toEqual({
213
+ channel: 'docedit:session-result',
214
+ payload: { tabId: 'tab-5', requestId: 'req-5', ok: false, error: 'timed out waiting on the background session' },
215
+ });
216
+ } finally {
217
+ vi.useRealTimers();
218
+ }
219
+ });
220
+ });
221
+
60
222
  describe('duplicateNameFor', () => {
61
223
  test('fresh name — no collision', () => {
62
224
  const result = duplicateNameFor('/tmp/dir', 'notes.txt', () => false);
@@ -1,40 +1,48 @@
1
1
  /**
2
2
  * prdCreate.test.cjs — unit tests for the create-PRD body builder (PRD 549,
3
- * gh-issue-6). Pure-function tests only; the HTTP route + auth/cwd-boundary
4
- * behavior is covered by adminServer.test.cjs.
3
+ * gh-issue-6) and, since PRD 689, its registerAdminRoute create-prd HTTP
4
+ * route (moved verbatim out of the former standalone admin HTTP server
5
+ * module's handleRequest).
5
6
  *
6
- * Run: timeout 120 node --test src/main/__tests__/prdCreate.test.cjs
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdCreate.test.cjs
7
8
  */
8
9
 
9
10
  'use strict';
10
11
 
11
- const { test } = require('node:test');
12
- const assert = require('node:assert/strict');
12
+ import { test, expect } from 'vitest';
13
13
  const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const http = require('node:http');
14
18
  const {
15
19
  deriveSlugFromTitle,
16
20
  buildPrdBody,
17
21
  readStandards,
22
+ STANDARDS_PATH,
18
23
  PRD_CREATE_SLUG_RE,
24
+ registerAdminRoute,
19
25
  } = require('../lib/prdCreate.cjs');
26
+ const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
27
+ const config = require('../config.cjs');
28
+ const prdParser = require('../scheduler/prdParser.cjs');
20
29
 
21
30
  test('deriveSlugFromTitle lowercases, kebab-cases, and strips non-alnum runs', () => {
22
- assert.strictEqual(deriveSlugFromTitle('Add Foo Bar!! Baz'), 'add-foo-bar-baz');
23
- assert.strictEqual(deriveSlugFromTitle(' leading/trailing '), 'leading-trailing');
31
+ expect(deriveSlugFromTitle('Add Foo Bar!! Baz')).toBe('add-foo-bar-baz');
32
+ expect(deriveSlugFromTitle(' leading/trailing ')).toBe('leading-trailing');
24
33
  });
25
34
 
26
35
  test('deriveSlugFromTitle output always satisfies PRD_CREATE_SLUG_RE', () => {
27
36
  const slug = deriveSlugFromTitle('Some Title 123');
28
- assert.ok(PRD_CREATE_SLUG_RE.test(slug));
37
+ expect(PRD_CREATE_SLUG_RE.test(slug)).toBeTruthy();
29
38
  });
30
39
 
31
40
  test('readStandards reads the real standards.md and returns non-empty text', async () => {
32
41
  const text = await readStandards();
33
- assert.ok(text.includes('Execution discipline'));
42
+ expect(text.includes('Execution discipline')).toBeTruthy();
34
43
  });
35
44
 
36
45
  test('buildPrdBody emits required frontmatter keys and body sections in order', async () => {
37
- const standards = await readStandards();
38
46
  const body = buildPrdBody({
39
47
  title: 'Do the thing',
40
48
  cwd: '~/Projects/session-manager',
@@ -43,40 +51,272 @@ test('buildPrdBody emits required frontmatter keys and body sections in order',
43
51
  acceptanceCriteria: ['thing exists', 'tests pass'],
44
52
  implementationNotes: 'See file.cjs:10.',
45
53
  outOfScope: ['not this'],
46
- }, standards);
54
+ });
47
55
 
48
- assert.ok(body.startsWith('---\n'));
49
- assert.match(body, /title: Do the thing/);
50
- assert.match(body, /cwd: ~\/Projects\/session-manager/);
51
- assert.match(body, /estimateMinutes: 15/);
56
+ expect(body.startsWith('---\n')).toBeTruthy();
57
+ expect(body).toMatch(/title: Do the thing/);
58
+ expect(body).toMatch(/cwd: ~\/Projects\/session-manager/);
59
+ expect(body).toMatch(/estimateMinutes: 15/);
52
60
 
53
61
  const goalIdx = body.indexOf('# Goal');
54
62
  const acIdx = body.indexOf('# Acceptance criteria');
55
63
  const implIdx = body.indexOf('# Implementation notes');
56
64
  const oosIdx = body.indexOf('# Out of scope');
57
65
  const standardsIdx = body.indexOf('## Engineering standards');
58
- assert.ok(goalIdx > 0 && goalIdx < acIdx && acIdx < implIdx && implIdx < oosIdx && oosIdx < standardsIdx,
59
- 'sections must appear in Goal -> AC -> Implementation notes -> Out of scope -> Engineering standards order');
66
+ // sections must appear in Goal -> AC -> Implementation notes -> Out of scope -> Engineering standards order
67
+ expect(goalIdx > 0 && goalIdx < acIdx && acIdx < implIdx && implIdx < oosIdx && oosIdx < standardsIdx).toBeTruthy();
60
68
 
61
- assert.match(body, /- \[ \] thing exists/);
62
- assert.match(body, /- \[ \] tests pass/);
63
- assert.match(body, /- not this/);
64
- assert.ok(body.includes('Execution discipline'), 'must inline the standards.md content verbatim');
69
+ expect(body).toMatch(/- \[ \] thing exists/);
70
+ expect(body).toMatch(/- \[ \] tests pass/);
71
+ expect(body).toMatch(/- not this/);
72
+ // must point at STANDARDS_PATH rather than inline the standards.md content
73
+ expect(body.includes(STANDARDS_PATH)).toBeTruthy();
74
+ expect(body).toMatch(/Before writing any code, read/);
75
+ // the pointer block mentions "Execution discipline" by name, but must not
76
+ // inline the full standards.md prose (e.g. its Performance-section rules)
77
+ expect(body.includes('Lay out hot data contiguously')).toBeFalsy();
65
78
  });
66
79
 
67
80
  test('buildPrdBody omits parallelGroup frontmatter key when not supplied', () => {
68
81
  const body = buildPrdBody({
69
82
  title: 't', cwd: '~/x', estimateMinutes: 5, goal: 'g',
70
83
  acceptanceCriteria: ['a'], implementationNotes: 'n',
71
- }, 'standards text');
72
- assert.ok(!/parallelGroup:/.test(body));
84
+ });
85
+ expect(!/parallelGroup:/.test(body)).toBeTruthy();
73
86
  });
74
87
 
75
88
  test('readStandards result is byte-identical to the on-disk file (single source of truth)', async () => {
76
- const path = require('node:path');
77
89
  const onDisk = fs.readFileSync(
78
90
  path.join(__dirname, '..', '..', '..', 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md'),
79
91
  'utf8',
80
92
  );
81
- assert.strictEqual(await readStandards(), onDisk);
93
+ expect(await readStandards()).toBe(onDisk);
94
+ });
95
+
96
+ // ──────────────────────────────────────────── create-prd admin route
97
+
98
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
99
+ return new Promise((resolve, reject) => {
100
+ const headers = {};
101
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
102
+ let payload;
103
+ if (body !== undefined) {
104
+ payload = JSON.stringify(body);
105
+ headers['Content-Type'] = 'application/json';
106
+ headers['Content-Length'] = Buffer.byteLength(payload);
107
+ }
108
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
109
+ const chunks = [];
110
+ res.on('data', (c) => chunks.push(c));
111
+ res.on('end', () => {
112
+ const text = Buffer.concat(chunks).toString('utf8');
113
+ let json = null;
114
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
115
+ resolve({ status: res.statusCode, json, text });
116
+ });
117
+ });
118
+ req.on('error', reject);
119
+ if (payload) req.write(payload);
120
+ req.end();
121
+ });
122
+ }
123
+
124
+ // config.cjs's validateWrite only allows writes under a registered non-home
125
+ // allowedRoot's `.claude/` subtree (mirrors the real ROOT layout, which is
126
+ // $HOME/.claude/session-manager/...) — so the temp prdsDir must live under
127
+ // `<root>/.claude/...`, not directly under the temp root itself.
128
+ async function mkTmpPrdsDir() {
129
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-admin-create-prd-'));
130
+ config.addAllowedRoot(root);
131
+ const prdsDir = path.join(root, '.claude', 'prds');
132
+ await fsp.mkdir(prdsDir, { recursive: true });
133
+ return prdsDir;
134
+ }
135
+
136
+ // Mirrors scheduler.cjs's real `remote.allocateParallelGroup` / `remote.writePrd`
137
+ // (same underlying prdParser.allocateParallelGroup + config.writeTextAtomic
138
+ // calls) but pointed at a throwaway temp dir instead of the user's real
139
+ // scheduled-plans/prds — so these tests never touch $HOME/.claude.
140
+ function makeFakeRemoteWithPrdsDir(prdsDir) {
141
+ return {
142
+ async allocateParallelGroup() {
143
+ return prdParser.allocateParallelGroup(prdsDir);
144
+ },
145
+ async readPrd(slug) {
146
+ try {
147
+ const text = await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8');
148
+ return { ok: true, text };
149
+ } catch (e) {
150
+ return { ok: false, error: e?.message };
151
+ }
152
+ },
153
+ async writePrd(slug, body) {
154
+ try {
155
+ const filePath = path.join(prdsDir, `${slug}.md`);
156
+ await config.writeTextAtomic(filePath, body);
157
+ return { ok: true };
158
+ } catch (e) {
159
+ return { ok: false, error: e?.message };
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ function validCreateBody(overrides = {}) {
166
+ return {
167
+ title: 'Add widget frobnication',
168
+ cwd: os.homedir(),
169
+ estimateMinutes: 15,
170
+ goal: 'Add frobnication to the widget subsystem.',
171
+ acceptanceCriteria: ['widget frobnicates on click', 'timeout 300 npm run typecheck passes'],
172
+ implementationNotes: 'See src/widget.cjs:10.',
173
+ outOfScope: ['not touching gadgets'],
174
+ ...overrides,
175
+ };
176
+ }
177
+
178
+ async function startWithRemote(remote) {
179
+ const admin = createAdminHttp();
180
+ registerAdminRoute(admin, remote);
181
+ const { port, token } = await admin.start();
182
+ return { admin, port, token };
183
+ }
184
+
185
+ test('POST /admin/scheduler/create-prd without token returns 401', async () => {
186
+ const prdsDir = await mkTmpPrdsDir();
187
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
188
+ const { admin, port } = await startWithRemote(remote);
189
+ try {
190
+ const res = await request(port, {
191
+ method: 'POST', path: '/admin/scheduler/create-prd', body: validCreateBody(),
192
+ });
193
+ expect(res.status).toBe(401);
194
+ } finally {
195
+ await admin.stop();
196
+ }
197
+ });
198
+
199
+ test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
200
+ const prdsDir = await mkTmpPrdsDir();
201
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
202
+ const { admin, port, token } = await startWithRemote(remote);
203
+ try {
204
+ const res = await request(port, {
205
+ method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
206
+ });
207
+ expect(res.status).toBe(200);
208
+ expect(res.json.status).toBe('queued');
209
+ expect(typeof res.json.nn).toBe('number');
210
+ expect(res.json.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
211
+
212
+ const written = await fsp.readFile(path.join(prdsDir, res.json.filename), 'utf8');
213
+ expect(written).toMatch(/^---\n/);
214
+ expect(written).toMatch(/title: Add widget frobnication/);
215
+ expect(written).toMatch(/cwd: .+/);
216
+ expect(written).toMatch(/estimateMinutes: 15/);
217
+ expect(written).toMatch(/# Goal/);
218
+ expect(written).toMatch(/# Acceptance criteria/);
219
+ expect(written).toMatch(/- \[ \] widget frobnicates on click/);
220
+ expect(written).toMatch(/# Implementation notes/);
221
+ expect(written).toMatch(/# Out of scope/);
222
+ expect(written).toMatch(/## Engineering standards/);
223
+ // must point at STANDARDS_PATH rather than inline standards.md
224
+ expect(written.includes(STANDARDS_PATH)).toBeTruthy();
225
+ expect(written.includes('Lay out hot data contiguously')).toBeFalsy();
226
+ } finally {
227
+ await admin.stop();
228
+ }
229
+ });
230
+
231
+ test('POST /admin/scheduler/create-prd with malformed frontmatter (missing acceptanceCriteria) is rejected and writes no file', async () => {
232
+ const prdsDir = await mkTmpPrdsDir();
233
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
234
+ const { admin, port, token } = await startWithRemote(remote);
235
+ try {
236
+ const body = validCreateBody();
237
+ delete body.acceptanceCriteria;
238
+ const res = await request(port, {
239
+ method: 'POST', path: '/admin/scheduler/create-prd', token, body,
240
+ });
241
+ expect(res.status).toBe(400);
242
+ expect(res.json.ok).toBe(false);
243
+ const entries = await fsp.readdir(prdsDir);
244
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
245
+ } finally {
246
+ await admin.stop();
247
+ }
248
+ });
249
+
250
+ test('POST /admin/scheduler/create-prd with cwd outside allowed roots is rejected and writes no file', async () => {
251
+ const prdsDir = await mkTmpPrdsDir();
252
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
253
+ const { admin, port, token } = await startWithRemote(remote);
254
+ try {
255
+ const res = await request(port, {
256
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
257
+ body: validCreateBody({ cwd: '/etc/passwd-adjacent-outside-home' }),
258
+ });
259
+ expect(res.status).toBe(400);
260
+ expect(res.json.ok).toBe(false);
261
+ const entries = await fsp.readdir(prdsDir);
262
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
263
+ } finally {
264
+ await admin.stop();
265
+ }
266
+ });
267
+
268
+ test('POST /admin/scheduler/create-prd rejects a title containing a newline (frontmatter-injection guard) and writes no file', async () => {
269
+ const prdsDir = await mkTmpPrdsDir();
270
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
271
+ const { admin, port, token } = await startWithRemote(remote);
272
+ try {
273
+ const res = await request(port, {
274
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
275
+ body: validCreateBody({ title: 'Legit title\n---\nEvil: injected' }),
276
+ });
277
+ expect(res.status).toBe(400);
278
+ expect(res.json.ok).toBe(false);
279
+ const entries = await fsp.readdir(prdsDir);
280
+ expect(entries.filter((f) => f.endsWith('.md')).length).toBe(0);
281
+ } finally {
282
+ await admin.stop();
283
+ }
284
+ });
285
+
286
+ test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
287
+ const prdsDir = await mkTmpPrdsDir();
288
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
289
+ const { admin, port, token } = await startWithRemote(remote);
290
+ try {
291
+ await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
292
+ const res = await request(port, {
293
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
294
+ body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
295
+ });
296
+ expect(res.status).toBe(409);
297
+ expect(res.json.ok).toBe(false);
298
+ const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
299
+ // existing PRD file must not be overwritten
300
+ expect(stillThere).toBe('ORIGINAL CONTENT\n');
301
+ } finally {
302
+ await admin.stop();
303
+ }
304
+ });
305
+
306
+ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
307
+ const prdsDir = await mkTmpPrdsDir();
308
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
309
+ const { admin, port, token } = await startWithRemote(remote);
310
+ try {
311
+ const res = await request(port, {
312
+ method: 'POST', path: '/admin/scheduler/create-prd', token,
313
+ body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
314
+ });
315
+ expect(res.status).toBe(200);
316
+ expect(res.json.nn).toBe(777);
317
+ expect(res.json.filename).toBe('777-my-explicit-slug.md');
318
+ expect(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md'))).toBeTruthy();
319
+ } finally {
320
+ await admin.stop();
321
+ }
82
322
  });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * scheduler-admin-routes.test.cjs — unit tests for scheduler.cjs's
3
+ * registerAdminRoutes (PRD 689 — moved out of the former standalone admin
4
+ * HTTP server module's handleRequest, no behavior change). Exercises the
5
+ * two job-management admin
6
+ * HTTP routes against a fake `remote` object, wired through the real
7
+ * localAdminHttp.cjs transport.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-admin-routes.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const http = require('node:http');
16
+ const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
17
+ const { registerAdminRoutes } = require('../scheduler.cjs');
18
+
19
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ const headers = {};
22
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
23
+ let payload;
24
+ if (body !== undefined) {
25
+ payload = JSON.stringify(body);
26
+ headers['Content-Type'] = 'application/json';
27
+ headers['Content-Length'] = Buffer.byteLength(payload);
28
+ }
29
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
30
+ const chunks = [];
31
+ res.on('data', (c) => chunks.push(c));
32
+ res.on('end', () => {
33
+ const text = Buffer.concat(chunks).toString('utf8');
34
+ let json = null;
35
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
36
+ resolve({ status: res.statusCode, json, text });
37
+ });
38
+ });
39
+ req.on('error', reject);
40
+ if (payload) req.write(payload);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ function makeFakeRemote({ jobs = [] } = {}) {
46
+ return {
47
+ async listJobs() {
48
+ return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
49
+ },
50
+ async resetJob(slug) {
51
+ const job = jobs.find((j) => j.slug === slug);
52
+ if (!job) return { ok: false, error: 'not found' };
53
+ job.status = 'pending';
54
+ job.runId = null;
55
+ job.startedAt = null;
56
+ job.finishedAt = null;
57
+ job.exitCode = null;
58
+ job.error = null;
59
+ delete job.runtime;
60
+ delete job.verifierVerdict;
61
+ return { ok: true, slug, status: 'pending' };
62
+ },
63
+ };
64
+ }
65
+
66
+ async function startWithRemote(remote) {
67
+ const admin = createAdminHttp();
68
+ registerAdminRoutes(admin, remote);
69
+ const { port, token } = await admin.start();
70
+ return { admin, port, token };
71
+ }
72
+
73
+ test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
74
+ const remote = makeFakeRemote({
75
+ jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
76
+ });
77
+ const { admin, port, token } = await startWithRemote(remote);
78
+ try {
79
+ const res = await request(port, { path: '/admin/scheduler/jobs', token });
80
+ expect(res.status).toBe(200);
81
+ expect(Array.isArray(res.json)).toBeTruthy();
82
+ expect(res.json.length).toBe(1);
83
+ expect(res.json[0].slug).toBe('10-foo');
84
+ } finally {
85
+ await admin.stop();
86
+ }
87
+ });
88
+
89
+ test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
90
+ const remote = makeFakeRemote({ jobs: [] });
91
+ const { admin, port, token } = await startWithRemote(remote);
92
+ try {
93
+ const res = await request(port, {
94
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
95
+ });
96
+ expect(res.status).toBe(200);
97
+ expect(res.json.ok).toBe(false);
98
+ } finally {
99
+ await admin.stop();
100
+ }
101
+ });
102
+
103
+ test('POST /admin/scheduler/reset-job without slug returns 400', async () => {
104
+ const remote = makeFakeRemote({ jobs: [] });
105
+ const { admin, port, token } = await startWithRemote(remote);
106
+ try {
107
+ const res = await request(port, {
108
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: {},
109
+ });
110
+ expect(res.status).toBe(400);
111
+ expect(res.json.ok).toBe(false);
112
+ } finally {
113
+ await admin.stop();
114
+ }
115
+ });
116
+
117
+ test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
118
+ const job = {
119
+ slug: '10-foo',
120
+ title: 'Foo',
121
+ status: 'completed',
122
+ cwd: '/tmp/foo',
123
+ runId: 'run-1',
124
+ startedAt: 100,
125
+ finishedAt: 200,
126
+ exitCode: 0,
127
+ error: null,
128
+ runtime: { pid: 1234 },
129
+ verifierVerdict: 'PASS',
130
+ };
131
+ const remote = makeFakeRemote({ jobs: [job] });
132
+ const { admin, port, token } = await startWithRemote(remote);
133
+ try {
134
+ const res = await request(port, {
135
+ method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
136
+ });
137
+ expect(res.status).toBe(200);
138
+ expect(res.json).toEqual({ ok: true, slug: '10-foo', status: 'pending' });
139
+ expect(job.status).toBe('pending');
140
+ expect(job.runId).toBe(null);
141
+ expect(job.startedAt).toBe(null);
142
+ expect(job.finishedAt).toBe(null);
143
+ expect(job.exitCode).toBe(null);
144
+ expect(job.error).toBe(null);
145
+ expect('runtime' in job).toBe(false);
146
+ expect('verifierVerdict' in job).toBe(false);
147
+ } finally {
148
+ await admin.stop();
149
+ }
150
+ });