claude-code-session-manager 0.75.3 → 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-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- 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 +151 -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 +31 -5
- 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
|
@@ -6,15 +6,28 @@
|
|
|
6
6
|
* Run: timeout 300 npx vitest run src/main/lib/__tests__/delegationReadiness.test.cjs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { test, expect, afterEach } from 'vitest';
|
|
9
|
+
import { test, expect, afterEach, beforeEach } from 'vitest';
|
|
10
10
|
const fs = require('node:fs');
|
|
11
11
|
const fsp = require('node:fs/promises');
|
|
12
12
|
const os = require('node:os');
|
|
13
13
|
const path = require('node:path');
|
|
14
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
checkDelegationReadiness,
|
|
16
|
+
installPrdWriteGuard,
|
|
17
|
+
probeSchedulerMcpLive,
|
|
18
|
+
clearLiveProbeCache,
|
|
19
|
+
PRD_WRITE_GUARD_SCRIPT,
|
|
20
|
+
} = require('../delegationReadiness.cjs');
|
|
21
|
+
|
|
22
|
+
const REQUIRED_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
clearLiveProbeCache();
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
const tmpDirs = [];
|
|
17
29
|
afterEach(async () => {
|
|
30
|
+
clearLiveProbeCache();
|
|
18
31
|
while (tmpDirs.length) {
|
|
19
32
|
const d = tmpDirs.pop();
|
|
20
33
|
await fsp.rm(d, { recursive: true, force: true });
|
|
@@ -32,46 +45,81 @@ async function writeJson(absPath, value) {
|
|
|
32
45
|
await fsp.writeFile(absPath, JSON.stringify(value), 'utf8');
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** A stub stdio MCP server: answers initialize + tools/list over stdin/stdout. */
|
|
49
|
+
async function writeAnsweringStub(dir, { toolNames = REQUIRED_TOOLS, countFile = null } = {}) {
|
|
50
|
+
const scriptPath = path.join(dir, 'stub-mcp-server.cjs');
|
|
51
|
+
const body = `
|
|
52
|
+
'use strict';
|
|
53
|
+
const readline = require('node:readline');
|
|
54
|
+
${countFile ? `require('node:fs').appendFileSync(${JSON.stringify(countFile)}, 'x\\n');` : ''}
|
|
55
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
56
|
+
rl.on('line', (line) => {
|
|
57
|
+
let msg;
|
|
58
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
59
|
+
if (msg.id === 1) {
|
|
60
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'stub', version: '1' } } }) + '\\n');
|
|
61
|
+
} else if (msg.id === 2) {
|
|
62
|
+
const tools = ${JSON.stringify(toolNames)}.map((name) => ({ name }));
|
|
63
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools } }) + '\\n');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
`;
|
|
67
|
+
await fsp.writeFile(scriptPath, body, 'utf8');
|
|
68
|
+
return scriptPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A stub that never responds to anything — exercises the timeout path. */
|
|
72
|
+
async function writeSilentStub(dir) {
|
|
73
|
+
const scriptPath = path.join(dir, 'silent-mcp-server.cjs');
|
|
74
|
+
await fsp.writeFile(scriptPath, 'process.stdin.resume();\n', 'utf8');
|
|
75
|
+
return scriptPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
async function makeGreenFixtures() {
|
|
36
79
|
const homeDir = await mkTmp('sm-delegation-home-');
|
|
37
80
|
const cwd = await mkTmp('sm-delegation-cwd-');
|
|
38
81
|
|
|
82
|
+
const scriptPath = await writeAnsweringStub(homeDir);
|
|
39
83
|
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
40
|
-
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [] } },
|
|
84
|
+
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
|
|
41
85
|
});
|
|
42
86
|
await writeJson(path.join(homeDir, '.claude', 'settings.json'), {
|
|
43
87
|
enabledPlugins: { 'session-manager-dev@session-manager': true },
|
|
44
88
|
});
|
|
45
89
|
await fsp.mkdir(path.join(homeDir, '.claude', 'agents'), { recursive: true });
|
|
46
90
|
await fsp.writeFile(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'), '# dev-lead', 'utf8');
|
|
91
|
+
// The canonical, sanctioned form: an ABSOLUTE path to session-manager's own
|
|
92
|
+
// guard script (installPrdWriteGuard's "reference, never vendor" decision).
|
|
47
93
|
await writeJson(path.join(cwd, '.claude', 'settings.json'), {
|
|
48
94
|
hooks: {
|
|
49
95
|
PreToolUse: [
|
|
50
96
|
{
|
|
51
97
|
matcher: 'Write|Edit|NotebookEdit',
|
|
52
|
-
hooks: [{ type: 'command', command:
|
|
98
|
+
hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
|
|
53
99
|
},
|
|
54
100
|
],
|
|
55
101
|
},
|
|
56
102
|
});
|
|
57
103
|
|
|
58
|
-
return { homeDir, cwd };
|
|
104
|
+
return { homeDir, cwd, scriptPath };
|
|
59
105
|
}
|
|
60
106
|
|
|
61
|
-
test('all
|
|
107
|
+
test('all six checks pass on a fully-configured project', async () => {
|
|
62
108
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
63
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
109
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
64
110
|
|
|
65
111
|
expect(result.ok).toBe(true);
|
|
66
|
-
expect(result.checks).toHaveLength(
|
|
112
|
+
expect(result.checks).toHaveLength(6);
|
|
67
113
|
expect(result.checks.every((c) => c.ok)).toBe(true);
|
|
68
114
|
expect(result.checks.map((c) => c.id)).toEqual([
|
|
69
115
|
'scheduler-mcp',
|
|
116
|
+
'scheduler-mcp-live',
|
|
117
|
+
'scheduler-mcp-project-duplicate',
|
|
70
118
|
'dev-plugin',
|
|
71
119
|
'agent-personas',
|
|
72
120
|
'prd-write-guard',
|
|
73
121
|
]);
|
|
74
|
-
});
|
|
122
|
+
}, 15_000);
|
|
75
123
|
|
|
76
124
|
test('scheduler-mcp: passes via project-scope .mcp.json even without user scope', async () => {
|
|
77
125
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -80,47 +128,72 @@ test('scheduler-mcp: passes via project-scope .mcp.json even without user scope'
|
|
|
80
128
|
mcpServers: { 'session-manager-scheduler': { command: 'node', args: [] } },
|
|
81
129
|
});
|
|
82
130
|
|
|
83
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
131
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
84
132
|
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
85
133
|
expect(check.ok).toBe(true);
|
|
86
|
-
|
|
87
|
-
});
|
|
134
|
+
}, 15_000);
|
|
88
135
|
|
|
89
136
|
test('scheduler-mcp: fails with a runnable fix when absent from both scopes', async () => {
|
|
90
137
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
91
138
|
await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
|
|
92
139
|
|
|
93
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
140
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
94
141
|
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
95
142
|
expect(check.ok).toBe(false);
|
|
96
143
|
expect(result.ok).toBe(false);
|
|
97
144
|
expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
|
|
98
145
|
expect(check.fix).toContain('scheduler-mcp-server.cjs');
|
|
99
|
-
});
|
|
146
|
+
}, 15_000);
|
|
147
|
+
|
|
148
|
+
test('scheduler-mcp: fails when the user-scope script path is RELATIVE', async () => {
|
|
149
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
150
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
151
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
155
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
156
|
+
expect(check.ok).toBe(false);
|
|
157
|
+
expect(check.detail).toMatch(/non-absolute/);
|
|
158
|
+
expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
|
|
159
|
+
// Live probe has nothing runnable to trust, so it must not report ready either.
|
|
160
|
+
expect(result.checks.find((c) => c.id === 'scheduler-mcp-live').skipped).toBe(true);
|
|
161
|
+
}, 15_000);
|
|
162
|
+
|
|
163
|
+
test('scheduler-mcp: fails when the user-scope script path is absolute but MISSING', async () => {
|
|
164
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
165
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
166
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: [path.join(homeDir, 'does-not-exist.cjs')] } },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
170
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
171
|
+
expect(check.ok).toBe(false);
|
|
172
|
+
expect(check.detail).toMatch(/no longer exists/);
|
|
173
|
+
}, 15_000);
|
|
100
174
|
|
|
101
175
|
test('dev-plugin: fails independently when enabledPlugins is missing the key', async () => {
|
|
102
176
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
103
177
|
await writeJson(path.join(homeDir, '.claude', 'settings.json'), { enabledPlugins: {} });
|
|
104
178
|
|
|
105
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
179
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
106
180
|
const check = result.checks.find((c) => c.id === 'dev-plugin');
|
|
107
181
|
expect(check.ok).toBe(false);
|
|
108
182
|
expect(result.ok).toBe(false);
|
|
109
183
|
expect(check.fix).toBeTruthy();
|
|
110
|
-
// the other
|
|
184
|
+
// the other checks still pass independently
|
|
111
185
|
expect(result.checks.filter((c) => c.id !== 'dev-plugin').every((c) => c.ok)).toBe(true);
|
|
112
|
-
});
|
|
186
|
+
}, 15_000);
|
|
113
187
|
|
|
114
188
|
test('agent-personas: fails independently when ~/.claude/agents has no personas', async () => {
|
|
115
189
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
116
190
|
await fsp.rm(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'));
|
|
117
191
|
|
|
118
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
192
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
119
193
|
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
120
194
|
expect(check.ok).toBe(false);
|
|
121
195
|
expect(result.ok).toBe(false);
|
|
122
|
-
|
|
123
|
-
});
|
|
196
|
+
}, 15_000);
|
|
124
197
|
|
|
125
198
|
test('agent-personas: passes via project-scope .claude/agents/ even without global personas', async () => {
|
|
126
199
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -128,31 +201,20 @@ test('agent-personas: passes via project-scope .claude/agents/ even without glob
|
|
|
128
201
|
await fsp.mkdir(path.join(cwd, '.claude', 'agents'), { recursive: true });
|
|
129
202
|
await fsp.writeFile(path.join(cwd, '.claude', 'agents', 'builder.md'), '# builder', 'utf8');
|
|
130
203
|
|
|
131
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
204
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
132
205
|
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
133
206
|
expect(check.ok).toBe(true);
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('agent-personas: fails when the directory does not exist at all', async () => {
|
|
138
|
-
const { homeDir, cwd } = await makeGreenFixtures();
|
|
139
|
-
await fsp.rm(path.join(homeDir, '.claude', 'agents'), { recursive: true, force: true });
|
|
140
|
-
|
|
141
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
142
|
-
const check = result.checks.find((c) => c.id === 'agent-personas');
|
|
143
|
-
expect(check.ok).toBe(false);
|
|
144
|
-
});
|
|
207
|
+
}, 15_000);
|
|
145
208
|
|
|
146
209
|
test('prd-write-guard: fails independently when the hook is missing', async () => {
|
|
147
210
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
148
211
|
await writeJson(path.join(cwd, '.claude', 'settings.json'), { hooks: { PreToolUse: [] } });
|
|
149
212
|
|
|
150
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
213
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
151
214
|
const check = result.checks.find((c) => c.id === 'prd-write-guard');
|
|
152
215
|
expect(check.ok).toBe(false);
|
|
153
216
|
expect(result.ok).toBe(false);
|
|
154
|
-
|
|
155
|
-
});
|
|
217
|
+
}, 15_000);
|
|
156
218
|
|
|
157
219
|
test('unparseable JSON files yield ok:false with a detail, never a throw', async () => {
|
|
158
220
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
@@ -160,8 +222,8 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
|
|
|
160
222
|
await fs.promises.writeFile(path.join(homeDir, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
161
223
|
await fs.promises.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
162
224
|
|
|
163
|
-
expect(
|
|
164
|
-
const result = checkDelegationReadiness({ cwd, homeDir });
|
|
225
|
+
await expect(checkDelegationReadiness({ cwd, homeDir })).resolves.toBeTruthy();
|
|
226
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
165
227
|
|
|
166
228
|
const scheduler = result.checks.find((c) => c.id === 'scheduler-mcp');
|
|
167
229
|
const devPlugin = result.checks.find((c) => c.id === 'dev-plugin');
|
|
@@ -173,13 +235,150 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
|
|
|
173
235
|
expect(guard.ok).toBe(false);
|
|
174
236
|
expect(guard.detail).toBeTruthy();
|
|
175
237
|
expect(result.ok).toBe(false);
|
|
176
|
-
});
|
|
238
|
+
}, 15_000);
|
|
239
|
+
|
|
240
|
+
// ─────────────────────────────── scheduler-mcp-project-duplicate
|
|
241
|
+
|
|
242
|
+
test('scheduler-mcp-project-duplicate: warns when both scopes register the server', async () => {
|
|
243
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
244
|
+
await writeJson(path.join(cwd, '.mcp.json'), {
|
|
245
|
+
mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
249
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
|
|
250
|
+
expect(check.ok).toBe(true);
|
|
251
|
+
expect(check.warn).toBe(true);
|
|
252
|
+
expect(check.detail).toMatch(/user scope is canonical/);
|
|
253
|
+
// A warning never fails the overall gate.
|
|
254
|
+
expect(result.ok).toBe(true);
|
|
255
|
+
}, 15_000);
|
|
256
|
+
|
|
257
|
+
test('scheduler-mcp-project-duplicate: no warning when only user scope is registered', async () => {
|
|
258
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
259
|
+
|
|
260
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
261
|
+
const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
|
|
262
|
+
expect(check.ok).toBe(true);
|
|
263
|
+
expect(check.warn).toBe(false);
|
|
264
|
+
}, 15_000);
|
|
265
|
+
|
|
266
|
+
// ─────────────────────────────── scheduler-mcp-live
|
|
267
|
+
|
|
268
|
+
test('scheduler-mcp-live: ok when the server answers tools/list with both required tools', async () => {
|
|
269
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
270
|
+
const scriptPath = await writeAnsweringStub(dir);
|
|
271
|
+
|
|
272
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
273
|
+
expect(result.ok).toBe(true);
|
|
274
|
+
expect(result.detail).toContain('scheduler_create_prd');
|
|
275
|
+
}, 15_000);
|
|
276
|
+
|
|
277
|
+
test('scheduler-mcp-live: fails naming the missing tool when the server answers WITHOUT it', async () => {
|
|
278
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
279
|
+
const scriptPath = await writeAnsweringStub(dir, { toolNames: ['scheduler_create_prd'] });
|
|
280
|
+
|
|
281
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
282
|
+
expect(result.ok).toBe(false);
|
|
283
|
+
expect(result.detail).toContain('session_manager_help');
|
|
284
|
+
}, 15_000);
|
|
285
|
+
|
|
286
|
+
test('scheduler-mcp-live: times out (bounded) against a server that never answers, and kills the child', async () => {
|
|
287
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
288
|
+
const scriptPath = await writeSilentStub(dir);
|
|
289
|
+
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
|
|
292
|
+
const elapsed = Date.now() - start;
|
|
177
293
|
|
|
178
|
-
|
|
294
|
+
expect(result.ok).toBe(false);
|
|
295
|
+
expect(result.detail).toBe('timeout');
|
|
296
|
+
expect(elapsed).toBeLessThan(15_000);
|
|
297
|
+
}, 20_000);
|
|
298
|
+
|
|
299
|
+
test('scheduler-mcp-live: is skipped, not failed, when scheduler-mcp already failed', async () => {
|
|
300
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
301
|
+
await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
|
|
302
|
+
|
|
303
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
304
|
+
const live = result.checks.find((c) => c.id === 'scheduler-mcp-live');
|
|
305
|
+
expect(live.ok).toBe(true);
|
|
306
|
+
expect(live.skipped).toBe(true);
|
|
307
|
+
// One red row for the registration failure, not two.
|
|
308
|
+
expect(result.checks.filter((c) => !c.ok)).toHaveLength(1);
|
|
309
|
+
}, 15_000);
|
|
310
|
+
|
|
311
|
+
test('scheduler-mcp-live: memoizes the probe per registration signature within the TTL', async () => {
|
|
312
|
+
const dir = await mkTmp('sm-live-probe-');
|
|
313
|
+
const countFile = path.join(dir, 'spawn-count.txt');
|
|
314
|
+
const scriptPath = await writeAnsweringStub(dir, { countFile });
|
|
315
|
+
|
|
316
|
+
const { homeDir, cwd } = await makeGreenFixtures();
|
|
317
|
+
await writeJson(path.join(homeDir, '.claude.json'), {
|
|
318
|
+
mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await checkDelegationReadiness({ cwd, homeDir });
|
|
322
|
+
await checkDelegationReadiness({ cwd, homeDir });
|
|
323
|
+
|
|
324
|
+
const spawnCount = (await fsp.readFile(countFile, 'utf8')).trim().split('\n').filter(Boolean).length;
|
|
325
|
+
expect(spawnCount).toBe(1);
|
|
326
|
+
}, 15_000);
|
|
327
|
+
|
|
328
|
+
// ─────────────────────────────── installPrdWriteGuard (unchanged behavior)
|
|
329
|
+
|
|
330
|
+
test('installPrdWriteGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
|
|
179
331
|
const { homeDir, cwd } = await makeGreenFixtures();
|
|
180
332
|
await fsp.rm(path.join(cwd, '.claude', 'settings.json'));
|
|
181
333
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
expect(
|
|
334
|
+
const r = await installPrdWriteGuard({ cwd });
|
|
335
|
+
expect(r.ok).toBe(true);
|
|
336
|
+
expect(r.action).toBe('installed');
|
|
337
|
+
expect(r.command).toBe(`node ${PRD_WRITE_GUARD_SCRIPT}`);
|
|
338
|
+
|
|
339
|
+
const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
|
|
340
|
+
expect(written.hooks.PreToolUse).toEqual([
|
|
341
|
+
{ matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
|
|
342
|
+
]);
|
|
343
|
+
const result = await checkDelegationReadiness({ cwd, homeDir });
|
|
344
|
+
expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
|
|
345
|
+
}, 15_000);
|
|
346
|
+
|
|
347
|
+
test('installPrdWriteGuard: is idempotent — a healthy guard is a no-op', async () => {
|
|
348
|
+
const { cwd } = await makeGreenFixtures();
|
|
349
|
+
await installPrdWriteGuard({ cwd });
|
|
350
|
+
const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
|
|
351
|
+
|
|
352
|
+
const again = await installPrdWriteGuard({ cwd });
|
|
353
|
+
expect(again.action).toBe('already-installed');
|
|
354
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('installPrdWriteGuard: refuses on unparseable settings rather than discarding them', async () => {
|
|
358
|
+
const { cwd } = await makeGreenFixtures();
|
|
359
|
+
await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
|
|
360
|
+
|
|
361
|
+
const r = await installPrdWriteGuard({ cwd });
|
|
362
|
+
expect(r.ok).toBe(false);
|
|
363
|
+
expect(r.action).toBe('error');
|
|
364
|
+
expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// The guard script is not just referenced — it is EXERCISED here, so the
|
|
368
|
+
// "certifies a config that has never been proven to run" gap is closed by a
|
|
369
|
+
// real end-to-end run of the exact command installPrdWriteGuard writes.
|
|
370
|
+
test('the installed command actually DENIES a scheduler PRD write and allows a normal one', async () => {
|
|
371
|
+
const { cwd } = await makeGreenFixtures();
|
|
372
|
+
const { execFileSync } = require('node:child_process');
|
|
373
|
+
|
|
374
|
+
const run = (toolInput) => JSON.parse(execFileSync('node', [PRD_WRITE_GUARD_SCRIPT], {
|
|
375
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: toolInput, cwd }),
|
|
376
|
+
encoding: 'utf8',
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
const denied = run({ file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' });
|
|
380
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
381
|
+
|
|
382
|
+
const allowed = run({ file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' });
|
|
383
|
+
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
185
384
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ephemeralCwd.test.cjs — isEphemeralCwd(cwd) must refuse a cwd that
|
|
3
|
+
* resolves to os.tmpdir() itself, into a managed worktree root, or onto a
|
|
4
|
+
* linked git worktree's own root — and must allow an ordinary project cwd.
|
|
5
|
+
*
|
|
6
|
+
* See ephemeralCwd.cjs's header for the live incident (2026-09-01) this
|
|
7
|
+
* predicate closes: queueStore.writeSplit materializing scheduler state
|
|
8
|
+
* inside an ephemeral epic worktree, torn down with the Epic and silently
|
|
9
|
+
* losing the write.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/ephemeralCwd.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { isEphemeralCwd } = require('../ephemeralCwd.cjs');
|
|
22
|
+
const { KIND_CONFIG } = require('../gitWorktree.cjs');
|
|
23
|
+
|
|
24
|
+
const tmpDirs = [];
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
while (tmpDirs.length) {
|
|
28
|
+
await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A real project must never itself live under /tmp — only ephemeral fixtures
|
|
33
|
+
// (worktree checkouts) do. `test-results/` is already gitignored.
|
|
34
|
+
const NON_TMP_SCRATCH_ROOT = path.join(process.cwd(), 'test-results', 'ephemeral-cwd-fixtures');
|
|
35
|
+
|
|
36
|
+
async function mkNonTmpDir(prefix) {
|
|
37
|
+
fs.mkdirSync(NON_TMP_SCRATCH_ROOT, { recursive: true });
|
|
38
|
+
return fsp.mkdtemp(path.join(NON_TMP_SCRATCH_ROOT, prefix));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function makeWorktreeFixture() {
|
|
42
|
+
const main = await mkNonTmpDir('sm-ephemeral-main-');
|
|
43
|
+
tmpDirs.push(main);
|
|
44
|
+
fs.mkdirSync(path.join(main, '.git'), { recursive: true });
|
|
45
|
+
const worktree = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-ephemeral-worktree-'));
|
|
46
|
+
tmpDirs.push(worktree);
|
|
47
|
+
const worktreeName = 'sm-epic-abc123';
|
|
48
|
+
const worktreeGitFile = path.join(worktree, '.git');
|
|
49
|
+
const adminDir = path.join(main, '.git', 'worktrees', worktreeName);
|
|
50
|
+
fs.mkdirSync(adminDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(adminDir, 'gitdir'), `${worktreeGitFile}\n`, 'utf8');
|
|
52
|
+
fs.writeFileSync(worktreeGitFile, `gitdir: ${adminDir}\n`, 'utf8');
|
|
53
|
+
return { main, worktree };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('isEphemeralCwd refuses os.tmpdir() itself', () => {
|
|
57
|
+
expect(isEphemeralCwd(os.tmpdir())).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('isEphemeralCwd refuses a cwd under the managed epic worktree root', () => {
|
|
61
|
+
const cwd = path.join(KIND_CONFIG.epic.root, 'some-hash', 'some-epic-slug');
|
|
62
|
+
expect(isEphemeralCwd(cwd)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('isEphemeralCwd refuses a cwd under the managed job worktree root', () => {
|
|
66
|
+
const cwd = path.join(KIND_CONFIG.job.root, 'some-job-id');
|
|
67
|
+
expect(isEphemeralCwd(cwd)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('isEphemeralCwd refuses a linked git worktree root anywhere on disk', async () => {
|
|
71
|
+
const { worktree } = await makeWorktreeFixture();
|
|
72
|
+
expect(isEphemeralCwd(worktree)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('isEphemeralCwd allows an ordinary project cwd (the worktree fixture\'s own main tree)', async () => {
|
|
76
|
+
const { main } = await makeWorktreeFixture();
|
|
77
|
+
expect(isEphemeralCwd(main)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('isEphemeralCwd allows an ordinary tmpdir-adjacent fixture used by other test suites (not os.tmpdir() itself, not a managed worktree root, not a linked worktree)', async () => {
|
|
81
|
+
const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-some-other-test-fixture-'));
|
|
82
|
+
tmpDirs.push(cwd);
|
|
83
|
+
expect(isEphemeralCwd(cwd)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('isEphemeralCwd is false for non-absolute or missing input, never throws', () => {
|
|
87
|
+
expect(isEphemeralCwd('relative/path')).toBe(false);
|
|
88
|
+
expect(isEphemeralCwd('')).toBe(false);
|
|
89
|
+
expect(isEphemeralCwd(null)).toBe(false);
|
|
90
|
+
expect(isEphemeralCwd(undefined)).toBe(false);
|
|
91
|
+
});
|
|
@@ -82,7 +82,7 @@ test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisola
|
|
|
82
82
|
|
|
83
83
|
test('a dirty base tree resolves to null rather than throwing', async () => {
|
|
84
84
|
const cwd = await mkRepoCwd();
|
|
85
|
-
fs.writeFileSync(path.join(cwd, '
|
|
85
|
+
fs.writeFileSync(path.join(cwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
86
86
|
|
|
87
87
|
const result = await handler(null, { cwd, epicId: 'epic-mint-3' });
|
|
88
88
|
expect(result).toBeNull();
|
|
@@ -168,8 +168,8 @@ test('[job] falls back with a reason for a non-git cwd', async () => {
|
|
|
168
168
|
expect(result.reason).toMatch(/not a git repository/);
|
|
169
169
|
});
|
|
170
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, '
|
|
171
|
+
test('[job] falls back with a reason for a dirty base tree with a modified TRACKED file (never silently drops uncommitted human WIP)', async () => {
|
|
172
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
173
173
|
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
174
174
|
expect(result.ok).toBe(false);
|
|
175
175
|
expect(result.reason).toMatch(/uncommitted changes/);
|
|
@@ -398,3 +398,15 @@ test('keyFromBranch strips the kind-specific prefix and returns null for a forei
|
|
|
398
398
|
test('createWorktree throws on an unknown kind', async () => {
|
|
399
399
|
await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
|
|
403
|
+
|
|
404
|
+
test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
|
|
405
|
+
fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
|
|
406
|
+
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
|
|
410
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
|
|
411
|
+
expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
|
|
412
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitWorktreeSalvage.test.cjs — PRD 1071: a job worktree's uncommitted diff
|
|
3
|
+
* (tracked + untracked) is salvaged to a patch file before teardown, so a
|
|
4
|
+
* job killed before its finish-protocol commit doesn't lose its work when
|
|
5
|
+
* cleanupWorktree removes the checkout right after.
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvage.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { execFileSync } = require('node:child_process');
|
|
17
|
+
const gitWorktree = require('../gitWorktree.cjs');
|
|
18
|
+
|
|
19
|
+
let tmpRoot;
|
|
20
|
+
let repoCwd;
|
|
21
|
+
// config.cjs's writeTextAtomic (used internally by salvageWorktreeDiff)
|
|
22
|
+
// restricts writes to a fixed allowlist — real prod call sites write under
|
|
23
|
+
// `~/.claude/session-manager/scheduled-plans/runs/...`, which is covered by
|
|
24
|
+
// config.cjs's WRITE_PREFIXES. Mirror that here instead of writing under
|
|
25
|
+
// os.tmpdir() (which validateWrite would reject) — same convention as
|
|
26
|
+
// coldBootPromptSessionsWrite.test.cjs.
|
|
27
|
+
let outDir;
|
|
28
|
+
|
|
29
|
+
function git(args, cwd) {
|
|
30
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initRepo(dir) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
git(['init', '-q'], dir);
|
|
36
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
37
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
39
|
+
git(['add', '-A'], dir);
|
|
40
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-'));
|
|
45
|
+
repoCwd = path.join(tmpRoot, 'repo');
|
|
46
|
+
initRepo(repoCwd);
|
|
47
|
+
outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-'));
|
|
48
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
53
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('dirty worktree (tracked edit + untracked file) produces a non-empty patch with the untracked file\'s contents', async () => {
|
|
57
|
+
const slug = 'salvage-dirty';
|
|
58
|
+
const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
59
|
+
expect(created.ok).toBe(true);
|
|
60
|
+
|
|
61
|
+
fs.writeFileSync(path.join(created.dir, 'README.md'), 'hello\nedited\n', 'utf8');
|
|
62
|
+
fs.writeFileSync(path.join(created.dir, 'new-file.txt'), 'brand new untracked content\n', 'utf8');
|
|
63
|
+
|
|
64
|
+
const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
|
|
65
|
+
const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
|
|
66
|
+
|
|
67
|
+
expect(result.ok).toBe(true);
|
|
68
|
+
expect(result.bytes).toBeGreaterThan(0);
|
|
69
|
+
expect(fs.existsSync(outFile)).toBe(true);
|
|
70
|
+
const patch = fs.readFileSync(outFile, 'utf8');
|
|
71
|
+
expect(patch).toContain('new-file.txt');
|
|
72
|
+
expect(patch).toContain('brand new untracked content');
|
|
73
|
+
expect(patch).toContain('README.md');
|
|
74
|
+
expect(patch).toContain('edited');
|
|
75
|
+
|
|
76
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('clean worktree produces no patch file (no 0-byte artifact)', async () => {
|
|
80
|
+
const slug = 'salvage-clean';
|
|
81
|
+
const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
|
|
82
|
+
expect(created.ok).toBe(true);
|
|
83
|
+
|
|
84
|
+
const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
|
|
85
|
+
const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
|
|
86
|
+
|
|
87
|
+
expect(result.ok).toBe(false);
|
|
88
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
89
|
+
|
|
90
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a git failure during salvage is swallowed (never throws) and cleanup still runs', async () => {
|
|
94
|
+
const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
|
|
95
|
+
fs.mkdirSync(notAGitRepo, { recursive: true });
|
|
96
|
+
const outFile = path.join(outDir, 'salvage-failure.uncommitted.patch');
|
|
97
|
+
|
|
98
|
+
await expect(gitWorktree.salvageWorktreeDiff({ cwd: notAGitRepo, outFile })).resolves.toEqual({ ok: false });
|
|
99
|
+
expect(fs.existsSync(outFile)).toBe(false);
|
|
100
|
+
|
|
101
|
+
// Cleanup of an unrelated, never-created worktree is itself a no-op but
|
|
102
|
+
// must still resolve without throwing — mirrors the finally-block shape in
|
|
103
|
+
// scheduler.cjs where salvage failure must never block integration/cleanup.
|
|
104
|
+
await expect(
|
|
105
|
+
gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: path.join(tmpRoot, 'never-existed'), branch: 'sm-job/does-not-exist' }),
|
|
106
|
+
).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
@@ -142,8 +142,8 @@ test('falls back with a reason for a non-git cwd', async () => {
|
|
|
142
142
|
expect(result.reason).toMatch(/not a git repository/);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
test('falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
|
|
146
|
-
fs.writeFileSync(path.join(repoCwd, '
|
|
145
|
+
test('falls back with a reason for a dirty base tree with a modified TRACKED file (never silently drops uncommitted human WIP)', async () => {
|
|
146
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
147
147
|
const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
|
|
148
148
|
expect(result.ok).toBe(false);
|
|
149
149
|
expect(result.reason).toMatch(/uncommitted changes/);
|