claude-code-session-manager 0.75.3 → 0.77.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-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -175,3 +175,154 @@ test('projectStateDir fails closed on an ops-internal cwd, naming the offending
|
|
|
175
175
|
// The writeSplit half of this guard lives in opsRootNestedWrite.test.cjs —
|
|
176
176
|
// it must override HOME before requiring queueStore (writeSplit persists
|
|
177
177
|
// machine state to a homedir path), which cannot be done from this file.
|
|
178
|
+
|
|
179
|
+
// ─── the third incident: a cwd INSIDE a linked git worktree ─────────────────
|
|
180
|
+
//
|
|
181
|
+
// Verified live 2026-09-01 in starry-night-ships: a transcript's last `cwd`
|
|
182
|
+
// row pointed at `/tmp/session-manager-epic-worktrees/<hash>/<epicId>` — an
|
|
183
|
+
// absolute, existing directory, so it was accepted as a project and the real
|
|
184
|
+
// project root (`/home/bilko/Projects/starry-night-ships`) never entered the
|
|
185
|
+
// list at all. `worktreeMainRootOf` reverses a linked worktree's `.git` FILE
|
|
186
|
+
// (`gitdir: <main>/.git/worktrees/<name>`) back to `<main>`.
|
|
187
|
+
|
|
188
|
+
const { worktreeMainRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
189
|
+
const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('../gitWorktree.cjs');
|
|
190
|
+
|
|
191
|
+
// A worktree's MAIN tree root must live outside os.tmpdir() for these fixtures
|
|
192
|
+
// to double as a realistic "real project" for the tmp-drop-guard tests below
|
|
193
|
+
// (a real project is never itself under /tmp — only the linked worktree is).
|
|
194
|
+
// `test-results/` is already gitignored in this repo.
|
|
195
|
+
const NON_TMP_SCRATCH_ROOT = path.join(process.cwd(), 'test-results', 'opsroot-fixtures');
|
|
196
|
+
|
|
197
|
+
async function mkNonTmpDir(prefix) {
|
|
198
|
+
fs.mkdirSync(NON_TMP_SCRATCH_ROOT, { recursive: true });
|
|
199
|
+
return fsp.mkdtemp(path.join(NON_TMP_SCRATCH_ROOT, prefix));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function makeWorktreeFixture() {
|
|
203
|
+
const main = await mkNonTmpDir('sm-opsroot-main-');
|
|
204
|
+
tmpDirs.push(main);
|
|
205
|
+
fs.mkdirSync(path.join(main, '.git'), { recursive: true });
|
|
206
|
+
const worktree = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-worktree-'));
|
|
207
|
+
tmpDirs.push(worktree);
|
|
208
|
+
const worktreeName = 'sm-epic-abc123';
|
|
209
|
+
const worktreeGitFile = path.join(worktree, '.git');
|
|
210
|
+
const adminDir = path.join(main, '.git', 'worktrees', worktreeName);
|
|
211
|
+
fs.mkdirSync(adminDir, { recursive: true });
|
|
212
|
+
// Real `git worktree add` writes this back-reference (the admin dir's own
|
|
213
|
+
// `gitdir` file points at the linked worktree's `.git` FILE) — required by
|
|
214
|
+
// worktreeMainRootOf's round-trip check so a crafted `.git` file elsewhere
|
|
215
|
+
// on disk can't redirect resolution to an arbitrary directory.
|
|
216
|
+
fs.writeFileSync(path.join(adminDir, 'gitdir'), `${worktreeGitFile}\n`, 'utf8');
|
|
217
|
+
fs.writeFileSync(worktreeGitFile, `gitdir: ${adminDir}\n`, 'utf8');
|
|
218
|
+
return { main, worktree };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
test('worktreeMainRootOf resolves a linked worktree .git FILE back to the main tree root', async () => {
|
|
222
|
+
const { main, worktree } = await makeWorktreeFixture();
|
|
223
|
+
expect(worktreeMainRootOf(worktree)).toBe(main);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('worktreeMainRootOf returns the ancestor unchanged when .git is a real directory', async () => {
|
|
227
|
+
const main = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-plain-main-'));
|
|
228
|
+
tmpDirs.push(main);
|
|
229
|
+
fs.mkdirSync(path.join(main, '.git'), { recursive: true });
|
|
230
|
+
const nested = path.join(main, 'src', 'main', 'lib');
|
|
231
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
232
|
+
expect(worktreeMainRootOf(nested)).toBe(main);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('worktreeMainRootOf returns null on a garbled .git file instead of throwing', async () => {
|
|
236
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-garbled-'));
|
|
237
|
+
tmpDirs.push(dir);
|
|
238
|
+
fs.writeFileSync(path.join(dir, '.git'), 'not a gitdir line at all', 'utf8');
|
|
239
|
+
expect(worktreeMainRootOf(dir)).toBeNull();
|
|
240
|
+
|
|
241
|
+
const dir2 = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-garbled2-'));
|
|
242
|
+
tmpDirs.push(dir2);
|
|
243
|
+
fs.writeFileSync(path.join(dir2, '.git'), 'gitdir: /some/path/without/the/marker\n', 'utf8');
|
|
244
|
+
expect(worktreeMainRootOf(dir2)).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('worktreeMainRootOf refuses a crafted .git file that redirects to an arbitrary directory with no back-reference', async () => {
|
|
248
|
+
// A `.git` FILE is an ordinary file — nothing stops an untrusted repo from
|
|
249
|
+
// shipping one at a nested path pointing anywhere. Without verifying the
|
|
250
|
+
// admin dir's own `gitdir` back-reference, this would let a crafted repo
|
|
251
|
+
// redirect activeSessions' project-root resolution (and therefore
|
|
252
|
+
// queueStore's ops-root writes) to an attacker-chosen absolute directory.
|
|
253
|
+
const arbitraryTarget = await mkNonTmpDir('sm-opsroot-arbitrary-target-');
|
|
254
|
+
tmpDirs.push(arbitraryTarget);
|
|
255
|
+
const attackerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-attacker-'));
|
|
256
|
+
tmpDirs.push(attackerDir);
|
|
257
|
+
// No admin dir/back-reference file exists under arbitraryTarget/.git/worktrees/.
|
|
258
|
+
fs.writeFileSync(
|
|
259
|
+
path.join(attackerDir, '.git'),
|
|
260
|
+
`gitdir: ${path.join(arbitraryTarget, '.git', 'worktrees', 'x')}\n`,
|
|
261
|
+
'utf8',
|
|
262
|
+
);
|
|
263
|
+
expect(worktreeMainRootOf(attackerDir)).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('worktreeMainRootOf refuses a crafted .git file whose admin dir exists but back-references a DIFFERENT worktree', async () => {
|
|
267
|
+
const { main } = await makeWorktreeFixture();
|
|
268
|
+
const attackerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-attacker2-'));
|
|
269
|
+
tmpDirs.push(attackerDir);
|
|
270
|
+
// Points at a REAL admin dir (main's own), but that admin dir's gitdir
|
|
271
|
+
// back-reference points at the ORIGINAL worktree, not attackerDir.
|
|
272
|
+
fs.writeFileSync(
|
|
273
|
+
path.join(attackerDir, '.git'),
|
|
274
|
+
`gitdir: ${path.join(main, '.git', 'worktrees', 'sm-epic-abc123')}\n`,
|
|
275
|
+
'utf8',
|
|
276
|
+
);
|
|
277
|
+
expect(worktreeMainRootOf(attackerDir)).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('worktreeMainRootOf returns null for a plain non-git directory', async () => {
|
|
281
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-nogit-'));
|
|
282
|
+
tmpDirs.push(dir);
|
|
283
|
+
expect(worktreeMainRootOf(dir)).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('projectRootOf resolves a worktree cwd to the main tree root', async () => {
|
|
287
|
+
const { main, worktree } = await makeWorktreeFixture();
|
|
288
|
+
const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
289
|
+
expect(projectRootOf(worktree)).toBe(main);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('projectRootOf resolves a cwd nested inside a worktree\'s OWN ops tree to the main root', async () => {
|
|
293
|
+
const { main, worktree } = await makeWorktreeFixture();
|
|
294
|
+
const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
295
|
+
const nestedOps = path.join(worktree, 'session-manager-operations', 'scheduler', 'state');
|
|
296
|
+
fs.mkdirSync(nestedOps, { recursive: true });
|
|
297
|
+
expect(projectRootOf(nestedOps)).toBe(main);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('activeProjectCwds resolves a worktree cwd to the real project and drops unresolvable tmp cwds', async () => {
|
|
301
|
+
const { main, worktree } = await makeWorktreeFixture();
|
|
302
|
+
// A bare, .git-less directory directly under the managed epic-worktree
|
|
303
|
+
// root — the shape a torn-down or mid-teardown worktree leaves behind.
|
|
304
|
+
// worktreeMainRootOf can't resolve it (no .git), so only the drop-guard's
|
|
305
|
+
// prefixDropRoots check keeps it out of the project list.
|
|
306
|
+
fs.mkdirSync(WORKTREE_KIND_CONFIG.epic.root, { recursive: true });
|
|
307
|
+
const bareWorktreeRoot = await fsp.mkdtemp(path.join(WORKTREE_KIND_CONFIG.epic.root, 'sm-opsroot-bare-'));
|
|
308
|
+
tmpDirs.push(bareWorktreeRoot);
|
|
309
|
+
const projectsDir = await mkProjectsDir({
|
|
310
|
+
resolvable: worktree,
|
|
311
|
+
unresolvable: bareWorktreeRoot,
|
|
312
|
+
bareTmpdir: os.tmpdir(),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
316
|
+
expect(cwds).toContain(main);
|
|
317
|
+
expect(cwds).not.toContain(worktree);
|
|
318
|
+
expect(cwds).not.toContain(bareWorktreeRoot);
|
|
319
|
+
expect(cwds).not.toContain(os.tmpdir());
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('a main-tree root resolved from a worktree dedupes against a directly-observed cwd for the same project', async () => {
|
|
323
|
+
const { main, worktree } = await makeWorktreeFixture();
|
|
324
|
+
const projectsDir = await mkProjectsDir({ direct: main, viaWorktree: worktree });
|
|
325
|
+
|
|
326
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
327
|
+
expect(cwds.filter((c) => c === main)).toHaveLength(1);
|
|
328
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/opsRootResolve.test.cjs
|
|
3
|
+
//
|
|
4
|
+
// PRD 1082 — opsOwnership.cjs is THE ONE ops-root resolver. Every reader and
|
|
5
|
+
// writer of `<project>/session-manager-operations/` builds its path through
|
|
6
|
+
// resolveProjectRoot / resolveOpsRoot / opsPath, which normalize a worktree or
|
|
7
|
+
// ops-internal cwd to the real project and refuse an ephemeral one. Before
|
|
8
|
+
// this, each namespace did its own `path.join(cwd, 'session-manager-
|
|
9
|
+
// operations', ...)`, so the worktree-cwd hazard had to be fixed one call site
|
|
10
|
+
// at a time (2026-08-30, PRDs 1073/1074/1081).
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const {
|
|
17
|
+
resolveProjectRoot, resolveOpsRoot, opsPath, assertOpsWrite, OPS_ROOT_DIR,
|
|
18
|
+
} = require('../opsOwnership.cjs');
|
|
19
|
+
const { KIND_CONFIG } = require('../gitWorktree.cjs');
|
|
20
|
+
|
|
21
|
+
const tmpDirs = [];
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
while (tmpDirs.length) {
|
|
24
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A fake main tree (has a real `.git` DIRECTORY) outside os.tmpdir(). */
|
|
29
|
+
async function mkMainTree() {
|
|
30
|
+
const base = path.join(os.homedir(), '.cache', 'sm-opsroot-resolve-tests');
|
|
31
|
+
fs.mkdirSync(base, { recursive: true });
|
|
32
|
+
const main = await fsp.mkdtemp(path.join(base, 'main-'));
|
|
33
|
+
tmpDirs.push(main);
|
|
34
|
+
fs.mkdirSync(path.join(main, '.git'), { recursive: true });
|
|
35
|
+
return main;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A linked worktree of `main` (a `.git` FILE + the admin back-reference git writes). */
|
|
39
|
+
async function mkLinkedWorktree(main) {
|
|
40
|
+
const worktree = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-resolve-wt-'));
|
|
41
|
+
tmpDirs.push(worktree);
|
|
42
|
+
const name = 'sm-epic-test';
|
|
43
|
+
const adminDir = path.join(main, '.git', 'worktrees', name);
|
|
44
|
+
fs.mkdirSync(adminDir, { recursive: true });
|
|
45
|
+
const gitFile = path.join(worktree, '.git');
|
|
46
|
+
fs.writeFileSync(path.join(adminDir, 'gitdir'), `${gitFile}\n`);
|
|
47
|
+
fs.writeFileSync(gitFile, `gitdir: ${adminDir}\n`);
|
|
48
|
+
return worktree;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── byte-equality regression for the common case ──────────────────────────
|
|
52
|
+
|
|
53
|
+
test('a plain project root resolves to <root>/session-manager-operations, byte-identical to the old join', async () => {
|
|
54
|
+
const main = await mkMainTree();
|
|
55
|
+
assert.equal(resolveProjectRoot(main), main);
|
|
56
|
+
assert.equal(resolveOpsRoot(main), path.join(main, OPS_ROOT_DIR));
|
|
57
|
+
assert.equal(
|
|
58
|
+
opsPath(main, 'prompt-sessions', 'active-index.json'),
|
|
59
|
+
path.join(main, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a plain directory with no enclosing .git anywhere is returned unchanged', () => {
|
|
64
|
+
const p = '/definitely/not/a/real/project';
|
|
65
|
+
assert.equal(resolveProjectRoot(p), p);
|
|
66
|
+
assert.equal(opsPath(p, 'logs'), path.join(p, OPS_ROOT_DIR, 'logs'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── normalization ──────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
test('a linked worktree root resolves to its MAIN tree', async () => {
|
|
72
|
+
const main = await mkMainTree();
|
|
73
|
+
const wt = await mkLinkedWorktree(main);
|
|
74
|
+
assert.equal(resolveProjectRoot(wt), main);
|
|
75
|
+
assert.equal(opsPath(wt, 'scheduler', 'state'), path.join(main, OPS_ROOT_DIR, 'scheduler', 'state'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a cwd deep inside a worktree's OWN ops tree resolves to the main tree's ops root", async () => {
|
|
79
|
+
const main = await mkMainTree();
|
|
80
|
+
const wt = await mkLinkedWorktree(main);
|
|
81
|
+
const nested = path.join(wt, OPS_ROOT_DIR, 'scheduler', 'state');
|
|
82
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
83
|
+
assert.equal(resolveOpsRoot(nested), path.join(main, OPS_ROOT_DIR));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('an ops-internal cwd of a plain project is truncated to the project (default normalize)', async () => {
|
|
87
|
+
const main = await mkMainTree();
|
|
88
|
+
const inside = path.join(main, OPS_ROOT_DIR, 'scheduler', 'epics', 'e1', 'prds');
|
|
89
|
+
assert.equal(resolveProjectRoot(inside), main);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a subdirectory of a git repo resolves to the repo root (the 2026-08-13 'src/main' stray-root shape)", async () => {
|
|
93
|
+
const main = await mkMainTree();
|
|
94
|
+
const sub = path.join(main, 'src', 'main', 'lib');
|
|
95
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
96
|
+
assert.equal(resolveProjectRoot(sub), main);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a nested project that is its OWN git repo keeps its own root', async () => {
|
|
100
|
+
const outer = await mkMainTree();
|
|
101
|
+
const inner = path.join(outer, 'apps', 'inner');
|
|
102
|
+
fs.mkdirSync(path.join(inner, '.git'), { recursive: true });
|
|
103
|
+
assert.equal(resolveProjectRoot(inner), inner);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── refusals ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test("opsInternal:'refuse' throws on an ops-internal cwd instead of normalizing (the writer posture)", async () => {
|
|
109
|
+
const main = await mkMainTree();
|
|
110
|
+
const inside = path.join(main, OPS_ROOT_DIR, 'scheduler', 'state');
|
|
111
|
+
assert.throws(() => resolveProjectRoot(inside, { opsInternal: 'refuse' }), /not a path inside session-manager-operations/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('os.tmpdir() itself is refused with a tagged ephemeral error', () => {
|
|
115
|
+
assert.throws(() => resolveOpsRoot(os.tmpdir()), (e) => e.ephemeral === true && /ephemeral/.test(e.message));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a bare, .git-less dir under a managed worktree root is refused (cannot be normalized)', async () => {
|
|
119
|
+
fs.mkdirSync(KIND_CONFIG.epic.root, { recursive: true });
|
|
120
|
+
const bare = await fsp.mkdtemp(path.join(KIND_CONFIG.epic.root, 'sm-opsroot-resolve-bare-'));
|
|
121
|
+
tmpDirs.push(bare);
|
|
122
|
+
assert.throws(() => opsPath(bare, 'logs'), (e) => e.ephemeral === true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('relative, empty and non-string cwds throw (never resolved against process.cwd())', () => {
|
|
126
|
+
assert.throws(() => resolveProjectRoot('src/main/lib'), /absolute/);
|
|
127
|
+
assert.throws(() => resolveProjectRoot(''), /required/);
|
|
128
|
+
assert.throws(() => resolveProjectRoot(null), /required/);
|
|
129
|
+
assert.throws(() => resolveProjectRoot(42), /required/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── the single-writer gate is also the last line ──────────────────────────
|
|
133
|
+
|
|
134
|
+
test('assertOpsWrite refuses a well-owned write whose project root is a linked worktree', async () => {
|
|
135
|
+
const main = await mkMainTree();
|
|
136
|
+
const wt = await mkLinkedWorktree(main);
|
|
137
|
+
const target = path.join(wt, OPS_ROOT_DIR, 'scheduler', 'state', 'queue.json');
|
|
138
|
+
assert.throws(() => assertOpsWrite(target, 'scheduler'), (e) => e.ephemeral === true);
|
|
139
|
+
// ...while the same write against the main tree is fine.
|
|
140
|
+
assert.doesNotThrow(() => assertOpsWrite(path.join(main, OPS_ROOT_DIR, 'scheduler', 'state', 'queue.json'), 'scheduler'));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('assertOpsWrite still refuses the wrong writer before it ever looks at the root', async () => {
|
|
144
|
+
const main = await mkMainTree();
|
|
145
|
+
assert.throws(
|
|
146
|
+
() => assertOpsWrite(path.join(main, OPS_ROOT_DIR, 'scheduler', 'state', 'queue.json'), 'epics'),
|
|
147
|
+
/single-writer law/,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdDeclaredPaths.test.cjs — extracting a PRD's declared file paths for the
|
|
3
|
+
* reverify self-heal pass's widened evidence window (PRD 1102).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/prdDeclaredPaths.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test } from 'vitest';
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { extractDeclaredPaths, declaredPathsForPrd } = require('../prdDeclaredPaths.cjs');
|
|
16
|
+
|
|
17
|
+
test('extracts backtick-quoted paths from Implementation notes', () => {
|
|
18
|
+
const body = [
|
|
19
|
+
'# Implementation notes',
|
|
20
|
+
'',
|
|
21
|
+
'Edit `src/main/scheduler.cjs` and `src/main/lib/gitWorktree.cjs`.',
|
|
22
|
+
'',
|
|
23
|
+
'# Out of scope',
|
|
24
|
+
'Do not touch `src/main/should-not-appear.cjs` here.',
|
|
25
|
+
].join('\n');
|
|
26
|
+
const paths = extractDeclaredPaths(body);
|
|
27
|
+
assert.deepStrictEqual(paths, ['src/main/scheduler.cjs', 'src/main/lib/gitWorktree.cjs']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('extracts paths from Acceptance criteria and strips trailing :line', () => {
|
|
31
|
+
const body = [
|
|
32
|
+
'# Acceptance criteria',
|
|
33
|
+
'- [ ] Fix the bug in `src/main/scheduler.cjs:490`',
|
|
34
|
+
].join('\n');
|
|
35
|
+
assert.deepStrictEqual(extractDeclaredPaths(body), ['src/main/scheduler.cjs']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('dedupes paths mentioned in both sections', () => {
|
|
39
|
+
const body = [
|
|
40
|
+
'# Acceptance criteria',
|
|
41
|
+
'- [ ] Fix `src/main/scheduler.cjs`',
|
|
42
|
+
'# Implementation notes',
|
|
43
|
+
'See `src/main/scheduler.cjs` again.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
assert.deepStrictEqual(extractDeclaredPaths(body), ['src/main/scheduler.cjs']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('never fabricates a path — no match anywhere returns []', () => {
|
|
49
|
+
const body = [
|
|
50
|
+
'# Implementation notes',
|
|
51
|
+
'Just prose, no backticked paths here.',
|
|
52
|
+
'# Acceptance criteria',
|
|
53
|
+
'- [ ] npm run typecheck passes',
|
|
54
|
+
].join('\n');
|
|
55
|
+
assert.deepStrictEqual(extractDeclaredPaths(body), []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('empty/non-string body returns []', () => {
|
|
59
|
+
assert.deepStrictEqual(extractDeclaredPaths(''), []);
|
|
60
|
+
assert.deepStrictEqual(extractDeclaredPaths(null), []);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('declaredPathsForPrd reads a real file and strips frontmatter', () => {
|
|
64
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prd-declared-paths-'));
|
|
65
|
+
const prdPath = path.join(dir, '01-test.md');
|
|
66
|
+
fs.writeFileSync(prdPath, [
|
|
67
|
+
'---',
|
|
68
|
+
'title: Test',
|
|
69
|
+
'---',
|
|
70
|
+
'# Implementation notes',
|
|
71
|
+
'Touch `src/main/scheduler.cjs`.',
|
|
72
|
+
].join('\n'));
|
|
73
|
+
assert.deepStrictEqual(declaredPathsForPrd(prdPath), ['src/main/scheduler.cjs']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('declaredPathsForPrd on a missing file returns [] without throwing', () => {
|
|
77
|
+
assert.deepStrictEqual(declaredPathsForPrd('/nonexistent/path/does-not-exist.md'), []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('declaredPathsForPrd with null path returns []', () => {
|
|
81
|
+
assert.deepStrictEqual(declaredPathsForPrd(null), []);
|
|
82
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectRootResolve.test.cjs — resolveProjectContext (PRD: worktree-cwd
|
|
3
|
+
* Epic-lookup hazard). Exercises the resolver purely in-process with injected
|
|
4
|
+
* deps (no Electron, no real git worktrees) — see prdCreate.test.cjs for the
|
|
5
|
+
* end-to-end fixture reproducing the live starry-night-ships shape.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/projectRootResolve.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, vi } from 'vitest';
|
|
13
|
+
const { resolveProjectContext } = require('../projectRootResolve.cjs');
|
|
14
|
+
|
|
15
|
+
function makeDeps({ projectRootOfImpl, allCwds = [], indexes = {}, ephemeral = [] } = {}) {
|
|
16
|
+
return {
|
|
17
|
+
projectRootOf: projectRootOfImpl || ((p) => p),
|
|
18
|
+
allProjectCwds: () => allCwds,
|
|
19
|
+
readActiveIndex: (cwd) => ({ sessions: indexes[cwd] || {}, events: {} }),
|
|
20
|
+
isEphemeralCwd: (cwd) => ephemeral.includes(cwd),
|
|
21
|
+
warn: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('a plain project root (no worktree, no ops-internal segment) is returned unchanged', () => {
|
|
26
|
+
const deps = makeDeps();
|
|
27
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/some-app' }, deps);
|
|
28
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/some-app', epicId: null, epicCwd: null, source: 'cwd' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a worktree cwd normalizes to the main tree', () => {
|
|
32
|
+
const deps = makeDeps({
|
|
33
|
+
projectRootOfImpl: (p) => (p.startsWith('/tmp/worktree') ? '/home/bilko/Projects/starry-night-ships' : p),
|
|
34
|
+
});
|
|
35
|
+
const result = resolveProjectContext({ cwd: '/tmp/worktree/abc/epic-1' }, deps);
|
|
36
|
+
expect(result.cwd).toBe('/home/bilko/Projects/starry-night-ships');
|
|
37
|
+
expect(result.source).toBe('cwd');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an ops-internal cwd normalizes to the project root', () => {
|
|
41
|
+
const deps = makeDeps({
|
|
42
|
+
projectRootOfImpl: (p) => (p.includes('session-manager-operations') ? '/home/bilko/Projects/foo' : p),
|
|
43
|
+
});
|
|
44
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo/session-manager-operations/scheduler/state' }, deps);
|
|
45
|
+
expect(result.cwd).toBe('/home/bilko/Projects/foo');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('Epic found in the normalized cwd\'s own active-index — source epic-index', () => {
|
|
49
|
+
const deps = makeDeps({
|
|
50
|
+
indexes: {
|
|
51
|
+
'/home/bilko/Projects/foo': { 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', status: 'active' } },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-1' }, deps);
|
|
55
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/foo', epicId: 'epic-1', epicCwd: '/home/bilko/Projects/foo', source: 'epic-index' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('Epic found only via cross-project scan — epicCwd wins over the supplied cwd', () => {
|
|
59
|
+
const deps = makeDeps({
|
|
60
|
+
allCwds: ['/home/bilko/Projects/foo', '/home/bilko/Projects/bar'],
|
|
61
|
+
indexes: {
|
|
62
|
+
'/home/bilko/Projects/bar': { 'epic-2': { id: 'epic-2', claudeSessionId: 'sess-2', status: 'active' } },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-2' }, deps);
|
|
66
|
+
expect(result.epicId).toBe('epic-2');
|
|
67
|
+
expect(result.epicCwd).toBe('/home/bilko/Projects/bar');
|
|
68
|
+
expect(result.cwd).toBe('/home/bilko/Projects/bar');
|
|
69
|
+
expect(result.source).toBe('cross-project-scan');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no match anywhere → epicId null, cwd unchanged', () => {
|
|
73
|
+
const deps = makeDeps({ allCwds: ['/home/bilko/Projects/foo'] });
|
|
74
|
+
const result = resolveProjectContext({ cwd: '/home/bilko/Projects/foo', originClaudeSessionId: 'sess-unknown' }, deps);
|
|
75
|
+
expect(result).toEqual({ cwd: '/home/bilko/Projects/foo', epicId: null, epicCwd: null, source: 'cwd' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('explicit epicId (sourcePromptId) resolves cwd when no cwd was supplied at all', () => {
|
|
79
|
+
const deps = makeDeps({
|
|
80
|
+
allCwds: ['/home/bilko/Projects/foo'],
|
|
81
|
+
indexes: {
|
|
82
|
+
'/home/bilko/Projects/foo': { 'epic-3': { id: 'epic-3', claudeSessionId: 'sess-3', status: 'proposed' } },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const result = resolveProjectContext({ epicId: 'epic-3' }, deps);
|
|
86
|
+
expect(result.cwd).toBe('/home/bilko/Projects/foo');
|
|
87
|
+
expect(result.epicId).toBe('epic-3');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('duplicate claudeSessionId across two projects: prefers the non-ephemeral, status=active row and warns naming both', () => {
|
|
91
|
+
const deps = makeDeps({
|
|
92
|
+
allCwds: ['/tmp/session-manager-epic-worktrees/abc/epic-1', '/home/bilko/Projects/starry-night-ships'],
|
|
93
|
+
indexes: {
|
|
94
|
+
'/tmp/session-manager-epic-worktrees/abc/epic-1': {
|
|
95
|
+
'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dup', status: 'proposed' },
|
|
96
|
+
},
|
|
97
|
+
'/home/bilko/Projects/starry-night-ships': {
|
|
98
|
+
'epic-1': { id: 'epic-1', claudeSessionId: 'sess-dup', status: 'active' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
ephemeral: ['/tmp/session-manager-epic-worktrees/abc/epic-1'],
|
|
102
|
+
});
|
|
103
|
+
const result = resolveProjectContext({ originClaudeSessionId: 'sess-dup' }, deps);
|
|
104
|
+
expect(result.epicCwd).toBe('/home/bilko/Projects/starry-night-ships');
|
|
105
|
+
expect(result.source).toBe('cross-project-scan');
|
|
106
|
+
expect(deps.warn).toHaveBeenCalledTimes(1);
|
|
107
|
+
const warnMsg = deps.warn.mock.calls[0][0];
|
|
108
|
+
expect(warnMsg).toContain('/tmp/session-manager-epic-worktrees/abc/epic-1');
|
|
109
|
+
expect(warnMsg).toContain('/home/bilko/Projects/starry-night-ships');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('an originProjectRoot hint outranks the supplied cwd, but a resolved Epic still outranks the hint', () => {
|
|
113
|
+
const deps = makeDeps({
|
|
114
|
+
indexes: {
|
|
115
|
+
'/home/bilko/Projects/hinted': { 'epic-4': { id: 'epic-4', claudeSessionId: 'sess-4', status: 'active' } },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const noEpic = resolveProjectContext({ cwd: '/home/bilko/Projects/guessed', originProjectRoot: '/home/bilko/Projects/hinted' }, deps);
|
|
119
|
+
expect(noEpic.cwd).toBe('/home/bilko/Projects/hinted');
|
|
120
|
+
|
|
121
|
+
const withEpic = resolveProjectContext(
|
|
122
|
+
{ cwd: '/home/bilko/Projects/guessed', originProjectRoot: '/home/bilko/Projects/hinted', originClaudeSessionId: 'sess-4' },
|
|
123
|
+
deps,
|
|
124
|
+
);
|
|
125
|
+
expect(withEpic.cwd).toBe('/home/bilko/Projects/hinted');
|
|
126
|
+
expect(withEpic.epicId).toBe('epic-4');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('never throws on missing cwd/originClaudeSessionId/epicId — returns an all-null result', () => {
|
|
130
|
+
const deps = makeDeps();
|
|
131
|
+
expect(resolveProjectContext({}, deps)).toEqual({ cwd: null, epicId: null, epicCwd: null, source: 'cwd' });
|
|
132
|
+
expect(resolveProjectContext(undefined, deps)).toEqual({ cwd: null, epicId: null, epicCwd: null, source: 'cwd' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('bounded: reads each candidate project\'s active-index exactly once per call', () => {
|
|
136
|
+
const readActiveIndex = vi.fn((cwd) => ({ sessions: {}, events: {} }));
|
|
137
|
+
const deps = {
|
|
138
|
+
projectRootOf: (p) => p,
|
|
139
|
+
allProjectCwds: () => ['/home/bilko/Projects/a', '/home/bilko/Projects/b', '/home/bilko/Projects/c'],
|
|
140
|
+
readActiveIndex,
|
|
141
|
+
isEphemeralCwd: () => false,
|
|
142
|
+
warn: vi.fn(),
|
|
143
|
+
};
|
|
144
|
+
resolveProjectContext({ cwd: '/home/bilko/Projects/a', originClaudeSessionId: 'sess-x' }, deps);
|
|
145
|
+
expect(readActiveIndex).toHaveBeenCalledTimes(3);
|
|
146
|
+
const calledCwds = readActiveIndex.mock.calls.map((c) => c[0]);
|
|
147
|
+
expect(new Set(calledCwds).size).toBe(3);
|
|
148
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueHealth.test.cjs — computeQueueHealth: the pure per-project rollup
|
|
3
|
+
* behind the periodic queue-health sweep (PRD 1109).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper, no electron import — mirrors reaperHelpers.test.cjs's pattern
|
|
6
|
+
* so it never needs scheduler.cjs's mocked IPC surface.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/queueHealth.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test } from 'vitest';
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const { computeQueueHealth } = require('../queueHealth.cjs');
|
|
16
|
+
|
|
17
|
+
test('counts never_ran, looksDone, and stuck per project cwd', () => {
|
|
18
|
+
const jobs = [
|
|
19
|
+
{ slug: 'a', cwd: '/proj-1', status: 'failed', gateOutcome: 'never_ran' },
|
|
20
|
+
{ slug: 'b', cwd: '/proj-1', status: 'failed', looksDone: { commits: ['abc1234'] } },
|
|
21
|
+
{ slug: 'c', cwd: '/proj-1', status: 'needs_review' },
|
|
22
|
+
{ slug: 'd', cwd: '/proj-2', status: 'quarantined' },
|
|
23
|
+
];
|
|
24
|
+
const rollup = computeQueueHealth(jobs);
|
|
25
|
+
const byProj = Object.fromEntries(rollup.map((r) => [r.cwd, r]));
|
|
26
|
+
assert.deepStrictEqual(byProj['/proj-1'], { cwd: '/proj-1', neverRan: 1, looksDone: 1, stuck: 1 });
|
|
27
|
+
assert.deepStrictEqual(byProj['/proj-2'], { cwd: '/proj-2', neverRan: 0, looksDone: 0, stuck: 1 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a project with zero drift across all three counters is omitted entirely', () => {
|
|
31
|
+
const jobs = [
|
|
32
|
+
{ slug: 'clean', cwd: '/proj-clean', status: 'completed' },
|
|
33
|
+
{ slug: 'running', cwd: '/proj-clean', status: 'running' },
|
|
34
|
+
{ slug: 'pending', cwd: '/proj-clean', status: 'pending' },
|
|
35
|
+
];
|
|
36
|
+
assert.deepStrictEqual(computeQueueHealth(jobs), []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('gateOutcome values other than never_ran do not count', () => {
|
|
40
|
+
const jobs = [
|
|
41
|
+
{ slug: 'a', cwd: '/proj', status: 'completed', gateOutcome: 'passed' },
|
|
42
|
+
{ slug: 'b', cwd: '/proj', status: 'failed', gateOutcome: 'failed' },
|
|
43
|
+
{ slug: 'c', cwd: '/proj', status: 'failed', gateOutcome: 'unknown' },
|
|
44
|
+
];
|
|
45
|
+
assert.deepStrictEqual(computeQueueHealth(jobs), []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('empty/missing jobs array is handled without throwing', () => {
|
|
49
|
+
assert.deepStrictEqual(computeQueueHealth([]), []);
|
|
50
|
+
assert.deepStrictEqual(computeQueueHealth(undefined), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('is a pure read: never mutates the input jobs array or its rows', () => {
|
|
54
|
+
const jobs = [{ slug: 'a', cwd: '/proj', status: 'needs_review', gateOutcome: 'never_ran' }];
|
|
55
|
+
const snapshot = JSON.parse(JSON.stringify(jobs));
|
|
56
|
+
computeQueueHealth(jobs);
|
|
57
|
+
assert.deepStrictEqual(jobs, snapshot);
|
|
58
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 60 npx vitest run src/main/lib/__tests__/quietMachineLease.test.cjs
|
|
4
|
+
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const lease = require('../quietMachineLease.cjs');
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
lease.__resetForTests();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('free lease: isHeld false, acquire succeeds and reports the holder', () => {
|
|
13
|
+
assert.equal(lease.isHeld(), false);
|
|
14
|
+
assert.equal(lease.acquire('101-quiet'), true);
|
|
15
|
+
assert.equal(lease.isHeld(), true);
|
|
16
|
+
assert.equal(lease.holder(), '101-quiet');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a second acquire while held fails — exclusive by construction', () => {
|
|
20
|
+
assert.equal(lease.acquire('101-quiet'), true);
|
|
21
|
+
assert.equal(lease.acquire('102-other'), false);
|
|
22
|
+
assert.equal(lease.holder(), '101-quiet');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('release frees the lease so the next acquire can succeed', () => {
|
|
26
|
+
lease.acquire('101-quiet');
|
|
27
|
+
assert.equal(lease.release('101-quiet'), true);
|
|
28
|
+
assert.equal(lease.isHeld(), false);
|
|
29
|
+
assert.equal(lease.acquire('102-other'), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('release is idempotent: releasing an unheld lease, or a slug that does not hold it, is a no-op', () => {
|
|
33
|
+
assert.equal(lease.release('nobody'), false);
|
|
34
|
+
lease.acquire('101-quiet');
|
|
35
|
+
assert.equal(lease.release('wrong-slug'), false);
|
|
36
|
+
assert.equal(lease.isHeld(), true);
|
|
37
|
+
assert.equal(lease.release('101-quiet'), true);
|
|
38
|
+
assert.equal(lease.release('101-quiet'), false);
|
|
39
|
+
});
|