claude-code-session-manager 0.65.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentLibrary-CiqimwWl.js +3 -0
- package/dist/assets/{History-DB-9-zwc.js → History-DbzjII2Z.js} +2 -2
- package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-C0PTplI_.js} +3 -3
- package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-vGc1FYLD.js} +1 -1
- package/dist/assets/{Library-BYSB0dmY.js → Library-4tU4rg2h.js} +1 -1
- package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-BGIvkCwZ.js} +1 -1
- package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-ChFdpCam.js} +1 -1
- package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-DsRWWpU3.js} +2 -2
- package/dist/assets/{Memory-DzPGXT5J.js → Memory-DmxoxxxY.js} +6 -6
- package/dist/assets/{Panel-BX6UZ-W0.js → Panel-KC-rv3jS.js} +1 -1
- package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-DrDjCRFl.js} +3 -3
- package/dist/assets/{Plugins-CIuey9R3.js → Plugins-KexSQwk0.js} +2 -2
- package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-Rg-jL94y.js} +1 -1
- package/dist/assets/SaveBar-BbEo8U2e.js +1 -0
- package/dist/assets/Scheduler-C4Ti9TCc.js +14 -0
- package/dist/assets/ScopeSwitcher-Di9BmYze.js +1 -0
- package/dist/assets/Settings-CxyhPuYQ.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-0q26RHrn.js} +1 -1
- package/dist/assets/Skills-BXkVyCo5.js +3 -0
- package/dist/assets/SystemPrompt-Bwe3NTbi.js +1 -0
- package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-CnwEf6EI.js} +1 -1
- package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-faCtLj9L.js} +1 -1
- package/dist/assets/{Toggle-D9sapSAv.js → Toggle-Bw7G_-RR.js} +1 -1
- package/dist/assets/{index-BRwaw_1W.js → index-B6dJ2CsU.js} +666 -665
- package/dist/assets/{index-LlWpj2VJ.css → index-CEnMgeQU.css} +1 -1
- package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-B5C9hZoS.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +73 -15
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
- package/src/main/__tests__/agentLibrary.test.cjs +40 -0
- package/src/main/__tests__/develop-skill-failure-modes.test.cjs +70 -0
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
- package/src/main/__tests__/health-per-project-stall.test.cjs +82 -0
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
- package/src/main/__tests__/prdCreate.test.cjs +7 -2
- package/src/main/__tests__/prdMigration.test.cjs +17 -0
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +108 -0
- package/src/main/agentLibrary.cjs +40 -2
- package/src/main/health.cjs +97 -2
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +60 -0
- package/src/main/lib/localAdminHttp.cjs +10 -3
- package/src/main/lib/prdAdminRoutes.cjs +175 -0
- package/src/main/lib/prdCreate.cjs +37 -2
- package/src/main/lib/prdFrontmatter.cjs +179 -1
- package/src/main/lib/prdMigration.cjs +82 -5
- package/src/main/lib/queueStore.cjs +41 -7
- package/src/main/lib/scheduleJobSchema.cjs +114 -0
- package/src/main/lib/scheduleJobTransitions.cjs +164 -0
- package/src/main/lib/schedulerConfig.cjs +7 -0
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +769 -138
- package/src/preload/api.d.ts +54 -2
- package/src/preload/index.cjs +12 -0
- package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
- package/dist/assets/Scheduler-X5y252Qw.js +0 -14
- package/dist/assets/ScopeSwitcher-DU7M_q5-.js +0 -1
- package/dist/assets/Settings-DEptXcEY.js +0 -3
- package/dist/assets/Skills-CGN56X1i.js +0 -3
- package/dist/assets/SystemPrompt-DIEK7FpJ.js +0 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduleJobStatusDrift.test.cjs — guards the mirrored copies of
|
|
3
|
+
* JOB_STATUSES (src/main/lib/scheduleJobSchema.cjs) against silent drift.
|
|
4
|
+
*
|
|
5
|
+
* The renderer cannot require a .cjs module directly, and src/preload/api.d.ts
|
|
6
|
+
* is types-only (erased at build time, nothing to import at test runtime), so
|
|
7
|
+
* each consumer keeps its own hand-written mirror of the status enum:
|
|
8
|
+
* - src/preload/api.d.ts's `ScheduleJobStatus` union
|
|
9
|
+
* - src/renderer/components/ui/StatusBadge.tsx's `JobStatus` union
|
|
10
|
+
* - src/renderer/components/SchedulePanel.tsx's `FilterStatus` union
|
|
11
|
+
* (which also carries the UI-only 'all' meta-value)
|
|
12
|
+
*
|
|
13
|
+
* This test reads each file's source text and extracts its status list by
|
|
14
|
+
* regex, then diffs it against JOB_STATUSES — the single source of truth.
|
|
15
|
+
* A file whose union drifts (a status added/removed/renamed in one place but
|
|
16
|
+
* not the others) fails this test instead of silently mis-filtering jobs,
|
|
17
|
+
* which is exactly how the 2026-08-07 'queued' incident went undetected.
|
|
18
|
+
*
|
|
19
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduleJobStatusDrift.test.cjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
import { test, expect } from 'vitest';
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { JOB_STATUSES } = require('../lib/scheduleJobSchema.cjs');
|
|
28
|
+
|
|
29
|
+
const ROOT = path.join(__dirname, '..', '..', '..');
|
|
30
|
+
|
|
31
|
+
function readSource(relPath) {
|
|
32
|
+
return fs.readFileSync(path.join(ROOT, relPath), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractQuotedList(source, anchorRegex) {
|
|
36
|
+
const match = source.match(anchorRegex);
|
|
37
|
+
if (!match) throw new Error(`extractQuotedList: anchor not found — ${anchorRegex}`);
|
|
38
|
+
return [...match[1].matchAll(/'([a-z_]+)'/g)].map((m) => m[1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('JOB_STATUSES is the expected canonical list', () => {
|
|
42
|
+
expect(JOB_STATUSES).toEqual(['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function sorted(list) {
|
|
46
|
+
return [...list].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('src/preload/api.d.ts ScheduleJobStatus matches JOB_STATUSES', () => {
|
|
50
|
+
const source = readSource('src/preload/api.d.ts');
|
|
51
|
+
const values = extractQuotedList(source, /export type ScheduleJobStatus = ([^;]+);/);
|
|
52
|
+
expect(sorted(values)).toEqual(sorted(JOB_STATUSES));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('src/renderer/components/ui/StatusBadge.tsx JobStatus matches JOB_STATUSES', () => {
|
|
56
|
+
const source = readSource('src/renderer/components/ui/StatusBadge.tsx');
|
|
57
|
+
const values = extractQuotedList(source, /export type JobStatus = ([^\n]+)/);
|
|
58
|
+
expect(sorted(values)).toEqual(sorted(JOB_STATUSES));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('src/renderer/components/SchedulePanel.tsx FilterStatus matches JOB_STATUSES plus "all"', () => {
|
|
62
|
+
const source = readSource('src/renderer/components/SchedulePanel.tsx');
|
|
63
|
+
const values = extractQuotedList(source, /type FilterStatus = ([^\n]+)/);
|
|
64
|
+
expect(sorted(values)).toEqual(sorted(['all', ...JOB_STATUSES]));
|
|
65
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduleJobTransitions.test.cjs — unit tests for transitionJob(), the sole
|
|
3
|
+
* chokepoint for a ScheduleJob's `status` field (scheduleJobTransitions.cjs).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduleJobTransitions.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect } from 'vitest';
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const {
|
|
13
|
+
transitionJob,
|
|
14
|
+
LEGAL_TRANSITIONS,
|
|
15
|
+
STATUS_HISTORY_CAP,
|
|
16
|
+
getRefusedTransitionCount,
|
|
17
|
+
_resetRefusedTransitionCountForTests,
|
|
18
|
+
} = require('../lib/scheduleJobTransitions.cjs');
|
|
19
|
+
const auditLog = require('../lib/auditLog.cjs');
|
|
20
|
+
|
|
21
|
+
function uniqueSlug(label) {
|
|
22
|
+
return `test-${label}-${crypto.randomUUID().slice(0, 8)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readAuditRecordsForSlug(slug) {
|
|
26
|
+
if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
|
|
27
|
+
return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
|
|
28
|
+
.split('\n')
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((line) => {
|
|
31
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
32
|
+
})
|
|
33
|
+
.filter((record) => record && record.slug === slug);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('a legal transition mutates the job, records statusHistory, and audits', () => {
|
|
37
|
+
const slug = uniqueSlug('legal');
|
|
38
|
+
const job = { slug, cwd: '/tmp/proj', status: 'pending' };
|
|
39
|
+
|
|
40
|
+
const ok = transitionJob(job, 'running', { reason: 'dispatched', source: 'test' });
|
|
41
|
+
|
|
42
|
+
expect(ok).toBe(true);
|
|
43
|
+
expect(job.status).toBe('running');
|
|
44
|
+
expect(job.statusHistory).toHaveLength(1);
|
|
45
|
+
expect(job.statusHistory[0]).toMatchObject({ from: 'pending', to: 'running', reason: 'dispatched', source: 'test' });
|
|
46
|
+
expect(typeof job.statusHistory[0].at).toBe('string');
|
|
47
|
+
|
|
48
|
+
const records = readAuditRecordsForSlug(slug);
|
|
49
|
+
expect(records).toHaveLength(1);
|
|
50
|
+
expect(records[0]).toMatchObject({ kind: 'job_transition', slug, from: 'pending', to: 'running', reason: 'dispatched', source: 'test', cwd: '/tmp/proj' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an illegal transition refuses: job untouched, error logged, counter incremented, audit written', () => {
|
|
54
|
+
const slug = uniqueSlug('illegal');
|
|
55
|
+
const job = { slug, cwd: '/tmp/proj', status: 'pending' };
|
|
56
|
+
const before = getRefusedTransitionCount();
|
|
57
|
+
|
|
58
|
+
const ok = transitionJob(job, 'needs_review', { reason: 'bogus', source: 'test' });
|
|
59
|
+
|
|
60
|
+
expect(ok).toBe(false);
|
|
61
|
+
expect(job.status).toBe('pending'); // untouched
|
|
62
|
+
expect(job.statusHistory).toBeUndefined(); // never mutated
|
|
63
|
+
expect(getRefusedTransitionCount()).toBe(before + 1);
|
|
64
|
+
|
|
65
|
+
const records = readAuditRecordsForSlug(slug);
|
|
66
|
+
expect(records).toHaveLength(1);
|
|
67
|
+
expect(records[0]).toMatchObject({ kind: 'job_transition_refused', slug, from: 'pending', to: 'needs_review' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an identity transition (status unchanged) is accepted as a no-op with no history entry', () => {
|
|
71
|
+
const slug = uniqueSlug('identity');
|
|
72
|
+
const job = { slug, cwd: '/tmp/proj', status: 'running' };
|
|
73
|
+
|
|
74
|
+
const ok = transitionJob(job, 'running', { reason: 'no-op', source: 'test' });
|
|
75
|
+
|
|
76
|
+
expect(ok).toBe(true);
|
|
77
|
+
expect(job.status).toBe('running');
|
|
78
|
+
expect(job.statusHistory).toBeUndefined();
|
|
79
|
+
expect(readAuditRecordsForSlug(slug)).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('statusHistory caps at STATUS_HISTORY_CAP entries, dropping the oldest first', () => {
|
|
83
|
+
const slug = uniqueSlug('cap');
|
|
84
|
+
const job = { slug, cwd: '/tmp/proj', status: 'pending' };
|
|
85
|
+
|
|
86
|
+
// pending -> running -> pending -> running -> ... alternate legally more
|
|
87
|
+
// than the cap, to exercise eviction.
|
|
88
|
+
const cycles = STATUS_HISTORY_CAP + 5;
|
|
89
|
+
for (let i = 0; i < cycles; i++) {
|
|
90
|
+
const to = job.status === 'pending' ? 'running' : 'pending';
|
|
91
|
+
expect(transitionJob(job, to, { reason: `cycle ${i}`, source: 'test' })).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(job.statusHistory).toHaveLength(STATUS_HISTORY_CAP);
|
|
95
|
+
// Oldest entries (early cycles) were dropped — the earliest surviving
|
|
96
|
+
// entry's reason should be from a late cycle, not "cycle 0".
|
|
97
|
+
expect(job.statusHistory[0].reason).not.toBe('cycle 0');
|
|
98
|
+
expect(job.statusHistory[job.statusHistory.length - 1].reason).toBe(`cycle ${cycles - 1}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('LEGAL_TRANSITIONS covers every real scheduler.cjs edge', () => {
|
|
102
|
+
// Spot-check the edges scheduler.cjs actually exercises, so a future prune
|
|
103
|
+
// of this table can't silently break a real call site without a test
|
|
104
|
+
// failure here pointing at it.
|
|
105
|
+
const mustBeLegal = [
|
|
106
|
+
['pending', 'running'],
|
|
107
|
+
['pending', 'completed'],
|
|
108
|
+
['pending', 'failed'],
|
|
109
|
+
['running', 'completed'],
|
|
110
|
+
['running', 'failed'],
|
|
111
|
+
['running', 'needs_review'],
|
|
112
|
+
['running', 'pending'],
|
|
113
|
+
['investigating', 'failed'],
|
|
114
|
+
['investigating', 'needs_review'],
|
|
115
|
+
['investigating', 'pending'],
|
|
116
|
+
['failed', 'investigating'],
|
|
117
|
+
['failed', 'pending'],
|
|
118
|
+
['failed', 'completed'],
|
|
119
|
+
['needs_review', 'investigating'],
|
|
120
|
+
['needs_review', 'pending'],
|
|
121
|
+
['needs_review', 'completed'],
|
|
122
|
+
['completed', 'pending'],
|
|
123
|
+
];
|
|
124
|
+
for (const [from, to] of mustBeLegal) {
|
|
125
|
+
expect(LEGAL_TRANSITIONS[from]).toContain(to);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a genuinely nonsensical edge (completed -> running) is illegal', () => {
|
|
130
|
+
expect(LEGAL_TRANSITIONS.completed).not.toContain('running');
|
|
131
|
+
const slug = uniqueSlug('nonsense');
|
|
132
|
+
const job = { slug, cwd: '/tmp/proj', status: 'completed' };
|
|
133
|
+
expect(transitionJob(job, 'running', { reason: 'nope', source: 'test' })).toBe(false);
|
|
134
|
+
expect(job.status).toBe('completed');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('transitionJob never throws on a missing/malformed job', () => {
|
|
138
|
+
expect(transitionJob(null, 'running', {})).toBe(false);
|
|
139
|
+
expect(transitionJob(undefined, 'running', {})).toBe(false);
|
|
140
|
+
// status undefined -> 'running' is not a legal edge from any known state —
|
|
141
|
+
// refused, not a crash.
|
|
142
|
+
expect(transitionJob({}, 'running', {})).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('_resetRefusedTransitionCountForTests resets the counter', () => {
|
|
146
|
+
const slug = uniqueSlug('reset-counter');
|
|
147
|
+
const job = { slug, status: 'completed' };
|
|
148
|
+
transitionJob(job, 'running', {});
|
|
149
|
+
expect(getRefusedTransitionCount()).toBeGreaterThan(0);
|
|
150
|
+
_resetRefusedTransitionCountForTests();
|
|
151
|
+
expect(getRefusedTransitionCount()).toBe(0);
|
|
152
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduleJobTransitionsGrep.test.cjs — asserts scheduleJobTransitions.cjs's
|
|
3
|
+
* transitionJob() is the ONLY place a ScheduleJob's `status` field is
|
|
4
|
+
* assigned, per the PRD that introduced it (job-lifecycle chokepoint,
|
|
5
|
+
* mirroring epicMint.cjs's ownership of the Epic's status transition).
|
|
6
|
+
*
|
|
7
|
+
* A bare `job.status = '...'` reintroduces the exact failure class this
|
|
8
|
+
* module exists to close: no legality check, no statusHistory trail, no
|
|
9
|
+
* audit record — the 1021/1022 stall (2026-08-07) was undiagnosable from
|
|
10
|
+
* the app because status changes left no trace beyond a heartbeat count.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately a plain grep, not an AST walk — this repo's other structural
|
|
13
|
+
* guards (check-unstable-selectors.cjs, check-conditional-hooks.cjs) use the
|
|
14
|
+
* same grep-based approach for the same reason: fast, dependency-free, and
|
|
15
|
+
* precise enough for a single well-known pattern.
|
|
16
|
+
*
|
|
17
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduleJobTransitionsGrep.test.cjs
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import { test, expect } from 'vitest';
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const SCHEDULER_PATH = path.join(__dirname, '..', 'scheduler.cjs');
|
|
26
|
+
|
|
27
|
+
// Lines explicitly exempt: `entry.status = ...` inside listPrdsInternal
|
|
28
|
+
// assigns a freshly-synthesized PRD-listing row's display field, never a
|
|
29
|
+
// persisted ScheduleJob's status (no queue.json mutation, no
|
|
30
|
+
// statusHistory/audit trail applies). Each must carry the justifying
|
|
31
|
+
// comment referencing this test, so the exemption can't silently drift.
|
|
32
|
+
const ALLOWLISTED_PATTERN = /^\s*entry\.status = /;
|
|
33
|
+
|
|
34
|
+
test('no bare `.status = ` assignment on a job object outside scheduleJobTransitions.cjs', () => {
|
|
35
|
+
const lines = fs.readFileSync(SCHEDULER_PATH, 'utf8').split('\n');
|
|
36
|
+
const offenders = [];
|
|
37
|
+
lines.forEach((line, i) => {
|
|
38
|
+
if (!/\.status = /.test(line)) return;
|
|
39
|
+
if (ALLOWLISTED_PATTERN.test(line)) return;
|
|
40
|
+
offenders.push(`scheduler.cjs:${i + 1}: ${line.trim()}`);
|
|
41
|
+
});
|
|
42
|
+
expect(offenders).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('scheduler.cjs references transitionJob (the chokepoint is actually wired up)', () => {
|
|
46
|
+
const content = fs.readFileSync(SCHEDULER_PATH, 'utf8');
|
|
47
|
+
expect(content).toMatch(/require\(['"]\.\/lib\/scheduleJobTransitions\.cjs['"]\)/);
|
|
48
|
+
// At least as many call sites as the PRD's own enumerated list — guards
|
|
49
|
+
// against someone reverting individual sites back to bare assignment
|
|
50
|
+
// without reintroducing the literal `.status = ` grep hit (e.g. swapping
|
|
51
|
+
// in a different mutation helper that itself bypasses transitionJob).
|
|
52
|
+
const callSites = content.match(/transitionJob\(/g) || [];
|
|
53
|
+
expect(callSites.length).toBeGreaterThanOrEqual(17);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('the allowlisted entry.status lines carry their justifying comment', () => {
|
|
57
|
+
const content = fs.readFileSync(SCHEDULER_PATH, 'utf8');
|
|
58
|
+
expect(content).toMatch(/exempt from the\s*\n\s*\/\/ transitionJob-only rule enforced by scheduleJobTransitionsGrep\.test\.cjs/);
|
|
59
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-invalid-repair.test.cjs — end-to-end reproduction of
|
|
3
|
+
* the 2026-08-07 1021/1022 incident: a queue.json row with `"status":
|
|
4
|
+
* "queued"` (a value outside ScheduleJobStatus) sat unpicked for 4+ hours.
|
|
5
|
+
*
|
|
6
|
+
* queueStore.shapeJobs (scheduleJobSchema.cjs, landed by a prior PRD)
|
|
7
|
+
* already quarantines such a row into `invalidJobs` on read rather than
|
|
8
|
+
* passing it through — but reconcile() itself was still add-only (`if
|
|
9
|
+
* (seen.has(slug)) continue`), so a quarantined row was never repaired, only
|
|
10
|
+
* silently absent from state.jobs with no log of what its bad status had
|
|
11
|
+
* been.
|
|
12
|
+
*
|
|
13
|
+
* This test exercises the real quarantine path (queueStore.shapeJobs on the
|
|
14
|
+
* raw incident JSON) against a real on-disk PRD, runs the real reconcile(),
|
|
15
|
+
* and asserts: computeStallSummary flags the PRE-repair state as stalled,
|
|
16
|
+
* reconcile() repairs the row to 'pending', logs the repair at warn level,
|
|
17
|
+
* and audits it.
|
|
18
|
+
*
|
|
19
|
+
* HOME-isolated (mirrors scheduler-prd-missing-skip.test.cjs): stub HOME to
|
|
20
|
+
* a mkdtemp dir before requiring scheduler.cjs/queueStore.cjs, and register
|
|
21
|
+
* the fixture project as "active" via a fake ~/.claude/projects transcript —
|
|
22
|
+
* reconcile's PRD/history discovery (prdLocations.cjs, queueHistory.cjs)
|
|
23
|
+
* only scans cwds it can find that way.
|
|
24
|
+
*
|
|
25
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
import { test, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const os = require('node:os');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
let tmpHome;
|
|
36
|
+
let originalHome;
|
|
37
|
+
let scheduler;
|
|
38
|
+
let queueStore;
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
originalHome = process.env.HOME;
|
|
42
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-repair-home-'));
|
|
43
|
+
process.env.HOME = tmpHome;
|
|
44
|
+
|
|
45
|
+
scheduler = require('../scheduler.cjs');
|
|
46
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
47
|
+
|
|
48
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
49
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
process.env.HOME = originalHome;
|
|
55
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function registerActiveProject(cwd) {
|
|
63
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
64
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
65
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeFixtureProject(prefix) {
|
|
70
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
71
|
+
registerActiveProject(cwd);
|
|
72
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
73
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'prds');
|
|
74
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
75
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
76
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
77
|
+
return { cwd, prdsDir, stateDir };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test('reconcile() repairs a queue row with an invalid status back to pending, logs, and audits it', async () => {
|
|
81
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-reconcile-repair-proj-');
|
|
82
|
+
try {
|
|
83
|
+
// Real on-disk PRD (its file location is the reconcile source of truth).
|
|
84
|
+
fs.writeFileSync(
|
|
85
|
+
path.join(prdsDir, '1021-fix-the-thing.md'),
|
|
86
|
+
'---\ntitle: Fix the thing\ncwd: ' + cwd + '\nestimateMinutes: 15\n---\n\n# Goal\nFix it.\n',
|
|
87
|
+
'utf8',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// The exact incident shape: a row whose status is 'queued', outside
|
|
91
|
+
// ScheduleJobStatus entirely.
|
|
92
|
+
const badRow = {
|
|
93
|
+
slug: '1021-fix-the-thing',
|
|
94
|
+
title: 'Fix the thing',
|
|
95
|
+
cwd,
|
|
96
|
+
status: 'queued',
|
|
97
|
+
runId: null,
|
|
98
|
+
startedAt: null,
|
|
99
|
+
finishedAt: null,
|
|
100
|
+
exitCode: null,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
const queuePath = path.join(stateDir, 'queue.json');
|
|
104
|
+
fs.writeFileSync(queuePath, JSON.stringify({ jobs: [badRow] }, null, 2), 'utf8');
|
|
105
|
+
|
|
106
|
+
// Exercise the REAL quarantine gate (same one queueStore.readMerged runs
|
|
107
|
+
// at boot/every IPC read) rather than hand-building invalidJobs.
|
|
108
|
+
const { jobs, invalid } = queueStore.shapeJobs(fs.readFileSync(queuePath, 'utf8'), queuePath);
|
|
109
|
+
expect(jobs.length).toBe(0); // the bad row never reaches state.jobs
|
|
110
|
+
expect(invalid.length).toBe(1);
|
|
111
|
+
expect(invalid[0].slug).toBe(badRow.slug);
|
|
112
|
+
expect(invalid[0].row.status).toBe('queued');
|
|
113
|
+
|
|
114
|
+
const state = { jobs, invalidJobs: invalid, paused: null };
|
|
115
|
+
|
|
116
|
+
// Stall detector must flag the PRE-repair state: the queue holds a
|
|
117
|
+
// (quarantined) job but nothing is running or pending and it isn't
|
|
118
|
+
// paused — this is exactly the condition that was silent for 4+ hours.
|
|
119
|
+
const preRepairStall = scheduler.computeStallSummary(state);
|
|
120
|
+
expect(preRepairStall.stalled).toBe(true);
|
|
121
|
+
expect(preRepairStall.total).toBe(1);
|
|
122
|
+
expect(preRepairStall.running).toBe(0);
|
|
123
|
+
expect(preRepairStall.pending).toBe(0);
|
|
124
|
+
|
|
125
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
126
|
+
// scheduler.cjs destructures `appendAuditEvent` out of auditLog.cjs at
|
|
127
|
+
// require time, so spying on the module's export property after the
|
|
128
|
+
// fact wouldn't intercept scheduler.cjs's already-bound reference —
|
|
129
|
+
// assert against the real (tmpHome-isolated) audit log file instead.
|
|
130
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
131
|
+
expect(AUDIT_LOG_PATH.startsWith(tmpHome)).toBe(true);
|
|
132
|
+
|
|
133
|
+
queueStore.bustCwdCache();
|
|
134
|
+
await scheduler.reconcile(state);
|
|
135
|
+
|
|
136
|
+
const repaired = state.jobs.find((j) => j.slug === badRow.slug);
|
|
137
|
+
expect(repaired).toBeDefined();
|
|
138
|
+
expect(repaired.status).toBe('pending');
|
|
139
|
+
|
|
140
|
+
const warnedRepair = warnSpy.mock.calls.some((args) =>
|
|
141
|
+
String(args[0]).includes('repaired invalid queue row') && String(args[0]).includes(badRow.slug),
|
|
142
|
+
);
|
|
143
|
+
expect(warnedRepair).toBe(true);
|
|
144
|
+
|
|
145
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
146
|
+
const auditedRepair = auditLines.some(
|
|
147
|
+
(rec) => rec.kind === 'scheduler_row_repaired' && rec.slug === badRow.slug && rec.oldStatus === 'queued',
|
|
148
|
+
);
|
|
149
|
+
expect(auditedRepair).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Post-repair, the same condition must no longer read as stalled.
|
|
152
|
+
const postRepairStall = scheduler.computeStallSummary(state);
|
|
153
|
+
expect(postRepairStall.stalled).toBe(false);
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('reconcile() does not resurrect an invalid row whose slug already has a terminal history record', async () => {
|
|
160
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-reconcile-repair-done-');
|
|
161
|
+
try {
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
path.join(prdsDir, '1022-already-done.md'),
|
|
164
|
+
'---\ntitle: Already done\ncwd: ' + cwd + '\nestimateMinutes: 15\n---\n\n# Goal\nDone.\n',
|
|
165
|
+
'utf8',
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const badRow = {
|
|
169
|
+
slug: '1022-already-done',
|
|
170
|
+
title: 'Already done',
|
|
171
|
+
cwd,
|
|
172
|
+
status: 'queued',
|
|
173
|
+
runId: 'run-xyz',
|
|
174
|
+
startedAt: new Date().toISOString(),
|
|
175
|
+
finishedAt: null,
|
|
176
|
+
exitCode: null,
|
|
177
|
+
error: null,
|
|
178
|
+
};
|
|
179
|
+
const queuePath = path.join(stateDir, 'queue.json');
|
|
180
|
+
fs.writeFileSync(queuePath, JSON.stringify({ jobs: [badRow] }, null, 2), 'utf8');
|
|
181
|
+
|
|
182
|
+
const { jobs, invalid } = queueStore.shapeJobs(fs.readFileSync(queuePath, 'utf8'), queuePath);
|
|
183
|
+
expect(invalid.length).toBe(1);
|
|
184
|
+
|
|
185
|
+
const state = { jobs, invalidJobs: invalid, paused: null };
|
|
186
|
+
|
|
187
|
+
// A terminal record already exists for this slug — reconcile must never
|
|
188
|
+
// repair the corrupted row back into a runnable 'pending' state on top
|
|
189
|
+
// of already-shipped work.
|
|
190
|
+
fs.writeFileSync(
|
|
191
|
+
path.join(stateDir, 'history.jsonl'),
|
|
192
|
+
JSON.stringify({ slug: badRow.slug, status: 'completed', finishedAt: new Date().toISOString(), runId: 'run-xyz' }) + '\n',
|
|
193
|
+
'utf8',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
queueStore.bustCwdCache();
|
|
197
|
+
await scheduler.reconcile(state);
|
|
198
|
+
|
|
199
|
+
expect(state.jobs.find((j) => j.slug === badRow.slug)).toBeUndefined();
|
|
200
|
+
} finally {
|
|
201
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-quarantine.test.cjs — PRD-authoring-lockdown provenance
|
|
3
|
+
* gate: reconcile() must never turn a discovered PRD with no `createdVia`
|
|
4
|
+
* frontmatter into a runnable 'pending' queue row. It quarantines it instead
|
|
5
|
+
* (a distinct, non-runnable status), logs a warning naming the file, and
|
|
6
|
+
* audits the event — then, once the file is stamped (the Scheduler tab's
|
|
7
|
+
* "adopt PRD" action / scheduler_update_prd, both routed through
|
|
8
|
+
* remote.updatePrd), promotes the row to 'pending' on the very next pass.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors scheduler-reconcile-invalid-repair.test.cjs's HOME-isolation setup.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-quarantine.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeAll, afterAll, afterEach, vi } 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 scheduler;
|
|
25
|
+
let queueStore;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-quarantine-home-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
|
|
32
|
+
scheduler = require('../scheduler.cjs');
|
|
33
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
34
|
+
|
|
35
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
36
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
process.env.HOME = originalHome;
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function registerActiveProject(cwd) {
|
|
50
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
51
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
52
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeFixtureProject(prefix) {
|
|
57
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
+
registerActiveProject(cwd);
|
|
59
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
60
|
+
// Epic-scoped, not the retired flat dir: a flat PRD with no live queue row
|
|
61
|
+
// gets swept into prds-archived/ by reconcile()'s own consolidation pass
|
|
62
|
+
// (see consolidateAllFlatPrds) BEFORE it's ever scanned as a discoverable
|
|
63
|
+
// PRD — exactly the scenario every test below needs to observe, so the
|
|
64
|
+
// flat layout would race the very thing under test.
|
|
65
|
+
const prdsDir = path.join(opsRoot, 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
66
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
67
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
68
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
69
|
+
return { cwd, prdsDir, stateDir };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('reconcile() quarantines a newly-discovered PRD with no createdVia provenance', async () => {
|
|
73
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-new-');
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(prdsDir, '9001-hand-written.md'),
|
|
77
|
+
`---\ntitle: Hand-written PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nDo the thing.\n`,
|
|
78
|
+
'utf8',
|
|
79
|
+
);
|
|
80
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
81
|
+
|
|
82
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
83
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
84
|
+
|
|
85
|
+
queueStore.bustCwdCache();
|
|
86
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
87
|
+
await scheduler.reconcile(state);
|
|
88
|
+
|
|
89
|
+
const row = state.jobs.find((j) => j.slug === '9001-hand-written');
|
|
90
|
+
expect(row).toBeDefined();
|
|
91
|
+
expect(row.status).toBe('quarantined');
|
|
92
|
+
|
|
93
|
+
const warned = warnSpy.mock.calls.some((args) =>
|
|
94
|
+
String(args[0]).includes('quarantining unstamped PRD') && String(args[0]).includes('9001-hand-written'),
|
|
95
|
+
);
|
|
96
|
+
expect(warned).toBe(true);
|
|
97
|
+
|
|
98
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
99
|
+
expect(auditLines.some((rec) => rec.kind === 'prd_quarantined' && rec.slug === '9001-hand-written')).toBe(true);
|
|
100
|
+
} finally {
|
|
101
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('reconcile() queues a newly-discovered PRD stamped with createdVia as pending, not quarantined', async () => {
|
|
106
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-stamped-');
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
path.join(prdsDir, '9002-api-written.md'),
|
|
110
|
+
`---\ntitle: API-written PRD\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
111
|
+
'utf8',
|
|
112
|
+
);
|
|
113
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
114
|
+
|
|
115
|
+
queueStore.bustCwdCache();
|
|
116
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
117
|
+
await scheduler.reconcile(state);
|
|
118
|
+
|
|
119
|
+
const row = state.jobs.find((j) => j.slug === '9002-api-written');
|
|
120
|
+
expect(row).toBeDefined();
|
|
121
|
+
expect(row.status).toBe('pending');
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no createdVia', async () => {
|
|
128
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fixplan-');
|
|
129
|
+
try {
|
|
130
|
+
fs.writeFileSync(
|
|
131
|
+
path.join(prdsDir, '9003-fix-something.md'),
|
|
132
|
+
`---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nHeal it.\n`,
|
|
133
|
+
'utf8',
|
|
134
|
+
);
|
|
135
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
136
|
+
|
|
137
|
+
queueStore.bustCwdCache();
|
|
138
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
139
|
+
await scheduler.reconcile(state);
|
|
140
|
+
|
|
141
|
+
const row = state.jobs.find((j) => j.slug === '9003-fix-something');
|
|
142
|
+
expect(row).toBeDefined();
|
|
143
|
+
expect(row.status).toBe('pending');
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
|
|
150
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
|
|
151
|
+
try {
|
|
152
|
+
const prdPath = path.join(prdsDir, '9004-adopt-me.md');
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
prdPath,
|
|
155
|
+
`---\ntitle: Adopt me\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nAdopt.\n`,
|
|
156
|
+
'utf8',
|
|
157
|
+
);
|
|
158
|
+
const quarantinedRow = {
|
|
159
|
+
slug: '9004-adopt-me',
|
|
160
|
+
title: 'Adopt me',
|
|
161
|
+
cwd,
|
|
162
|
+
status: 'quarantined',
|
|
163
|
+
runId: null,
|
|
164
|
+
startedAt: null,
|
|
165
|
+
finishedAt: null,
|
|
166
|
+
exitCode: null,
|
|
167
|
+
error: null,
|
|
168
|
+
};
|
|
169
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [quarantinedRow] }, null, 2), 'utf8');
|
|
170
|
+
|
|
171
|
+
// Simulate the adopt action: stamp the file via the same update-prd
|
|
172
|
+
// logic the IPC handler/admin route call (parsePrdFile/serializePrdFile
|
|
173
|
+
// round-trip), rather than hand-writing raw text, to exercise the real
|
|
174
|
+
// round-trip path this feature depends on.
|
|
175
|
+
const { parsePrdFile, serializePrdFile } = require('../lib/prdFrontmatter.cjs');
|
|
176
|
+
const raw = fs.readFileSync(prdPath, 'utf8');
|
|
177
|
+
const { frontmatter: fm, body } = parsePrdFile(raw);
|
|
178
|
+
fm.createdVia = 'legacy-adopted';
|
|
179
|
+
fm.issuedAt = '2026-08-07T01:00:00.000Z';
|
|
180
|
+
fs.writeFileSync(prdPath, serializePrdFile(fm, body), 'utf8');
|
|
181
|
+
|
|
182
|
+
const { AUDIT_LOG_PATH } = require('../lib/auditLog.cjs');
|
|
183
|
+
queueStore.bustCwdCache();
|
|
184
|
+
const state = { jobs: [quarantinedRow], invalidJobs: [], paused: null };
|
|
185
|
+
await scheduler.reconcile(state);
|
|
186
|
+
|
|
187
|
+
const row = state.jobs.find((j) => j.slug === '9004-adopt-me');
|
|
188
|
+
expect(row).toBeDefined();
|
|
189
|
+
expect(row.status).toBe('pending');
|
|
190
|
+
|
|
191
|
+
const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
192
|
+
expect(auditLines.some((rec) => rec.kind === 'scheduler_prd_adopted' && rec.slug === '9004-adopt-me')).toBe(true);
|
|
193
|
+
} finally {
|
|
194
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|