claude-code-session-manager 0.72.0 → 0.73.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-CXw7Z5Iw.js → AgentLibrary-BowrgaXK.js} +1 -1
- package/dist/assets/{History-D64U_aDf.js → History-B8rJXcxk.js} +1 -1
- package/dist/assets/{Hooks-BFVkM6QH.js → Hooks-D-KzXjDk.js} +1 -1
- package/dist/assets/{HostBilko-BRERoCra.js → HostBilko-BSJhBE9I.js} +1 -1
- package/dist/assets/{Library-C6shVsjv.js → Library-CtqgqUc6.js} +1 -1
- package/dist/assets/{ListDetail-dXWSZuDV.js → ListDetail-D7k4TXvO.js} +1 -1
- package/dist/assets/{MarkdownEditor-D_akyPJ2.js → MarkdownEditor-DFm0I9B_.js} +1 -1
- package/dist/assets/{McpServers-DxROkYGa.js → McpServers-DDK04FMq.js} +1 -1
- package/dist/assets/{Memory-OqUyTRrr.js → Memory-B1p59Mas.js} +1 -1
- package/dist/assets/{Panel-Biw98xRO.js → Panel-BJpqcDzp.js} +1 -1
- package/dist/assets/{Permissions-CIdZOzK8.js → Permissions-CFC3RusZ.js} +1 -1
- package/dist/assets/{Plugins-Dt147wEE.js → Plugins-TeCQMr6X.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BUikIHf4.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
- package/dist/assets/{SaveBar-BoHRDHlp.js → SaveBar-7SmNcZB-.js} +1 -1
- package/dist/assets/{Scheduler-CipmAcy7.js → Scheduler-B9ovEniK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CkCwUYU5.js → ScopeSwitcher-KmemfTLa.js} +1 -1
- package/dist/assets/Settings-CxZijd66.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BuSuA3B7.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
- package/dist/assets/{Skills-BM8w5BGV.js → Skills-CFZU6ltE.js} +1 -1
- package/dist/assets/{SystemPrompt-BWOdtfze.js → SystemPrompt-3tHpyoge.js} +1 -1
- package/dist/assets/{TagLibrary-cn3JECC5.js → TagLibrary-BoJHe-aY.js} +1 -1
- package/dist/assets/{TiptapBody-BwoXKY8J.js → TiptapBody-DzlxVXaw.js} +1 -1
- package/dist/assets/{Toggle-BlNVxkq-.js → Toggle-DKmoif0X.js} +1 -1
- package/dist/assets/{index-BpzvONtX.js → index-Dtv0whpT.js} +672 -672
- package/dist/assets/{index-DDpA9yBe.css → index-N_v_4fqC.css} +1 -1
- package/dist/assets/{settingsSchema-DePBZnaw.js → settingsSchema-CW-0QfXi.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +10 -1
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +35 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
- package/src/main/lib/epicSpawnCwd.cjs +95 -0
- package/src/main/lib/epicWorktreeBoot.cjs +49 -0
- package/src/main/lib/epicWorktreeMerge.cjs +63 -0
- package/src/main/lib/epicWorktreeMint.cjs +54 -0
- package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
- package/src/main/lib/gitWorktree.cjs +446 -0
- package/src/main/lib/jobWorktree.cjs +36 -293
- package/src/main/lib/promptSessionSchema.cjs +15 -0
- package/src/main/lib/queueStore.cjs +18 -0
- package/src/main/lib/terminalRunOutcome.cjs +14 -2
- package/src/main/pty.cjs +11 -1
- package/src/main/queueOps.cjs +40 -12
- package/src/main/scheduler.cjs +14 -0
- package/src/preload/api.d.ts +59 -0
- package/src/preload/index.cjs +22 -0
- package/dist/assets/Settings-CmU_7z2e.js +0 -3
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitWorktree.test.cjs — PRD 1032: generalized `git worktree` isolation
|
|
3
|
+
* primitives, backing both the job kind (unchanged behavior, mirrored from
|
|
4
|
+
* jobWorktree.test.cjs) and the new epic kind.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the real `git worktree` plumbing against throwaway repos under
|
|
7
|
+
* os.tmpdir() (fast — no network, no Electron).
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktree.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
20
|
+
|
|
21
|
+
let tmpRoot;
|
|
22
|
+
let repoCwd;
|
|
23
|
+
let originalJobDisable;
|
|
24
|
+
let originalJobMax;
|
|
25
|
+
let originalEpicDisable;
|
|
26
|
+
let originalEpicMax;
|
|
27
|
+
|
|
28
|
+
function git(args, cwd) {
|
|
29
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function initRepo(dir) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
git(['init', '-q'], dir);
|
|
35
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
36
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
38
|
+
git(['add', '-A'], dir);
|
|
39
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-'));
|
|
44
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
45
|
+
initRepo(repoCwd);
|
|
46
|
+
originalJobDisable = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
47
|
+
originalJobMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
48
|
+
originalEpicDisable = process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
49
|
+
originalEpicMax = process.env.SM_EPIC_WORKTREE_MAX;
|
|
50
|
+
delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
51
|
+
delete process.env.SM_JOB_WORKTREE_MAX;
|
|
52
|
+
delete process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
53
|
+
delete process.env.SM_EPIC_WORKTREE_MAX;
|
|
54
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
55
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
const restore = (name, val) => { if (val === undefined) delete process.env[name]; else process.env[name] = val; };
|
|
60
|
+
restore('SM_JOB_WORKTREE_DISABLE', originalJobDisable);
|
|
61
|
+
restore('SM_JOB_WORKTREE_MAX', originalJobMax);
|
|
62
|
+
restore('SM_EPIC_WORKTREE_DISABLE', originalEpicDisable);
|
|
63
|
+
restore('SM_EPIC_WORKTREE_MAX', originalEpicMax);
|
|
64
|
+
// Best-effort: prune any worktree either kind's tests created before removing the repo.
|
|
65
|
+
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' }); } catch { /* ignore */ }
|
|
66
|
+
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' }); } catch { /* ignore */ }
|
|
67
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ──────────────────────────────────────────── job kind (mirrors jobWorktree.test.cjs)
|
|
71
|
+
|
|
72
|
+
test('[job] createJobWorktree creates a linked worktree on a fresh branch from HEAD', async () => {
|
|
73
|
+
const slug = 'test-slug-create';
|
|
74
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
75
|
+
expect(result.ok).toBe(true);
|
|
76
|
+
expect(fs.existsSync(result.dir)).toBe(true);
|
|
77
|
+
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
78
|
+
expect(result.branch).toBe(`sm-job/${slug}`);
|
|
79
|
+
|
|
80
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
81
|
+
expect(fs.existsSync(result.dir)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('[job] a commit made inside the worktree becomes visible on the main tree HEAD after integrateJobBranch', async () => {
|
|
85
|
+
const slug = 'test-slug-integrate';
|
|
86
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
87
|
+
expect(worktree.ok).toBe(true);
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
|
|
90
|
+
git(['add', '-A'], worktree.dir);
|
|
91
|
+
git(['commit', '-q', '-m', 'job commit'], worktree.dir);
|
|
92
|
+
const jobCommitSha = git(['rev-parse', 'HEAD'], worktree.dir).trim();
|
|
93
|
+
|
|
94
|
+
const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
95
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
96
|
+
expect(outcome.ok).toBe(true);
|
|
97
|
+
expect(outcome.integrated).toBe(true);
|
|
98
|
+
|
|
99
|
+
const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
|
|
100
|
+
expect(headAfter).not.toBe(headBefore);
|
|
101
|
+
expect(headAfter).toBe(jobCommitSha);
|
|
102
|
+
expect(fs.existsSync(path.join(repoCwd, 'new-file.txt'))).toBe(true);
|
|
103
|
+
|
|
104
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
105
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('[job] integrateJobBranch is a no-op (integrated:false) when the branch has no new commits', async () => {
|
|
109
|
+
const slug = 'test-slug-noop';
|
|
110
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
111
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
112
|
+
expect(outcome.ok).toBe(true);
|
|
113
|
+
expect(outcome.integrated).toBe(false);
|
|
114
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('[job] a real merge conflict surfaces as an explicit integration failure and preserves the branch', async () => {
|
|
118
|
+
const slug = 'test-slug-conflict';
|
|
119
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
120
|
+
|
|
121
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
122
|
+
git(['add', '-A'], worktree.dir);
|
|
123
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
124
|
+
|
|
125
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'main tree edit\n', 'utf8');
|
|
126
|
+
git(['add', '-A'], repoCwd);
|
|
127
|
+
git(['commit', '-q', '-m', 'main tree edits README'], repoCwd);
|
|
128
|
+
|
|
129
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
130
|
+
expect(outcome.ok).toBe(false);
|
|
131
|
+
expect(outcome.reason).toMatch(/conflict|merge failed/i);
|
|
132
|
+
|
|
133
|
+
const status = git(['status', '--porcelain'], repoCwd);
|
|
134
|
+
expect(status.trim()).toBe('');
|
|
135
|
+
|
|
136
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
|
|
137
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
138
|
+
expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('[job] merge-commit message text is byte-for-byte unchanged from the pre-generalization jobWorktree.cjs', async () => {
|
|
142
|
+
const slug = 'test-slug-merge-message';
|
|
143
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
144
|
+
|
|
145
|
+
fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'job edit\n', 'utf8');
|
|
146
|
+
git(['add', '-A'], worktree.dir);
|
|
147
|
+
git(['commit', '-q', '-m', 'job edits README'], worktree.dir);
|
|
148
|
+
|
|
149
|
+
fs.writeFileSync(path.join(repoCwd, 'other.txt'), 'main tree edit\n', 'utf8');
|
|
150
|
+
git(['add', '-A'], repoCwd);
|
|
151
|
+
git(['commit', '-q', '-m', 'main tree adds other.txt'], repoCwd);
|
|
152
|
+
|
|
153
|
+
const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
|
|
154
|
+
expect(outcome.ok).toBe(true);
|
|
155
|
+
expect(outcome.mergeCommit).toBe(true);
|
|
156
|
+
|
|
157
|
+
const lastMessage = git(['log', '-1', '--format=%s'], repoCwd).trim();
|
|
158
|
+
expect(lastMessage).toBe(`merge scheduler job ${slug}`);
|
|
159
|
+
|
|
160
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('[job] falls back with a reason for a non-git cwd', async () => {
|
|
164
|
+
const nonGitDir = path.join(tmpRoot, 'not-a-repo');
|
|
165
|
+
fs.mkdirSync(nonGitDir, { recursive: true });
|
|
166
|
+
const result = await gitWorktree.createJobWorktree({ cwd: nonGitDir, slug: 'x' });
|
|
167
|
+
expect(result.ok).toBe(false);
|
|
168
|
+
expect(result.reason).toMatch(/not a git repository/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('[job] falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
|
|
172
|
+
fs.writeFileSync(path.join(repoCwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
|
|
173
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
174
|
+
expect(result.ok).toBe(false);
|
|
175
|
+
expect(result.reason).toMatch(/uncommitted changes/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|
|
179
|
+
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
180
|
+
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
|
|
181
|
+
expect(first.ok).toBe(true);
|
|
182
|
+
|
|
183
|
+
const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-2' });
|
|
184
|
+
expect(second.ok).toBe(false);
|
|
185
|
+
expect(second.reason).toMatch(/cap reached/);
|
|
186
|
+
|
|
187
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
188
|
+
|
|
189
|
+
const third = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-3' });
|
|
190
|
+
expect(third.ok).toBe(true);
|
|
191
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('[job] SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
|
|
195
|
+
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
196
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
197
|
+
expect(result.ok).toBe(false);
|
|
198
|
+
expect(result.reason).toMatch(/SM_JOB_WORKTREE_DISABLE/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('[job] reconcileWorktreesOnBoot removes a leaked worktree (simulated crash) and its branch', async () => {
|
|
202
|
+
const slug = 'test-slug-leak';
|
|
203
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
204
|
+
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
205
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
206
|
+
|
|
207
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
208
|
+
|
|
209
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
210
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
211
|
+
expect(branches.trim()).toBe('');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('[job] reconcileWorktreesOnBoot defaults to kind:job when no opts are given', async () => {
|
|
215
|
+
const slug = 'test-slug-default-kind';
|
|
216
|
+
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
217
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd]);
|
|
218
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('[job] reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_ROOT', async () => {
|
|
222
|
+
const humanWorktreeDir = path.join(tmpRoot, 'human-made-worktree');
|
|
223
|
+
git(['worktree', 'add', '-b', 'human-branch', humanWorktreeDir, 'HEAD'], repoCwd);
|
|
224
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
|
|
225
|
+
expect(fs.existsSync(humanWorktreeDir)).toBe(true);
|
|
226
|
+
git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ──────────────────────────────────────────── epic kind (new)
|
|
230
|
+
|
|
231
|
+
test('[epic] createEpicWorktree names the branch sm-epic/<id>, distinct from the job root', async () => {
|
|
232
|
+
const epicId = 'my-epic-abc123';
|
|
233
|
+
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
234
|
+
expect(result.ok).toBe(true);
|
|
235
|
+
expect(result.branch).toBe(`sm-epic/${epicId}`);
|
|
236
|
+
expect(result.dir.startsWith(gitWorktree.worktreeRootFor('epic'))).toBe(true);
|
|
237
|
+
expect(result.dir.startsWith(gitWorktree.worktreeRootFor('job'))).toBe(false);
|
|
238
|
+
expect(fs.existsSync(path.join(result.dir, 'README.md'))).toBe(true);
|
|
239
|
+
|
|
240
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
241
|
+
expect(fs.existsSync(result.dir)).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('[epic] a commit made inside an epic worktree integrates onto the main tree HEAD', async () => {
|
|
245
|
+
const epicId = 'epic-integrate';
|
|
246
|
+
const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
247
|
+
expect(worktree.ok).toBe(true);
|
|
248
|
+
|
|
249
|
+
fs.writeFileSync(path.join(worktree.dir, 'epic-file.txt'), 'epic output\n', 'utf8');
|
|
250
|
+
git(['add', '-A'], worktree.dir);
|
|
251
|
+
git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
|
|
252
|
+
|
|
253
|
+
const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
|
|
254
|
+
expect(outcome.ok).toBe(true);
|
|
255
|
+
expect(outcome.integrated).toBe(true);
|
|
256
|
+
expect(fs.existsSync(path.join(repoCwd, 'epic-file.txt'))).toBe(true);
|
|
257
|
+
|
|
258
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('[epic] no premature cap eviction — many concurrent epic worktrees stay under the default epic cap', async () => {
|
|
262
|
+
const created = [];
|
|
263
|
+
// The job kind's default cap is 4; prove the epic kind doesn't inherit that
|
|
264
|
+
// ceiling by creating more than 4 concurrent epic worktrees successfully.
|
|
265
|
+
for (let i = 0; i < 6; i++) {
|
|
266
|
+
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: `epic-cap-${i}` });
|
|
267
|
+
expect(result.ok).toBe(true);
|
|
268
|
+
created.push(result);
|
|
269
|
+
}
|
|
270
|
+
expect(gitWorktree._getActiveWorktreeCountForTests('epic')).toBe(6);
|
|
271
|
+
// The job counter must be completely unaffected by epic-kind activity.
|
|
272
|
+
expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(0);
|
|
273
|
+
|
|
274
|
+
for (const worktree of created) {
|
|
275
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('[epic] SM_EPIC_WORKTREE_MAX raises/lowers the epic cap independently of the job cap', async () => {
|
|
280
|
+
process.env.SM_EPIC_WORKTREE_MAX = '1';
|
|
281
|
+
const first = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-1' });
|
|
282
|
+
expect(first.ok).toBe(true);
|
|
283
|
+
|
|
284
|
+
const second = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'epic-max-2' });
|
|
285
|
+
expect(second.ok).toBe(false);
|
|
286
|
+
expect(second.reason).toMatch(/cap reached/);
|
|
287
|
+
|
|
288
|
+
// The job cap (default 4) is untouched by SM_EPIC_WORKTREE_MAX=1.
|
|
289
|
+
const job1 = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'job-max-1' });
|
|
290
|
+
expect(job1.ok).toBe(true);
|
|
291
|
+
|
|
292
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
|
|
293
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: job1.dir, branch: job1.branch });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independently of the job kill-switch', async () => {
|
|
297
|
+
process.env.SM_EPIC_WORKTREE_DISABLE = '1';
|
|
298
|
+
const epicResult = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'x' });
|
|
299
|
+
expect(epicResult.ok).toBe(false);
|
|
300
|
+
expect(epicResult.reason).toMatch(/SM_EPIC_WORKTREE_DISABLE/);
|
|
301
|
+
|
|
302
|
+
// The job kind must still work — the two kill-switches are independent.
|
|
303
|
+
const jobResult = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
304
|
+
expect(jobResult.ok).toBe(true);
|
|
305
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobResult.dir, branch: jobResult.branch });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
|
|
309
|
+
const projectConfig = require('../epicWorktreeProjectConfig.cjs');
|
|
310
|
+
const originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
311
|
+
process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = path.join(tmpRoot, 'epic-worktree-project-config.json');
|
|
312
|
+
try {
|
|
313
|
+
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
|
|
314
|
+
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
|
|
315
|
+
|
|
316
|
+
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
|
|
317
|
+
expect(result.ok).toBe(false);
|
|
318
|
+
expect(result.reason).toMatch(/isolation toggle/);
|
|
319
|
+
|
|
320
|
+
// A different, untouched project cwd is unaffected — the toggle is
|
|
321
|
+
// per-project, not machine-wide like the env var.
|
|
322
|
+
const otherCwd = path.join(tmpRoot, 'other-repo');
|
|
323
|
+
initRepo(otherCwd);
|
|
324
|
+
const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
|
|
325
|
+
expect(otherResult.ok).toBe(true);
|
|
326
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
|
|
327
|
+
|
|
328
|
+
// Turning it back off restores isolation for the original project.
|
|
329
|
+
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
|
|
330
|
+
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
|
|
331
|
+
const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
|
|
332
|
+
expect(restored.ok).toBe(true);
|
|
333
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
|
|
334
|
+
} finally {
|
|
335
|
+
if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
336
|
+
else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
|
|
341
|
+
const epicId = 'epic-leak';
|
|
342
|
+
const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
|
|
343
|
+
expect(fs.existsSync(worktree.dir)).toBe(true);
|
|
344
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
345
|
+
|
|
346
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
|
|
347
|
+
|
|
348
|
+
expect(fs.existsSync(worktree.dir)).toBe(false);
|
|
349
|
+
const branches = git(['branch', '--list', worktree.branch], repoCwd);
|
|
350
|
+
expect(branches.trim()).toBe('');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('[epic] reconcileWorktreesOnBoot does NOT remove a worktree whose owning Epic is still active, per the isLive predicate', async () => {
|
|
354
|
+
const liveEpicId = 'epic-still-active';
|
|
355
|
+
const deadEpicId = 'epic-orphaned';
|
|
356
|
+
const liveWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: liveEpicId });
|
|
357
|
+
const deadWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: deadEpicId });
|
|
358
|
+
gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
|
|
359
|
+
|
|
360
|
+
// Stub for a real active-index.json read (wired in the next PRD in this
|
|
361
|
+
// chain) — this only needs to prove reconcileWorktreesOnBoot accepts and
|
|
362
|
+
// respects a liveness predicate.
|
|
363
|
+
const activeIndexStub = new Set([liveEpicId]);
|
|
364
|
+
const isLive = async (epicId) => activeIndexStub.has(epicId);
|
|
365
|
+
|
|
366
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic', isLive });
|
|
367
|
+
|
|
368
|
+
expect(fs.existsSync(liveWorktree.dir)).toBe(true);
|
|
369
|
+
expect(fs.existsSync(deadWorktree.dir)).toBe(false);
|
|
370
|
+
const liveBranches = git(['branch', '--list', liveWorktree.branch], repoCwd);
|
|
371
|
+
expect(liveBranches).toContain(liveEpicId);
|
|
372
|
+
const deadBranches = git(['branch', '--list', deadWorktree.branch], repoCwd);
|
|
373
|
+
expect(deadBranches.trim()).toBe('');
|
|
374
|
+
|
|
375
|
+
// Cleanup: the live one is left registered by design; tear it down manually
|
|
376
|
+
// so afterEach's rm doesn't leave a dangling worktree registration.
|
|
377
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: liveWorktree.dir, branch: liveWorktree.branch });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('[epic] reconcileWorktreesOnBoot for kind:epic never touches a job-kind worktree, and vice versa', async () => {
|
|
381
|
+
const jobWorktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cross-kind-job' });
|
|
382
|
+
const epicWorktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'cross-kind-epic' });
|
|
383
|
+
|
|
384
|
+
await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' });
|
|
385
|
+
expect(fs.existsSync(jobWorktree.dir)).toBe(true);
|
|
386
|
+
expect(fs.existsSync(epicWorktree.dir)).toBe(false);
|
|
387
|
+
|
|
388
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: jobWorktree.dir, branch: jobWorktree.branch });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('keyFromBranch strips the kind-specific prefix and returns null for a foreign branch', () => {
|
|
392
|
+
expect(gitWorktree.keyFromBranch('epic', 'sm-epic/abc-123')).toBe('abc-123');
|
|
393
|
+
expect(gitWorktree.keyFromBranch('job', 'sm-job/my-slug')).toBe('my-slug');
|
|
394
|
+
expect(gitWorktree.keyFromBranch('epic', 'sm-job/my-slug')).toBe(null);
|
|
395
|
+
expect(gitWorktree.keyFromBranch('job', 'main')).toBe(null);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('createWorktree throws on an unknown kind', async () => {
|
|
399
|
+
await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
|
|
400
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsRootAbsoluteCwd.test.cjs — a project cwd must be an ABSOLUTE path, at
|
|
3
|
+
* both ends of the ops-root resolution chain.
|
|
4
|
+
*
|
|
5
|
+
* The incident this guards (observed live 2026-08-13): five stray
|
|
6
|
+
* `session-manager-operations/scheduler/state/queue.json` trees appeared under
|
|
7
|
+
* source directories — `src/main/lib/`, `src/main/`, `src/renderer/state/`,
|
|
8
|
+
* `src/renderer/components/epics/`, `src/renderer/testUtils/` — each holding
|
|
9
|
+
* an empty `{"jobs": []}`. Cause: `path.join()` silently resolves a RELATIVE
|
|
10
|
+
* first segment against `process.cwd()`, so a caller handing
|
|
11
|
+
* `projectStateDir()` a repo-relative fragment like `src/main/lib` gets a
|
|
12
|
+
* plausible-looking absolute path pointing at entirely the wrong place, and a
|
|
13
|
+
* whole ops root is materialized there. Nothing threw; the writes "succeeded".
|
|
14
|
+
*
|
|
15
|
+
* This is the ops-root hazard gitWorktree.cjs's header comment describes,
|
|
16
|
+
* reached from the opposite end: not a worktree dir substituted for a project
|
|
17
|
+
* cwd, but a relative fragment accepted as one. A PROJECT IS A CWD and a cwd
|
|
18
|
+
* is absolute (CLAUDE.md's knownProjectAggregate.ts bullet says the same
|
|
19
|
+
* thing renderer-side) — so both layers fail closed rather than guessing.
|
|
20
|
+
*
|
|
21
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
import { test, expect, afterEach } from 'vitest';
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const fsp = require('node:fs/promises');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const queueStore = require('../queueStore.cjs');
|
|
32
|
+
const { activeProjectCwds } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
33
|
+
|
|
34
|
+
const tmpDirs = [];
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
while (tmpDirs.length) {
|
|
38
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── queueStore.projectStateDir — the write-path guard ──────────────────────
|
|
43
|
+
|
|
44
|
+
test('projectStateDir refuses a relative cwd instead of resolving it against process.cwd()', () => {
|
|
45
|
+
expect(() => queueStore.projectStateDir('src/main/lib')).toThrow(/absolute path/);
|
|
46
|
+
expect(() => queueStore.projectStateDir('./somewhere')).toThrow(/absolute path/);
|
|
47
|
+
expect(() => queueStore.projectStateDir('..')).toThrow(/absolute path/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('projectStateDir names the offending value so a bad caller is findable from the log alone', () => {
|
|
51
|
+
expect(() => queueStore.projectStateDir('src/renderer/state')).toThrow(/src\/renderer\/state/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('projectStateDir still accepts an absolute cwd and appends the ops-root subpath', () => {
|
|
55
|
+
const dir = queueStore.projectStateDir('/projects/foo');
|
|
56
|
+
expect(dir).toBe(path.join('/projects/foo', 'session-manager-operations', 'scheduler', 'state'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('projectStateDir keeps rejecting a missing/non-string cwd', () => {
|
|
60
|
+
expect(() => queueStore.projectStateDir()).toThrow(/cwd is required/);
|
|
61
|
+
expect(() => queueStore.projectStateDir('')).toThrow(/cwd is required/);
|
|
62
|
+
expect(() => queueStore.projectStateDir(42)).toThrow(/cwd is required/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ─── activeSessions.addCwd — the upstream guard ─────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// Building a transcript fixture whose recorded `cwd` is relative is the whole
|
|
68
|
+
// point: statSync('src/main/lib') SUCCEEDS whenever this process happens to
|
|
69
|
+
// run from the repo root, which is exactly how such a value used to survive
|
|
70
|
+
// into the project list and reach projectStateDir.
|
|
71
|
+
|
|
72
|
+
async function mkProjectsDir(rows) {
|
|
73
|
+
const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-projects-'));
|
|
74
|
+
tmpDirs.push(projectsDir);
|
|
75
|
+
for (const [slug, cwd] of Object.entries(rows)) {
|
|
76
|
+
const dir = path.join(projectsDir, slug);
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
fs.writeFileSync(path.join(dir, 'session.jsonl'), `${JSON.stringify({ cwd })}\n`, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
return projectsDir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('a transcript recording a RELATIVE cwd never enters the project list', async () => {
|
|
84
|
+
const projectsDir = await mkProjectsDir({ relative: 'src/main/lib' });
|
|
85
|
+
// Sanity: the bad value really would have passed the old existence check.
|
|
86
|
+
expect(fs.existsSync('src/main/lib')).toBe(true);
|
|
87
|
+
|
|
88
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
89
|
+
expect(cwds).not.toContain('src/main/lib');
|
|
90
|
+
expect(cwds).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('an absolute cwd that exists is still collected', async () => {
|
|
94
|
+
const real = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-real-'));
|
|
95
|
+
tmpDirs.push(real);
|
|
96
|
+
const projectsDir = await mkProjectsDir({ good: real });
|
|
97
|
+
|
|
98
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
99
|
+
expect(cwds).toContain(real);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('an absolute cwd pointing at a FILE (not a directory) is rejected — a file is not a project', async () => {
|
|
103
|
+
const holder = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-file-'));
|
|
104
|
+
tmpDirs.push(holder);
|
|
105
|
+
const filePath = path.join(holder, 'not-a-dir.txt');
|
|
106
|
+
fs.writeFileSync(filePath, 'x', 'utf8');
|
|
107
|
+
const projectsDir = await mkProjectsDir({ afile: filePath });
|
|
108
|
+
|
|
109
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
110
|
+
expect(cwds).not.toContain(filePath);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an absolute cwd that no longer exists on disk is rejected', async () => {
|
|
114
|
+
const projectsDir = await mkProjectsDir({ gone: path.join(os.tmpdir(), 'sm-opsroot-definitely-not-here-12345') });
|
|
115
|
+
|
|
116
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
117
|
+
expect(cwds).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('the guards compose: no relative cwd from a transcript can reach an ops-root write', async () => {
|
|
121
|
+
const projectsDir = await mkProjectsDir({ relative: 'src/renderer/state', alsoRelative: './src/main' });
|
|
122
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
123
|
+
expect(cwds).toEqual([]);
|
|
124
|
+
// And even if one somehow did, the write path itself refuses it.
|
|
125
|
+
expect(() => queueStore.projectStateDir('src/renderer/state')).toThrow(/absolute path/);
|
|
126
|
+
});
|
|
@@ -116,3 +116,85 @@ test('stats at most the newest few run dirs (bound enforced)', () => {
|
|
|
116
116
|
// too, so at most MAX_DIRS_SCANNED * 2 readFileSync calls.
|
|
117
117
|
expect(readFileCalls).toBeLessThanOrEqual(MAX_DIRS_SCANNED * 2);
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
// ─── sidecar-resilience (2026-08-13) ────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// Every branch used to `return` on the FIRST candidate dir, so an unreadable
|
|
123
|
+
// or half-written sidecar in the newest run erased an older, complete terminal
|
|
124
|
+
// record — the anti-resurrection guard switching itself off on exactly the
|
|
125
|
+
// input it exists to survive, which lets reconcile re-derive a fresh queue row
|
|
126
|
+
// and re-run an already-completed PRD. It also made MAX_DIRS_SCANNED a no-op.
|
|
127
|
+
|
|
128
|
+
function memFs(files) {
|
|
129
|
+
return {
|
|
130
|
+
readdirSync: (d) => Object.keys(files[d] ?? {}),
|
|
131
|
+
existsSync: (p) => {
|
|
132
|
+
const dir = path.dirname(p);
|
|
133
|
+
const base = path.basename(p);
|
|
134
|
+
return Boolean(files[path.dirname(dir)]?.[path.basename(dir)]?.[base]);
|
|
135
|
+
},
|
|
136
|
+
readFileSync: (p) => {
|
|
137
|
+
const dir = path.dirname(p);
|
|
138
|
+
const base = path.basename(p);
|
|
139
|
+
const content = files[path.dirname(dir)]?.[path.basename(dir)]?.[base];
|
|
140
|
+
if (content === undefined) throw new Error(`ENOENT: ${p}`);
|
|
141
|
+
return content;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const RUNS = '/runs';
|
|
147
|
+
const SLUG = '900-thing';
|
|
148
|
+
|
|
149
|
+
function tree(dirs) {
|
|
150
|
+
return { [RUNS]: dirs };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test('an unreadable meta sidecar in the newest dir falls through to an older complete run', () => {
|
|
154
|
+
const fsImpl = memFs(tree({
|
|
155
|
+
'2026-08-14T02-00-00-000Z': { [`${SLUG}.meta.json`]: '{ not json' },
|
|
156
|
+
'2026-08-14T01-00-00-000Z': {
|
|
157
|
+
[`${SLUG}.meta.json`]: JSON.stringify({ exitCode: 0, finishedAt: 1786669013583 }),
|
|
158
|
+
[`${SLUG}.verdicts.json`]: JSON.stringify({ verdict: 'clean' }),
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
const out = latestTerminalOutcomeForSlug(SLUG, { runsDir: RUNS, fsImpl });
|
|
162
|
+
expect(out).toBeTruthy();
|
|
163
|
+
expect(out.status).toBe('completed');
|
|
164
|
+
expect(out.runId).toBe('2026-08-14T01-00-00-000Z');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a newest run missing its verdicts sidecar falls through instead of erasing the older record', () => {
|
|
168
|
+
const fsImpl = memFs(tree({
|
|
169
|
+
'2026-08-14T02-00-00-000Z': { [`${SLUG}.meta.json`]: JSON.stringify({ exitCode: 0, finishedAt: 1 }) },
|
|
170
|
+
'2026-08-14T01-00-00-000Z': {
|
|
171
|
+
[`${SLUG}.meta.json`]: JSON.stringify({ exitCode: 0, finishedAt: 1786669013583 }),
|
|
172
|
+
[`${SLUG}.verdicts.json`]: JSON.stringify({ verdict: 'pass_no_commit_prior_run_verified' }),
|
|
173
|
+
},
|
|
174
|
+
}));
|
|
175
|
+
const out = latestTerminalOutcomeForSlug(SLUG, { runsDir: RUNS, fsImpl });
|
|
176
|
+
expect(out).toBeTruthy();
|
|
177
|
+
expect(out.status).toBe('completed');
|
|
178
|
+
expect(out.runId).toBe('2026-08-14T01-00-00-000Z');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a non-zero exit in the newest dir is still authoritative — fall-through only covers UNREADABLE sidecars', () => {
|
|
182
|
+
const fsImpl = memFs(tree({
|
|
183
|
+
'2026-08-14T02-00-00-000Z': { [`${SLUG}.meta.json`]: JSON.stringify({ exitCode: 1, finishedAt: 2 }) },
|
|
184
|
+
'2026-08-14T01-00-00-000Z': {
|
|
185
|
+
[`${SLUG}.meta.json`]: JSON.stringify({ exitCode: 0, finishedAt: 1 }),
|
|
186
|
+
[`${SLUG}.verdicts.json`]: JSON.stringify({ verdict: 'clean' }),
|
|
187
|
+
},
|
|
188
|
+
}));
|
|
189
|
+
const out = latestTerminalOutcomeForSlug(SLUG, { runsDir: RUNS, fsImpl });
|
|
190
|
+
expect(out.status).toBe('failed');
|
|
191
|
+
expect(out.runId).toBe('2026-08-14T02-00-00-000Z');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('every candidate being unreadable still yields null (no invented outcome)', () => {
|
|
195
|
+
const fsImpl = memFs(tree({
|
|
196
|
+
'2026-08-14T02-00-00-000Z': { [`${SLUG}.meta.json`]: '{ nope' },
|
|
197
|
+
'2026-08-14T01-00-00-000Z': { [`${SLUG}.meta.json`]: '{ also nope' },
|
|
198
|
+
}));
|
|
199
|
+
expect(latestTerminalOutcomeForSlug(SLUG, { runsDir: RUNS, fsImpl })).toBeNull();
|
|
200
|
+
});
|