claude-code-session-manager 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdParserHighWater.test.cjs — regression test for allocateParallelGroup's
|
|
3
|
+
* high-water mark: once queueOps.cjs's autoArchiveCompleted starts moving
|
|
4
|
+
* old NN-*.md files out of prds/ into prds-archived/, a bare directory scan
|
|
5
|
+
* (maxParallelGroupInUse) can regress once the highest-NN files age out —
|
|
6
|
+
* letting a later allocation reissue an already-used NN and collide with an
|
|
7
|
+
* archived group. The high-water-mark sidecar (.max-allocated-group) must
|
|
8
|
+
* keep the allocator monotonic even after every file is archived away.
|
|
9
|
+
*
|
|
10
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/prdParserHighWater.test.cjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import { test, expect } from 'vitest';
|
|
16
|
+
const fsp = require('node:fs/promises');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const { allocateParallelGroup, maxParallelGroupInUse, _resetCache } = require('../scheduler/prdParser.cjs');
|
|
21
|
+
|
|
22
|
+
function mkTmpPrdsDir() {
|
|
23
|
+
return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-highwater-'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('allocator stays monotonic after every NN-*.md file (and its reservation marker) is archived away', async () => {
|
|
27
|
+
const dir = await mkTmpPrdsDir();
|
|
28
|
+
_resetCache();
|
|
29
|
+
|
|
30
|
+
// Directly-authored legacy files (never allocated via allocateParallelGroup
|
|
31
|
+
// — e.g. /develop's deliberate-sibling path — so no .reserved-NN marker
|
|
32
|
+
// exists for them). This is the real gap: reservation markers alone would
|
|
33
|
+
// already protect any NN the allocator itself minted, so the regression
|
|
34
|
+
// this PRD guards against is specifically legacy/directly-authored NNs.
|
|
35
|
+
await fsp.writeFile(path.join(dir, '10-a.md'), '# a\n');
|
|
36
|
+
await fsp.writeFile(path.join(dir, '544-b.md'), '# b\n');
|
|
37
|
+
|
|
38
|
+
// A scan (via allocateParallelGroup) observes 544 and persists it as the
|
|
39
|
+
// high-water mark before returning the next free NN.
|
|
40
|
+
const first = await allocateParallelGroup(dir);
|
|
41
|
+
expect(first).toBe(545);
|
|
42
|
+
|
|
43
|
+
// Simulate autoArchiveCompleted moving every .md out of the live dir, and
|
|
44
|
+
// also strip the reservation marker the allocation above created — so
|
|
45
|
+
// nothing on disk still evidences 544/545 except the high-water sidecar.
|
|
46
|
+
await fsp.unlink(path.join(dir, '10-a.md'));
|
|
47
|
+
await fsp.unlink(path.join(dir, '544-b.md'));
|
|
48
|
+
await fsp.unlink(path.join(dir, '.reserved-545'));
|
|
49
|
+
|
|
50
|
+
const bareScanMax = await maxParallelGroupInUse(dir);
|
|
51
|
+
expect(bareScanMax).toBe(0);
|
|
52
|
+
|
|
53
|
+
_resetCache();
|
|
54
|
+
const second = await allocateParallelGroup(dir);
|
|
55
|
+
expect(second).toBeGreaterThan(545);
|
|
56
|
+
expect(second).toBe(546);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('high-water mark also protects against a fresh scan that is lower than a prior allocation, without any archiving involved', async () => {
|
|
60
|
+
const dir = await mkTmpPrdsDir();
|
|
61
|
+
_resetCache();
|
|
62
|
+
|
|
63
|
+
await fsp.writeFile(path.join(dir, '5-only.md'), '# x\n');
|
|
64
|
+
const nn = await allocateParallelGroup(dir);
|
|
65
|
+
expect(nn).toBe(6);
|
|
66
|
+
|
|
67
|
+
// A slow/partial directory listing (or a caller passing a differently
|
|
68
|
+
// scoped dir) must not be able to walk the allocator backwards.
|
|
69
|
+
const rescanMax = await maxParallelGroupInUse(dir);
|
|
70
|
+
expect(rescanMax).toBe(6); // the .reserved-6 marker from the allocation above still counts
|
|
71
|
+
|
|
72
|
+
const next = await allocateParallelGroup(dir);
|
|
73
|
+
expect(next).toBe(7);
|
|
74
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueHistory.test.cjs — unit tests for src/main/lib/queueHistory.cjs and
|
|
3
|
+
* scheduler.cjs's selectHistoryJobs merge.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/queueHistory.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
16
|
+
const NOW = Date.parse('2026-07-24T12:00:00.000Z');
|
|
17
|
+
|
|
18
|
+
function fresh(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
slug: '01-fresh',
|
|
21
|
+
status: 'completed',
|
|
22
|
+
runId: 'run-1',
|
|
23
|
+
finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString(), // 1h ago
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function old(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
slug: '02-old',
|
|
31
|
+
status: 'completed',
|
|
32
|
+
runId: 'run-2',
|
|
33
|
+
finishedAt: new Date(NOW - 10 * 24 * 60 * 60_000).toISOString(), // 10d ago
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let queueHistory;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// Fresh require per test so HISTORY_PATH's target file starts clean.
|
|
42
|
+
delete require.cache[require.resolve('../lib/queueHistory.cjs')];
|
|
43
|
+
queueHistory = require('../lib/queueHistory.cjs');
|
|
44
|
+
try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
try { fs.unlinkSync(queueHistory.HISTORY_PATH); } catch { /* ok if absent */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------- partitionJobs ----------
|
|
52
|
+
|
|
53
|
+
test('partitionJobs: fresh completed job stays hot', () => {
|
|
54
|
+
const { hot, toArchive } = queueHistory.partitionJobs([fresh()], NOW);
|
|
55
|
+
expect(hot.map((j) => j.slug)).toEqual(['01-fresh']);
|
|
56
|
+
expect(toArchive).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('partitionJobs: old completed job archives', () => {
|
|
60
|
+
const { hot, toArchive } = queueHistory.partitionJobs([old()], NOW);
|
|
61
|
+
expect(hot).toEqual([]);
|
|
62
|
+
expect(toArchive.map((j) => j.slug)).toEqual(['02-old']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('partitionJobs: old failed job archives', () => {
|
|
66
|
+
const j = old({ slug: '03-old-failed', status: 'failed', runId: 'run-3' });
|
|
67
|
+
const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
|
|
68
|
+
expect(hot).toEqual([]);
|
|
69
|
+
expect(toArchive.map((x) => x.slug)).toEqual(['03-old-failed']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('partitionJobs: running and pending jobs never archive, regardless of age', () => {
|
|
73
|
+
const running = old({ slug: '04-running', status: 'running', runId: 'run-4', finishedAt: null });
|
|
74
|
+
const pending = old({ slug: '05-pending', status: 'pending', runId: null, finishedAt: null });
|
|
75
|
+
const { hot, toArchive } = queueHistory.partitionJobs([running, pending], NOW);
|
|
76
|
+
expect(toArchive).toEqual([]);
|
|
77
|
+
expect(hot.map((j) => j.slug).sort()).toEqual(['04-running', '05-pending']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('partitionJobs: needs_review jobs never archive', () => {
|
|
81
|
+
const j = old({ slug: '06-review', status: 'needs_review', runId: 'run-6' });
|
|
82
|
+
const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
|
|
83
|
+
expect(toArchive).toEqual([]);
|
|
84
|
+
expect(hot.map((x) => x.slug)).toEqual(['06-review']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('partitionJobs: job with no/invalid finishedAt stays hot even if old status', () => {
|
|
88
|
+
const j = { slug: '07-nofinish', status: 'completed', runId: 'run-7', finishedAt: null };
|
|
89
|
+
const { hot, toArchive } = queueHistory.partitionJobs([j], NOW);
|
|
90
|
+
expect(toArchive).toEqual([]);
|
|
91
|
+
expect(hot.map((x) => x.slug)).toEqual(['07-nofinish']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('partitionJobs: fix-plan linkage keeps an old original hot while its fix is pending', () => {
|
|
95
|
+
const original = old({ slug: '08-thing', status: 'failed', runId: 'run-8' });
|
|
96
|
+
const fixPlan = { slug: '08-fix-thing', status: 'pending', runId: null, finishedAt: null };
|
|
97
|
+
const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
|
|
98
|
+
expect(toArchive).toEqual([]);
|
|
99
|
+
expect(hot.map((x) => x.slug).sort()).toEqual(['08-fix-thing', '08-thing']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('partitionJobs: fix-plan linkage keeps an old original hot while its fix is running', () => {
|
|
103
|
+
const original = old({ slug: '09-thing', status: 'failed', runId: 'run-9' });
|
|
104
|
+
const fixPlan = { slug: '09-fix-thing', status: 'running', runId: 'run-9b', finishedAt: null };
|
|
105
|
+
const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
|
|
106
|
+
expect(toArchive).toEqual([]);
|
|
107
|
+
expect(hot.map((x) => x.slug).sort()).toEqual(['09-fix-thing', '09-thing']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('partitionJobs: an old original with a COMPLETED fix-plan is free to archive (no longer protected)', () => {
|
|
111
|
+
const original = old({ slug: '10-thing', status: 'completed', runId: 'run-10' });
|
|
112
|
+
const fixPlan = old({ slug: '10-fix-thing', status: 'completed', runId: 'run-10b' });
|
|
113
|
+
const { hot, toArchive } = queueHistory.partitionJobs([original, fixPlan], NOW);
|
|
114
|
+
expect(hot).toEqual([]);
|
|
115
|
+
expect(toArchive.map((x) => x.slug).sort()).toEqual(['10-fix-thing', '10-thing']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('partitionJobs: respects opts.retentionMs override', () => {
|
|
119
|
+
const j = { slug: '11-recent', status: 'completed', runId: 'run-11', finishedAt: new Date(NOW - 2 * 60 * 60_000).toISOString() };
|
|
120
|
+
const { toArchive } = queueHistory.partitionJobs([j], NOW, { retentionMs: 60 * 60_000 }); // 1h retention
|
|
121
|
+
expect(toArchive.map((x) => x.slug)).toEqual(['11-recent']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---------- appendHistory / readHistory ----------
|
|
125
|
+
|
|
126
|
+
test('appendHistory writes entries, readHistory reads them back newest-first', async () => {
|
|
127
|
+
const a = old({ slug: 'a', runId: 'ra', finishedAt: new Date(NOW - 3 * 60 * 60_000).toISOString() });
|
|
128
|
+
const b = old({ slug: 'b', runId: 'rb', finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString() });
|
|
129
|
+
await queueHistory.appendHistory([a, b]);
|
|
130
|
+
const read = await queueHistory.readHistory({ limit: 10 });
|
|
131
|
+
// appended in file order a,b; readHistory reverses file order to newest-appended-first
|
|
132
|
+
expect(read.map((j) => j.slug)).toEqual(['b', 'a']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('appendHistory dedupes by slug+runId against what is already on disk', async () => {
|
|
136
|
+
const a = old({ slug: 'dup', runId: 'r1' });
|
|
137
|
+
const r1 = await queueHistory.appendHistory([a]);
|
|
138
|
+
expect(r1.appended).toBe(1);
|
|
139
|
+
const r2 = await queueHistory.appendHistory([a]); // replay of the same batch (crash-recovery scenario)
|
|
140
|
+
expect(r2.appended).toBe(0);
|
|
141
|
+
const read = await queueHistory.readHistory({ limit: 10 });
|
|
142
|
+
expect(read.length).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('readHistory respects limit and returns [] when file is absent', async () => {
|
|
146
|
+
const empty = await queueHistory.readHistory({ limit: 5 });
|
|
147
|
+
expect(empty).toEqual([]);
|
|
148
|
+
|
|
149
|
+
const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - i * 60_000).toISOString() }));
|
|
150
|
+
await queueHistory.appendHistory(entries);
|
|
151
|
+
const read = await queueHistory.readHistory({ limit: 3 });
|
|
152
|
+
expect(read.length).toBe(3);
|
|
153
|
+
// newest-appended-first == last 3 entries reversed
|
|
154
|
+
expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('readHistory tail-reads correctly when the file is larger than one chunk', async () => {
|
|
158
|
+
// Force many small appends so the file exceeds the initial 64KB read chunk.
|
|
159
|
+
const bigEntries = Array.from({ length: 2000 }, (_, i) => ({
|
|
160
|
+
slug: `bulk-${i}`,
|
|
161
|
+
status: 'completed',
|
|
162
|
+
runId: `bulk-run-${i}`,
|
|
163
|
+
finishedAt: new Date(NOW - i * 1000).toISOString(),
|
|
164
|
+
bodyPreview: 'x'.repeat(80),
|
|
165
|
+
}));
|
|
166
|
+
await queueHistory.appendHistory(bigEntries);
|
|
167
|
+
const read = await queueHistory.readHistory({ limit: 5 });
|
|
168
|
+
// appended in ascending index order; readHistory reverses to newest-appended-first
|
|
169
|
+
expect(read.map((j) => j.slug)).toEqual(['bulk-1999', 'bulk-1998', 'bulk-1997', 'bulk-1996', 'bulk-1995']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------- selectHistoryJobs merge (scheduler.cjs) ----------
|
|
173
|
+
|
|
174
|
+
test('selectHistoryJobs merges hot jobs[] and history tail, newest-first, deduped', () => {
|
|
175
|
+
const { selectHistoryJobs } = require('../scheduler.cjs');
|
|
176
|
+
const hotJob = fresh({ slug: 'hot-1', runId: 'rh1' });
|
|
177
|
+
const historyEntry = old({ slug: 'hist-1', runId: 'rhist1' });
|
|
178
|
+
const merged = selectHistoryJobs([hotJob], 10, [historyEntry]);
|
|
179
|
+
expect(merged.map((j) => j.slug)).toEqual(['hot-1', 'hist-1']); // hot is newer
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('selectHistoryJobs: jobs[] entry wins over a duplicate history entry with the same slug+runId', () => {
|
|
183
|
+
const { selectHistoryJobs } = require('../scheduler.cjs');
|
|
184
|
+
const hotCopy = fresh({ slug: 'dup', runId: 'rd', bodyPreview: 'from-hot' });
|
|
185
|
+
const historyCopy = fresh({ slug: 'dup', runId: 'rd', bodyPreview: 'from-history' });
|
|
186
|
+
const merged = selectHistoryJobs([hotCopy], 10, [historyCopy]);
|
|
187
|
+
expect(merged.length).toBe(1);
|
|
188
|
+
expect(merged[0].bodyPreview).toBe('from-hot');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('selectHistoryJobs: default historyEntries=[] preserves old call shape', () => {
|
|
192
|
+
const { selectHistoryJobs } = require('../scheduler.cjs');
|
|
193
|
+
const merged = selectHistoryJobs([fresh()], 10);
|
|
194
|
+
expect(merged.map((j) => j.slug)).toEqual(['01-fresh']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------- historyTerminalBySlug ----------
|
|
198
|
+
//
|
|
199
|
+
// Guards against scheduler.cjs's reconcile() resurrecting an already-
|
|
200
|
+
// archived-to-history job as a fresh 'pending' entry (and the scheduler
|
|
201
|
+
// then genuinely re-executing an already-completed PRD) just because its
|
|
202
|
+
// job row already left jobs[]. reconcile() consults this map before
|
|
203
|
+
// treating an unmatched on-disk PRD slug as brand-new.
|
|
204
|
+
|
|
205
|
+
test('historyTerminalBySlug: returns status + finishedAt for every archived slug', async () => {
|
|
206
|
+
const a = old({ slug: 'term-completed', status: 'completed', runId: 'rc' });
|
|
207
|
+
const b = old({ slug: 'term-failed', status: 'failed', runId: 'rf' });
|
|
208
|
+
await queueHistory.appendHistory([a, b]);
|
|
209
|
+
|
|
210
|
+
const map = await queueHistory.historyTerminalBySlug();
|
|
211
|
+
expect(map.get('term-completed')).toEqual({ status: 'completed', finishedAt: a.finishedAt });
|
|
212
|
+
expect(map.get('term-failed')).toEqual({ status: 'failed', finishedAt: b.finishedAt });
|
|
213
|
+
expect(map.has('never-archived-slug')).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('historyTerminalBySlug: returns an empty map when history.jsonl is absent', async () => {
|
|
217
|
+
const map = await queueHistory.historyTerminalBySlug();
|
|
218
|
+
expect(map.size).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('historyTerminalBySlug: cache invalidates after a new appendHistory call (mtime changes)', async () => {
|
|
222
|
+
const a = old({ slug: 'cache-1', runId: 'rca' });
|
|
223
|
+
await queueHistory.appendHistory([a]);
|
|
224
|
+
const first = await queueHistory.historyTerminalBySlug();
|
|
225
|
+
expect(first.has('cache-1')).toBe(true);
|
|
226
|
+
expect(first.has('cache-2')).toBe(false);
|
|
227
|
+
|
|
228
|
+
const b = old({ slug: 'cache-2', runId: 'rcb' });
|
|
229
|
+
await queueHistory.appendHistory([b]);
|
|
230
|
+
const second = await queueHistory.historyTerminalBySlug();
|
|
231
|
+
expect(second.has('cache-1')).toBe(true);
|
|
232
|
+
expect(second.has('cache-2')).toBe(true);
|
|
233
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueOpsAutoArchive.test.cjs — unit tests for queueOps.cjs's
|
|
3
|
+
* selectAutoArchivable predicate + autoArchiveCompleted orchestration
|
|
4
|
+
* (auto-archiving completed PRDs' .md files out of the live prds/ dir).
|
|
5
|
+
*
|
|
6
|
+
* selectAutoArchivable is pure (no fs) so it's fully covered here without
|
|
7
|
+
* touching disk. autoArchiveCompleted's disk-mutating path (archiveMany) is
|
|
8
|
+
* exercised only via its kill-switch / no-op branches, since archiveMany
|
|
9
|
+
* targets the real ~/.claude/session-manager/scheduled-plans/prds-archived
|
|
10
|
+
* directory (not test-injectable) — mutating that from a test run would be
|
|
11
|
+
* an unwanted side effect on the developer's real filesystem.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/queueOpsAutoArchive.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
import { test, expect, afterEach } from 'vitest';
|
|
19
|
+
|
|
20
|
+
const RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
21
|
+
const NOW = Date.parse('2026-07-24T12:00:00.000Z');
|
|
22
|
+
|
|
23
|
+
const { selectAutoArchivable, autoArchiveCompleted } = require('../queueOps.cjs');
|
|
24
|
+
|
|
25
|
+
function fresh(overrides = {}) {
|
|
26
|
+
return {
|
|
27
|
+
slug: '01-fresh',
|
|
28
|
+
status: 'completed',
|
|
29
|
+
finishedAt: new Date(NOW - 1 * 60 * 60_000).toISOString(), // 1h ago
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function old(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
slug: '02-old',
|
|
37
|
+
status: 'completed',
|
|
38
|
+
finishedAt: new Date(NOW - 10 * 24 * 60 * 60_000).toISOString(), // 10d ago
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
delete process.env.SM_PRD_AUTOARCHIVE_DISABLE;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------- selectAutoArchivable ----------
|
|
48
|
+
|
|
49
|
+
test('selectAutoArchivable: fresh completed job stays (not old enough)', () => {
|
|
50
|
+
const slugs = selectAutoArchivable([fresh()], { nowMs: NOW });
|
|
51
|
+
expect(slugs).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('selectAutoArchivable: old completed job archives', () => {
|
|
55
|
+
const slugs = selectAutoArchivable([old()], { nowMs: NOW });
|
|
56
|
+
expect(slugs).toEqual(['02-old']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('selectAutoArchivable: needs_review jobs never archive, regardless of age', () => {
|
|
60
|
+
const j = old({ slug: '03-review', status: 'needs_review' });
|
|
61
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
62
|
+
expect(slugs).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('selectAutoArchivable: failed jobs never archive, regardless of age', () => {
|
|
66
|
+
const j = old({ slug: '04-failed', status: 'failed' });
|
|
67
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
68
|
+
expect(slugs).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('selectAutoArchivable: pending jobs never archive', () => {
|
|
72
|
+
const j = old({ slug: '05-pending', status: 'pending', finishedAt: null });
|
|
73
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
74
|
+
expect(slugs).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('selectAutoArchivable: running jobs never archive', () => {
|
|
78
|
+
const j = old({ slug: '06-running', status: 'running', finishedAt: null });
|
|
79
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
80
|
+
expect(slugs).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('selectAutoArchivable: a completed job with a PENDING NN-fix-<slug> sibling is protected', () => {
|
|
84
|
+
const original = old({ slug: '07-thing', status: 'completed' });
|
|
85
|
+
const fixPlan = { slug: '07-fix-thing', status: 'pending', finishedAt: null };
|
|
86
|
+
const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW });
|
|
87
|
+
expect(slugs).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('selectAutoArchivable: a completed job with a RUNNING NN-fix-<slug> sibling is protected', () => {
|
|
91
|
+
const original = old({ slug: '08-thing', status: 'completed' });
|
|
92
|
+
const fixPlan = { slug: '08-fix-thing', status: 'running', finishedAt: null };
|
|
93
|
+
const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW });
|
|
94
|
+
expect(slugs).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('selectAutoArchivable: a completed job with a COMPLETED fix-plan sibling is free to archive (no longer protected)', () => {
|
|
98
|
+
const original = old({ slug: '09-thing', status: 'completed' });
|
|
99
|
+
const fixPlan = old({ slug: '09-fix-thing', status: 'completed' });
|
|
100
|
+
const slugs = selectAutoArchivable([original, fixPlan], { nowMs: NOW }).sort();
|
|
101
|
+
expect(slugs).toEqual(['09-fix-thing', '09-thing']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('selectAutoArchivable: job with no/invalid finishedAt never archives even if status completed', () => {
|
|
105
|
+
const j = { slug: '10-nofinish', status: 'completed', finishedAt: null };
|
|
106
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
107
|
+
expect(slugs).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('selectAutoArchivable: respects retentionMs override', () => {
|
|
111
|
+
const j = { slug: '11-recent', status: 'completed', finishedAt: new Date(NOW - 2 * 60 * 60_000).toISOString() };
|
|
112
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW, retentionMs: 60 * 60_000 }); // 1h retention
|
|
113
|
+
expect(slugs).toEqual(['11-recent']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('selectAutoArchivable: defaults retentionMs to the shared HISTORY_RETENTION_MS constant', () => {
|
|
117
|
+
const j = { slug: '12-boundary', status: 'completed', finishedAt: new Date(NOW - RETENTION_MS - 1).toISOString() };
|
|
118
|
+
const slugs = selectAutoArchivable([j], { nowMs: NOW });
|
|
119
|
+
expect(slugs).toEqual(['12-boundary']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------- autoArchiveCompleted ----------
|
|
123
|
+
|
|
124
|
+
test('autoArchiveCompleted: kill switch SM_PRD_AUTOARCHIVE_DISABLE=1 skips entirely', async () => {
|
|
125
|
+
process.env.SM_PRD_AUTOARCHIVE_DISABLE = '1';
|
|
126
|
+
const result = await autoArchiveCompleted({ jobs: [old()] }, { nowMs: NOW });
|
|
127
|
+
expect(result.archived).toBe(0);
|
|
128
|
+
expect(result.skipped).toBe('disabled');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('autoArchiveCompleted: no eligible slugs returns a no-op without touching disk', async () => {
|
|
132
|
+
const result = await autoArchiveCompleted({ jobs: [fresh()] }, { nowMs: NOW });
|
|
133
|
+
expect(result.ok).toBe(true);
|
|
134
|
+
expect(result.archived).toBe(0);
|
|
135
|
+
expect(result.archivedTo).toBe(null);
|
|
136
|
+
expect(result.results).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async () => {
|
|
140
|
+
const result = await autoArchiveCompleted({}, { nowMs: NOW });
|
|
141
|
+
expect(result.archived).toBe(0);
|
|
142
|
+
});
|