claude-code-session-manager 0.75.2 → 0.76.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-s1jRGOji.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-DNefcWbk.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-4sMk80yI.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-CNUVwH5P.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-BhkyvIhw.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-BpockanA.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-Cre7h3U0.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-x-FzvaXm.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-CXAcQLMB.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-CGBv1Rco.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-czEs21N2.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-rGkLOpqI.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-BTmB_vgU.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-dmCivDq5.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D80TNkAx.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-C6wHSZP7.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DsaiYTgl.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-m2fyU8Q5.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-yS-lgBFU.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-DtQgCs9-.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CLuVdoMA.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-DQXoI752.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-BGKVXrvq.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-RFnEV71m.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-Ddrw7J7O.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-LwuJXKD1.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +146 -4
- package/scripts/scheduler-mcp-server.cjs +154 -95
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdCreate.test.cjs +109 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- 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 +22 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +56 -4
- package/src/main/ipcSchemas.cjs +18 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +202 -0
- package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -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__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +33 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/delegationReadiness.cjs +408 -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/gitWorktree.cjs +56 -2
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +285 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +94 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdCreate.cjs +46 -14
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueStore.cjs +70 -8
- package/src/main/lib/rcaReport.cjs +1 -1
- package/src/main/lib/reaperHelpers.cjs +47 -1
- package/src/main/lib/schedulerBatch.cjs +171 -29
- package/src/main/lib/schedulerConfig.cjs +80 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +2 -1
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/scheduler.cjs +346 -49
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +2 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/loadGate.test.cjs
|
|
3
|
+
//
|
|
4
|
+
// PRD 1085 — CPU-load launch gate. The static per-project cap
|
|
5
|
+
// (schedulerBatchProjectCap.test.cjs) cannot see the feedback loop observed
|
|
6
|
+
// 2026-09-01 on starry-night-ships: 4 Godot test batteries under Xvfb,
|
|
7
|
+
// loadavg 12.95 / 14 cores = 0.93, every battery stretching past its
|
|
8
|
+
// executor's timeout and spawning fix-chain reruns that launch more
|
|
9
|
+
// batteries. These tests pin the pure decision, the audit rate-limit and the
|
|
10
|
+
// escalation, with loadavg/cores/clock all injected.
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { isLoadGated, createLoadGate, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
|
|
13
|
+
const { loadGateThreshold, LOAD_GATE_PER_CORE } = require('../schedulerConfig.cjs');
|
|
14
|
+
|
|
15
|
+
const ORIGINAL_ENV = process.env.SM_LOAD_GATE_PER_CORE;
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (ORIGINAL_ENV === undefined) delete process.env.SM_LOAD_GATE_PER_CORE;
|
|
18
|
+
else process.env.SM_LOAD_GATE_PER_CORE = ORIGINAL_ENV;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// ─── isLoadGated ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
test('the live incident shape gates: 12.95 / 14 cores = 0.93 > 0.85', () => {
|
|
24
|
+
assert.equal(isLoadGated(12.95, 14, 0.85), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('below threshold does not gate', () => {
|
|
28
|
+
assert.equal(isLoadGated(5, 14, 0.85), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('exactly AT threshold does not gate (strictly greater)', () => {
|
|
32
|
+
assert.equal(isLoadGated(0.85 * 14, 14, 0.85), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('zero cores never gates (unknown topology must not wedge the queue)', () => {
|
|
36
|
+
assert.equal(isLoadGated(100, 0, 0.85), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('loadavg [0,0,0] (Windows / unsupported) never gates', () => {
|
|
40
|
+
assert.equal(isLoadGated(0, 14, 0.85), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a disabled threshold (0) never gates regardless of load', () => {
|
|
44
|
+
assert.equal(isLoadGated(1000, 1, 0), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── loadGateThreshold (env) ────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
test('default threshold is the documented 0.85', () => {
|
|
50
|
+
delete process.env.SM_LOAD_GATE_PER_CORE;
|
|
51
|
+
assert.equal(LOAD_GATE_PER_CORE, 0.85);
|
|
52
|
+
assert.equal(loadGateThreshold(), 0.85);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('SM_LOAD_GATE_PER_CORE is honored and clamped to [0.25, 4]; 0 disables; garbage falls back', () => {
|
|
56
|
+
process.env.SM_LOAD_GATE_PER_CORE = '1.5';
|
|
57
|
+
assert.equal(loadGateThreshold(), 1.5);
|
|
58
|
+
process.env.SM_LOAD_GATE_PER_CORE = '0.01';
|
|
59
|
+
assert.equal(loadGateThreshold(), 0.25);
|
|
60
|
+
process.env.SM_LOAD_GATE_PER_CORE = '99';
|
|
61
|
+
assert.equal(loadGateThreshold(), 4);
|
|
62
|
+
process.env.SM_LOAD_GATE_PER_CORE = '0';
|
|
63
|
+
assert.equal(loadGateThreshold(), 0);
|
|
64
|
+
process.env.SM_LOAD_GATE_PER_CORE = 'banana';
|
|
65
|
+
assert.equal(loadGateThreshold(), 0.85);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── createLoadGate: decision, 1-minute-only, bypass ───────────────────────
|
|
69
|
+
|
|
70
|
+
function gateWith({ l1 = 12.95, l5 = 0, l15 = 0, cores = 14, threshold = 0.85, clock }) {
|
|
71
|
+
let t = clock ?? 0;
|
|
72
|
+
const g = createLoadGate({
|
|
73
|
+
loadavg: () => [l1, l5, l15],
|
|
74
|
+
cores: () => cores,
|
|
75
|
+
now: () => t,
|
|
76
|
+
threshold,
|
|
77
|
+
});
|
|
78
|
+
return { g, tick: (ms) => { t += ms; } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('evaluate() gates on the incident shape and reports ratio/threshold', () => {
|
|
82
|
+
const { g } = gateWith({});
|
|
83
|
+
const r = g.evaluate();
|
|
84
|
+
assert.equal(r.gated, true);
|
|
85
|
+
assert.equal(r.bypassed, false);
|
|
86
|
+
assert.equal(r.ratio, 0.925);
|
|
87
|
+
assert.equal(r.threshold, 0.85);
|
|
88
|
+
assert.equal(r.loadavg1, 12.95);
|
|
89
|
+
assert.equal(r.cores, 14);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('only the 1-minute average is consulted — a saturated 5/15-minute history with a quiet last minute launches', () => {
|
|
93
|
+
const { g } = gateWith({ l1: 2, l5: 13, l15: 13 });
|
|
94
|
+
assert.equal(g.evaluate().gated, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an explicit Run now bypasses the gate but records that it did', () => {
|
|
98
|
+
const { g } = gateWith({});
|
|
99
|
+
const r = g.evaluate({ bypass: true });
|
|
100
|
+
assert.equal(r.gated, false);
|
|
101
|
+
assert.equal(r.bypassed, true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('bypass on an UNgated tick is not reported as a bypass', () => {
|
|
105
|
+
const { g } = gateWith({ l1: 1 });
|
|
106
|
+
const r = g.evaluate({ bypass: true });
|
|
107
|
+
assert.deepEqual([r.gated, r.bypassed], [false, false]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── audit rate limit + escalation (fake clock) ─────────────────────────────
|
|
111
|
+
|
|
112
|
+
test('audits once, then not again until AUDIT_INTERVAL_MS has elapsed', () => {
|
|
113
|
+
const { g, tick } = gateWith({});
|
|
114
|
+
assert.equal(g.evaluate().shouldAudit, true, 'first gated tick audits');
|
|
115
|
+
tick(60_000);
|
|
116
|
+
assert.equal(g.evaluate().shouldAudit, false, '1 min later: silent');
|
|
117
|
+
tick(AUDIT_INTERVAL_MS - 60_000 - 1);
|
|
118
|
+
assert.equal(g.evaluate().shouldAudit, false, 'just under the interval: silent');
|
|
119
|
+
tick(1);
|
|
120
|
+
assert.equal(g.evaluate().shouldAudit, true, 'at the interval: audits again');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('an ungated tick never audits', () => {
|
|
124
|
+
const { g } = gateWith({ l1: 1 });
|
|
125
|
+
assert.equal(g.evaluate().shouldAudit, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('escalates once the gated stretch exceeds the escalation window, and the stretch resets when load drops', () => {
|
|
129
|
+
let l1 = 12.95;
|
|
130
|
+
let t = 0;
|
|
131
|
+
const g = createLoadGate({
|
|
132
|
+
loadavg: () => [l1, 0, 0],
|
|
133
|
+
cores: () => 14,
|
|
134
|
+
now: () => t,
|
|
135
|
+
threshold: 0.85,
|
|
136
|
+
escalateAfterMs: 45 * 60_000,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(g.evaluate().escalate, false);
|
|
139
|
+
t += 44 * 60_000;
|
|
140
|
+
assert.equal(g.evaluate().escalate, false, 'under 45m: no escalation');
|
|
141
|
+
t += 60_000;
|
|
142
|
+
const r = g.evaluate();
|
|
143
|
+
assert.equal(r.escalate, true, 'at 45m: escalates');
|
|
144
|
+
assert.equal(r.gatedSinceMs, 45 * 60_000);
|
|
145
|
+
l1 = 1; t += 60_000;
|
|
146
|
+
assert.equal(g.evaluate().gatedSinceMs, 0, 'load dropped: stretch resets');
|
|
147
|
+
l1 = 12.95; t += 60_000;
|
|
148
|
+
assert.equal(g.evaluate().gatedSinceMs, 0, 'a fresh stretch starts from zero');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('snapshot() reflects the last evaluation and is null before any', () => {
|
|
152
|
+
const { g } = gateWith({});
|
|
153
|
+
assert.equal(g.snapshot(), null);
|
|
154
|
+
g.evaluate();
|
|
155
|
+
const s = g.snapshot();
|
|
156
|
+
assert.equal(s.gated, true);
|
|
157
|
+
assert.equal(typeof s.at, 'string');
|
|
158
|
+
assert.equal('shouldAudit' in s, false, 'per-tick flags are not part of the persisted snapshot');
|
|
159
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcpToolCatalog.test.cjs — parity + shape gate for PRD (mcpToolCatalog):
|
|
3
|
+
* asserts the catalog and the live scheduler-mcp-server.cjs TOOLS array can
|
|
4
|
+
* never drift apart, that every catalog entry is a well-formed
|
|
5
|
+
* documentation record, and that each tool's exampleArgs is actually
|
|
6
|
+
* runnable against that tool's own inputSchema.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/mcpToolCatalog.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect } from 'vitest';
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { MCP_TOOL_CATALOG, MCP_RECIPES, CatalogEntrySchema, composeDescription } = require('../mcpToolCatalog.cjs');
|
|
15
|
+
// scheduler-mcp-server.cjs guards its stdio-server main() behind
|
|
16
|
+
// `require.main === module`, so requiring it here (require.main is the test
|
|
17
|
+
// runner, not this file) just loads TOOLS without opening a stdio transport.
|
|
18
|
+
const { TOOLS } = require(path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs'));
|
|
19
|
+
|
|
20
|
+
const LIVE_MCP_TOOL_NAMES = TOOLS.map((t) => t.name);
|
|
21
|
+
|
|
22
|
+
test('every live MCP tool name has a catalog entry', () => {
|
|
23
|
+
const catalogNames = new Set(MCP_TOOL_CATALOG.map((e) => e.name));
|
|
24
|
+
const missing = LIVE_MCP_TOOL_NAMES.filter((n) => !catalogNames.has(n));
|
|
25
|
+
expect(missing).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('every catalog entry corresponds to a live MCP tool', () => {
|
|
29
|
+
const liveNames = new Set(LIVE_MCP_TOOL_NAMES);
|
|
30
|
+
const extra = MCP_TOOL_CATALOG.filter((e) => !liveNames.has(e.name)).map((e) => e.name);
|
|
31
|
+
expect(extra).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each(TOOLS)('$name description is composed from the catalog, not a separate literal', (tool) => {
|
|
35
|
+
const entry = MCP_TOOL_CATALOG.find((e) => e.name === tool.name);
|
|
36
|
+
expect(entry).toBeTruthy();
|
|
37
|
+
expect(tool.description).toBe(composeDescription(entry));
|
|
38
|
+
expect(tool.description.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test.each(MCP_TOOL_CATALOG)('$name has all required catalog fields', (entry) => {
|
|
42
|
+
expect(() => CatalogEntrySchema.parse(entry)).not.toThrow();
|
|
43
|
+
expect(entry.name).toBeTruthy();
|
|
44
|
+
expect(entry.group).toBeTruthy();
|
|
45
|
+
expect(entry.purpose).toBeTruthy();
|
|
46
|
+
expect(entry.whenToUse).toBeTruthy();
|
|
47
|
+
expect(entry.whenNotToUse).toBeTruthy();
|
|
48
|
+
expect(entry.exampleArgs).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a catalog entry missing a required field fails schema validation', () => {
|
|
52
|
+
const broken = {
|
|
53
|
+
name: 'some_tool',
|
|
54
|
+
group: 'scheduler',
|
|
55
|
+
purpose: '',
|
|
56
|
+
whenToUse: 'x',
|
|
57
|
+
whenNotToUse: 'y',
|
|
58
|
+
exampleArgs: {},
|
|
59
|
+
notes: null,
|
|
60
|
+
};
|
|
61
|
+
expect(() => CatalogEntrySchema.parse(broken)).toThrow();
|
|
62
|
+
|
|
63
|
+
const missingField = {
|
|
64
|
+
name: 'some_tool',
|
|
65
|
+
group: 'scheduler',
|
|
66
|
+
purpose: 'x',
|
|
67
|
+
whenToUse: 'y',
|
|
68
|
+
// whenNotToUse omitted entirely
|
|
69
|
+
exampleArgs: {},
|
|
70
|
+
notes: null,
|
|
71
|
+
};
|
|
72
|
+
expect(() => CatalogEntrySchema.parse(missingField)).toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test.each(MCP_TOOL_CATALOG)('$name composeDescription joins purpose/whenToUse/whenNotToUse/notes deterministically', (entry) => {
|
|
76
|
+
const expected = [entry.purpose, entry.whenToUse, entry.whenNotToUse, entry.notes].filter(Boolean).join(' ');
|
|
77
|
+
expect(composeDescription(entry)).toBe(expected);
|
|
78
|
+
expect(composeDescription(entry).length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('MCP_RECIPES covers queue-work, unstick-needs-review, and hand-off-to-another-project', () => {
|
|
82
|
+
const ids = MCP_RECIPES.map((r) => r.id);
|
|
83
|
+
expect(ids).toContain('queue-work-via-develop');
|
|
84
|
+
expect(ids).toContain('unstick-needs-review-job');
|
|
85
|
+
expect(ids).toContain('hand-finding-to-another-project');
|
|
86
|
+
for (const recipe of MCP_RECIPES) {
|
|
87
|
+
expect(recipe.title).toBeTruthy();
|
|
88
|
+
expect(Array.isArray(recipe.steps)).toBe(true);
|
|
89
|
+
expect(recipe.steps.length).toBeGreaterThan(0);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test.each(MCP_TOOL_CATALOG)('$name exampleArgs satisfies its own inputSchema required fields', (entry) => {
|
|
94
|
+
const tool = TOOLS.find((t) => t.name === entry.name);
|
|
95
|
+
expect(tool).toBeTruthy();
|
|
96
|
+
const required = tool.inputSchema?.required ?? [];
|
|
97
|
+
for (const key of required) {
|
|
98
|
+
expect(Object.prototype.hasOwnProperty.call(entry.exampleArgs, key)).toBe(true);
|
|
99
|
+
expect(entry.exampleArgs[key]).not.toBeUndefined();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -124,3 +124,205 @@ test('the guards compose: no relative cwd from a transcript can reach an ops-roo
|
|
|
124
124
|
// And even if one somehow did, the write path itself refuses it.
|
|
125
125
|
expect(() => queueStore.projectStateDir('src/renderer/state')).toThrow(/absolute path/);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
// ─── the second incident: an ABSOLUTE cwd INSIDE an ops root ────────────────
|
|
129
|
+
//
|
|
130
|
+
// Reported from starry-night-ships on 2026-08-30: 14 stray
|
|
131
|
+
// `.../session-manager-operations/scheduler/state/queue.json` stubs, each
|
|
132
|
+
// nested under another ops directory (`scheduler/prds/`, `prompt-sessions/`,
|
|
133
|
+
// `scheduler/epics/<id>/prds/`), every one holding `{"jobs": []}` and mtimes
|
|
134
|
+
// spanning 8 days. The 2026-08-13 absolute-path guard above could not catch
|
|
135
|
+
// them: these cwds ARE absolute and DO exist. They came from transcripts
|
|
136
|
+
// where an agent had `cd`'d into the artifact directory it was writing, so
|
|
137
|
+
// the last transcript row recorded that subdirectory as the session cwd.
|
|
138
|
+
|
|
139
|
+
test('a transcript cwd inside an ops root is normalized to the project root, not taken literally', async () => {
|
|
140
|
+
const project = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-nested-'));
|
|
141
|
+
tmpDirs.push(project);
|
|
142
|
+
const prdDir = path.join(project, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
|
|
143
|
+
fs.mkdirSync(prdDir, { recursive: true });
|
|
144
|
+
const projectsDir = await mkProjectsDir({ nested: prdDir });
|
|
145
|
+
|
|
146
|
+
const cwds = activeProjectCwds(Infinity, { projectsDir });
|
|
147
|
+
expect(cwds).toContain(project);
|
|
148
|
+
expect(cwds).not.toContain(prdDir);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('normalization keeps the active-project signal rather than dropping the row', () => {
|
|
152
|
+
const { projectRootOf } = require('../../../../scripts/lib/activeSessions.cjs');
|
|
153
|
+
expect(projectRootOf('/p/session-manager-operations/prompt-sessions')).toBe('/p');
|
|
154
|
+
expect(projectRootOf('/p/session-manager-operations')).toBe('/p');
|
|
155
|
+
expect(projectRootOf('/p/session-manager-operations/scheduler/state')).toBe('/p');
|
|
156
|
+
// Already-nested strays truncate at the FIRST segment — the inner one is
|
|
157
|
+
// itself the bug, never a project.
|
|
158
|
+
expect(projectRootOf('/p/session-manager-operations/x/session-manager-operations/y')).toBe('/p');
|
|
159
|
+
// A plain project cwd is untouched.
|
|
160
|
+
expect(projectRootOf('/p/src/main')).toBe('/p/src/main');
|
|
161
|
+
// A directory that merely CONTAINS the name as a substring is not a segment.
|
|
162
|
+
expect(projectRootOf('/p/session-manager-operations-archive')).toBe('/p/session-manager-operations-archive');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('projectStateDir fails closed on an ops-internal cwd, naming the offending value', () => {
|
|
166
|
+
expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
|
|
167
|
+
.toThrow(/must be a project root/);
|
|
168
|
+
expect(() => queueStore.projectStateDir('/p/session-manager-operations/scheduler/prds'))
|
|
169
|
+
.toThrow(/scheduler\/prds/);
|
|
170
|
+
// The guard is segment-exact, not a substring match.
|
|
171
|
+
expect(queueStore.projectStateDir('/p/session-manager-operations-archive'))
|
|
172
|
+
.toBe(path.join('/p/session-manager-operations-archive', 'session-manager-operations', 'scheduler', 'state'));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The writeSplit half of this guard lives in opsRootNestedWrite.test.cjs —
|
|
176
|
+
// it must override HOME before requiring queueStore (writeSplit persists
|
|
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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opsRootNestedWrite.test.cjs — writeSplit must never materialize a second
|
|
3
|
+
* ops root beneath the first.
|
|
4
|
+
*
|
|
5
|
+
* Companion to opsRootAbsoluteCwd.test.cjs; separate file because HOME must
|
|
6
|
+
* be overridden BEFORE requiring queueStore — MACHINE_STATE_PATH is baked
|
|
7
|
+
* into a top-level const from os.homedir() at require time, and writeSplit
|
|
8
|
+
* writes it on every call. (Learned the hard way: running this assertion in
|
|
9
|
+
* the shared file overwrote the real ~/.claude/session-manager/
|
|
10
|
+
* scheduler-machine.json, dropping the user's firePolicy and concurrencyCap.)
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/opsRootNestedWrite.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, afterEach } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const fsp = require('node:fs/promises');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opsroot-home-'));
|
|
24
|
+
process.env.HOME = tmpHome;
|
|
25
|
+
|
|
26
|
+
const queueStore = require('../queueStore.cjs');
|
|
27
|
+
|
|
28
|
+
const tmpDirs = [tmpHome];
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
while (tmpDirs.length > 1) {
|
|
31
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('one ops-internal cwd in a write batch is skipped without losing the other projects', async () => {
|
|
36
|
+
const good = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-opsroot-good-'));
|
|
37
|
+
tmpDirs.push(good);
|
|
38
|
+
const bad = path.join(good, 'session-manager-operations', 'scheduler', 'epics', 'e-1', 'prds');
|
|
39
|
+
fs.mkdirSync(bad, { recursive: true });
|
|
40
|
+
|
|
41
|
+
await queueStore.writeSplit({
|
|
42
|
+
config: {},
|
|
43
|
+
sourceCwds: [bad, good],
|
|
44
|
+
jobs: [{ slug: 'keeper', cwd: good }, { slug: 'stray', cwd: bad }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const written = JSON.parse(fs.readFileSync(queueStore.projectQueuePath(good), 'utf8'));
|
|
48
|
+
expect(written.jobs.map((j) => j.slug)).toEqual(['keeper']);
|
|
49
|
+
// The doubled-suffix stub the starry-night-ships report was about.
|
|
50
|
+
expect(fs.existsSync(path.join(bad, 'session-manager-operations'))).toBe(false);
|
|
51
|
+
});
|
|
@@ -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
|
+
});
|