claude-code-session-manager 0.88.1 → 0.90.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-kQF8_wAw.js → AgentLibrary-CrmoSn9-.js} +1 -1
- package/dist/assets/DataModel-SPLQTR8E.js +1 -0
- package/dist/assets/{History-Sji2brb0.js → History-DT6S3abz.js} +2 -2
- package/dist/assets/{Hooks-CtqpO-p5.js → Hooks-CC2iv5Oq.js} +3 -3
- package/dist/assets/{HostBilko-DN0ydOCr.js → HostBilko-DqY7dA2a.js} +1 -1
- package/dist/assets/{Library-CoiLZTKh.js → Library-CUoOC77T.js} +1 -1
- package/dist/assets/{ListDetail-CmqiQ_io.js → ListDetail-DgrG72f4.js} +1 -1
- package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
- package/dist/assets/{McpServers-Bj1X3_NT.js → McpServers-DupdjvFt.js} +2 -2
- package/dist/assets/{Memory-1PKjsM3b.js → Memory-C_4LhC2r.js} +6 -6
- package/dist/assets/{Panel-I1s6ghPo.js → Panel-DWmE5Fu9.js} +1 -1
- package/dist/assets/{Permissions-BhiiZjHQ.js → Permissions-hxiN2IWM.js} +3 -3
- package/dist/assets/{Plugins-gv3_fkPX.js → Plugins-fttEgZqy.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BGcgBd8V.js → ProvenanceBadge-DROCzlbx.js} +1 -1
- package/dist/assets/{SaveBar-BGr9eZ4g.js → SaveBar-BZs0IQHq.js} +1 -1
- package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
- package/dist/assets/{ScopeSwitcher-ukl0qWaA.js → ScopeSwitcher-C0538XU8.js} +1 -1
- package/dist/assets/{Settings-DJkFj22G.js → Settings-CcW6x-wu.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-0t1Qblp6.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
- package/dist/assets/{Skills-CeVdBGE-.js → Skills-Daf_1sQR.js} +2 -2
- package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
- package/dist/assets/{TagLibrary-DAzNaUPd.js → TagLibrary-81Mr1RLU.js} +1 -1
- package/dist/assets/{TiptapBody--S_rGXoA.js → TiptapBody-BxuLNg1e.js} +1 -1
- package/dist/assets/{Toggle-Dszm0xnp.js → Toggle-CN8EVEai.js} +1 -1
- package/dist/assets/{index-Dp4Rc--q.js → index-A9TWmB7_.js} +345 -345
- package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
- package/dist/assets/{settingsSchema-BLbssMYo.js → settingsSchema-C9I9a0jv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +41 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +23 -0
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +172 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +196 -4
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +35 -4
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +106 -18
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-CY_mMnsr.js +0 -1
- package/dist/assets/MarkdownEditor-HPoNeKhT.js +0 -1
- package/dist/assets/Scheduler-Bdo_awPq.js +0 -14
- package/dist/assets/SystemPrompt-CfKvNHi3.js +0 -1
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-A9TWmB7_.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Dh7BLxdA.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
package/scripts/README.md
CHANGED
|
@@ -10,6 +10,7 @@ scripts/lib was moved into src/main/lib (2026-09); nothing under src/ may requir
|
|
|
10
10
|
| --- | --- | --- |
|
|
11
11
|
| audit-ops-hygiene.cjs | manual; named in `ops-sweep` + `develop` SKILL.md | yes |
|
|
12
12
|
| check-conditional-hooks.cjs | `npm run lint:hooks` (in `lint`) | no |
|
|
13
|
+
| check-epic-transcripts.cjs | manual read-only diagnostic (`--json`); also surfaced as a non-fatal warning in `npm run health` | no |
|
|
13
14
|
| check-doc-hierarchy.cjs | `npm run lint:docs` (in `lint`) | no |
|
|
14
15
|
| check-unregistered-tests.cjs | `npm run lint:unregistered-tests` (in `lint`) | no |
|
|
15
16
|
| check-unstable-selectors.cjs | `npm run lint:selectors` (in `lint`) | no |
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatRunner-session-flag-retry.test.cjs — main is authoritative about
|
|
3
|
+
* --session-id vs --resume, and a CLI flag rejection is retried exactly once
|
|
4
|
+
* with the other flag without losing the turn.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/chatRunner-session-flag-retry.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
let tmpHome;
|
|
17
|
+
let originalHome;
|
|
18
|
+
let cr;
|
|
19
|
+
let sent;
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
originalHome = process.env.HOME;
|
|
23
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-flagretry-home-'));
|
|
24
|
+
process.env.HOME = tmpHome;
|
|
25
|
+
cr = require('../chatRunner.cjs');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
process.env.HOME = originalHome;
|
|
30
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
35
|
+
cr.__resetQueueForTests();
|
|
36
|
+
cr.attachWindow(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function attachRecorder() {
|
|
40
|
+
sent = [];
|
|
41
|
+
cr.attachWindow({
|
|
42
|
+
isDestroyed: () => false,
|
|
43
|
+
webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Stub claude: logs its argv (one JSON line per spawn) to logFile. Invocation 1
|
|
48
|
+
// fails with `firstStderr` (optionally after emitting assistant text); later
|
|
49
|
+
// invocations succeed.
|
|
50
|
+
function writeStub(logFile, { firstStderr, textFirst }) {
|
|
51
|
+
const stubPath = path.join(os.tmpdir(), `sm-flagretry-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
52
|
+
const body = `
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const log = ${JSON.stringify(logFile)};
|
|
55
|
+
fs.appendFileSync(log, JSON.stringify(process.argv.slice(2)) + '\\n');
|
|
56
|
+
const n = fs.readFileSync(log, 'utf8').trim().split('\\n').length;
|
|
57
|
+
if (n === 1) {
|
|
58
|
+
${textFirst ? "process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'partial' }] } }) + '\\n');" : ''}
|
|
59
|
+
process.stderr.write(${JSON.stringify(firstStderr)});
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
`;
|
|
65
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
66
|
+
return stubPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function waitFor(pred, timeoutMs = 8000) {
|
|
70
|
+
const start = Date.now();
|
|
71
|
+
while (!pred()) {
|
|
72
|
+
if (Date.now() - start > timeoutMs) throw new Error('timed out');
|
|
73
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const TERMINAL = ['chat:run:complete', 'chat:run:needs-input', 'chat:run:error'];
|
|
78
|
+
let curTab;
|
|
79
|
+
const terminals = () => sent.filter((e) => TERMINAL.includes(e.channel) && e.payload.tabId === curTab);
|
|
80
|
+
const spawns = (logFile) => fs.readFileSync(logFile, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
81
|
+
|
|
82
|
+
test('in-use error on --session-id retries once with --resume; one terminal event; lane freed', async () => {
|
|
83
|
+
attachRecorder();
|
|
84
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
|
|
85
|
+
const logFile = path.join(cwd, 'argv.log');
|
|
86
|
+
const sessionId = `flag-sess-${process.pid}`;
|
|
87
|
+
process.env.SM_CLAUDE_BIN = writeStub(logFile, { firstStderr: `Error: Session ID ${sessionId} is already in use.\n` });
|
|
88
|
+
try {
|
|
89
|
+
curTab = 'tab-a';
|
|
90
|
+
cr.run({ tabId: 'tab-a', sessionId, prompt: 'hello', cwd, resume: false });
|
|
91
|
+
await waitFor(() => terminals().length > 0);
|
|
92
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
93
|
+
|
|
94
|
+
const argvs = spawns(logFile);
|
|
95
|
+
expect(argvs).toHaveLength(2);
|
|
96
|
+
expect(argvs[0]).toContain('--session-id');
|
|
97
|
+
expect(argvs[1]).toContain('--resume');
|
|
98
|
+
expect(argvs[1]).not.toContain('--session-id');
|
|
99
|
+
expect(argvs[1][argvs[1].indexOf('--model') + 1]).toBe(argvs[0][argvs[0].indexOf('--model') + 1]);
|
|
100
|
+
expect(argvs[1][1]).toBe(argvs[0][1]);
|
|
101
|
+
|
|
102
|
+
expect(terminals()).toHaveLength(1);
|
|
103
|
+
expect(terminals()[0].channel).toBe('chat:run:complete');
|
|
104
|
+
expect(sent.some((e) => e.channel === 'chat:run:notice')).toBe(true);
|
|
105
|
+
|
|
106
|
+
// Lane freed: the same tab accepts a new run immediately (not queued).
|
|
107
|
+
const again = cr.run({ tabId: 'tab-a', sessionId, prompt: 'again', cwd, resume: true });
|
|
108
|
+
expect(again.queued).toBe(false);
|
|
109
|
+
await cr.cancel('tab-a');
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('no retry when the failed attempt already streamed assistant text', async () => {
|
|
116
|
+
attachRecorder();
|
|
117
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
|
|
118
|
+
const logFile = path.join(cwd, 'argv.log');
|
|
119
|
+
const sessionId = `flag-sess2-${process.pid}`;
|
|
120
|
+
process.env.SM_CLAUDE_BIN = writeStub(logFile, {
|
|
121
|
+
firstStderr: `Error: Session ID ${sessionId} is already in use.\n`,
|
|
122
|
+
textFirst: true,
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
curTab = 'tab-b';
|
|
126
|
+
cr.run({ tabId: 'tab-b', sessionId, prompt: 'hello', cwd, resume: false });
|
|
127
|
+
await waitFor(() => terminals().length > 0);
|
|
128
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
129
|
+
|
|
130
|
+
expect(spawns(logFile)).toHaveLength(1);
|
|
131
|
+
expect(terminals()).toHaveLength(1);
|
|
132
|
+
expect(terminals()[0].channel).toBe('chat:run:error');
|
|
133
|
+
expect(terminals()[0].payload.message).toMatch(/already in use/);
|
|
134
|
+
} finally {
|
|
135
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('"No conversation found" on --resume retries once with --session-id (main overrides a wrong renderer flag only via transcript)', async () => {
|
|
140
|
+
attachRecorder();
|
|
141
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
|
|
142
|
+
const logFile = path.join(cwd, 'argv.log');
|
|
143
|
+
const sessionId = `flag-sess3-${process.pid}`;
|
|
144
|
+
process.env.SM_CLAUDE_BIN = writeStub(logFile, { firstStderr: `No conversation found with session ID: ${sessionId}\n` });
|
|
145
|
+
try {
|
|
146
|
+
// No transcript exists anywhere → main computes resume=false even though
|
|
147
|
+
// the renderer said true, so the first attempt is --session-id and the
|
|
148
|
+
// stub's failure is the wrong-direction message: no retry, error surfaces.
|
|
149
|
+
curTab = 'tab-c';
|
|
150
|
+
cr.run({ tabId: 'tab-c', sessionId, prompt: 'hello', cwd, resume: true });
|
|
151
|
+
await waitFor(() => terminals().length > 0);
|
|
152
|
+
const argvs = spawns(logFile);
|
|
153
|
+
expect(argvs).toHaveLength(1);
|
|
154
|
+
expect(argvs[0]).toContain('--session-id');
|
|
155
|
+
expect(terminals()[0].channel).toBe('chat:run:error');
|
|
156
|
+
} finally {
|
|
157
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
@@ -94,7 +94,9 @@ test('end-to-end: check() reports delegation_chain for this repo, expected to pa
|
|
|
94
94
|
process.env.SM_ALLOW_LIVE_ROOT_READS = '1';
|
|
95
95
|
let status;
|
|
96
96
|
try {
|
|
97
|
-
|
|
97
|
+
// skipTypecheck: this test asserts on delegation_chain only; the tsc shell-out was
|
|
98
|
+
// ~15s of the ~16s idle runtime and starved to the 90s budget under CPU load.
|
|
99
|
+
status = await check({ skipTypecheck: true });
|
|
98
100
|
} finally {
|
|
99
101
|
delete process.env.SM_ALLOW_LIVE_ROOT_READS;
|
|
100
102
|
process.env.HOME = sandboxHome;
|
|
@@ -135,13 +135,19 @@ test('pollLoop still attempts a dispatch after a transient billing failure, usin
|
|
|
135
135
|
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
136
136
|
|
|
137
137
|
await seedOnePendingJob();
|
|
138
|
-
|
|
138
|
+
// Baseline, not `toBeNull()`: under full-suite load a straggler tick from an
|
|
139
|
+
// earlier poll/test (spawnJob's completion tick, a slot-release pump) can
|
|
140
|
+
// stamp between the reset above and here, so the stamp is not guaranteed
|
|
141
|
+
// null. What the test proves is that THIS poll+flush advances the stamp.
|
|
142
|
+
const before = await lastDispatchAttemptAt();
|
|
139
143
|
|
|
140
144
|
process.env.SM_MOCK_BILLING_KIND = 'transient';
|
|
141
145
|
await scheduler.pollLoop();
|
|
142
146
|
await flushPendingTick();
|
|
143
147
|
|
|
144
|
-
|
|
148
|
+
const after = await lastDispatchAttemptAt();
|
|
149
|
+
expect(after).not.toBeNull();
|
|
150
|
+
expect(after).not.toBe(before);
|
|
145
151
|
});
|
|
146
152
|
|
|
147
153
|
test('pollLoop still attempts a dispatch after the billing poll throws, using the degraded budget (not utilization 0)', async () => {
|
|
@@ -152,7 +158,11 @@ test('pollLoop still attempts a dispatch after the billing poll throws, using th
|
|
|
152
158
|
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
153
159
|
|
|
154
160
|
await seedOnePendingJob();
|
|
155
|
-
|
|
161
|
+
// Baseline, not `toBeNull()`: under full-suite load a straggler tick from an
|
|
162
|
+
// earlier poll/test (spawnJob's completion tick, a slot-release pump) can
|
|
163
|
+
// stamp between the reset above and here, so the stamp is not guaranteed
|
|
164
|
+
// null. What the test proves is that THIS poll+flush advances the stamp.
|
|
165
|
+
const before = await lastDispatchAttemptAt();
|
|
156
166
|
|
|
157
167
|
delete process.env.SM_E2E;
|
|
158
168
|
delete process.env.SM_MOCK_BILLING_KIND;
|
|
@@ -160,5 +170,7 @@ test('pollLoop still attempts a dispatch after the billing poll throws, using th
|
|
|
160
170
|
await scheduler.pollLoop();
|
|
161
171
|
await flushPendingTick();
|
|
162
172
|
|
|
163
|
-
|
|
173
|
+
const after = await lastDispatchAttemptAt();
|
|
174
|
+
expect(after).not.toBeNull();
|
|
175
|
+
expect(after).not.toBe(before);
|
|
164
176
|
});
|
|
@@ -19,6 +19,13 @@ const path = require('node:path');
|
|
|
19
19
|
|
|
20
20
|
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'default-eligible-heal-test-'));
|
|
21
21
|
|
|
22
|
+
// Fixture cwd must be a REAL, writable dir: transitionJob out of needs_review
|
|
23
|
+
// fire-and-forgets a history append under <cwd>/session-manager-operations. A fake
|
|
24
|
+
// path made it fail EACCES AFTER the sync test returned, so its console.error hit
|
|
25
|
+
// vitest mid worker-close ("Closing rpc while onUserConsoleLog was pending").
|
|
26
|
+
const PROJECT_CWD = path.join(process.env.HOME, 'project');
|
|
27
|
+
fs.mkdirSync(PROJECT_CWD, { recursive: true });
|
|
28
|
+
|
|
22
29
|
const {
|
|
23
30
|
isRescanCandidate,
|
|
24
31
|
isTranscriptRescannable,
|
|
@@ -39,7 +46,7 @@ const {
|
|
|
39
46
|
} = require('../scheduler.cjs');
|
|
40
47
|
const { evaluateBlockingParkHealth } = require('../health.cjs');
|
|
41
48
|
|
|
42
|
-
const row = (o = {}) => ({ slug: '10-x', status: 'needs_review', runId: 'r1', cwd:
|
|
49
|
+
const row = (o = {}) => ({ slug: '10-x', status: 'needs_review', runId: 'r1', cwd: PROJECT_CWD, ...o });
|
|
43
50
|
|
|
44
51
|
test('shared_tree_reverted is a rescan candidate without being on any list', () => {
|
|
45
52
|
assert.equal(RESCANNABLE_VERDICTS.has('shared_tree_reverted'), false);
|
|
@@ -142,8 +149,8 @@ test('auto-resolve ladder is capped: requeue attempts stop at NEEDS_REVIEW_RESOL
|
|
|
142
149
|
describe('evaluateBlockingParkHealth', () => {
|
|
143
150
|
const now = Date.parse('2026-09-18T20:00:00Z');
|
|
144
151
|
const parkedAgo = (ms) => [{ to: 'needs_review', at: new Date(now - ms).toISOString() }];
|
|
145
|
-
const park = (ms, o = {}) => ({ slug: '5-park', status: 'needs_review', cwd:
|
|
146
|
-
const dep = (slug, dependsOn) => ({ slug, status: 'pending', cwd:
|
|
152
|
+
const park = (ms, o = {}) => ({ slug: '5-park', status: 'needs_review', cwd: PROJECT_CWD, verifierVerdict: 'shared_tree_reverted', statusHistory: parkedAgo(ms), ...o });
|
|
153
|
+
const dep = (slug, dependsOn) => ({ slug, status: 'pending', cwd: PROJECT_CWD, dependsOn });
|
|
147
154
|
|
|
148
155
|
test('parked past one interval with pending dependents (transitive) is non-green, naming row + count', () => {
|
|
149
156
|
const r = evaluateBlockingParkHealth([park(REVERIFY_INTERVAL_MS + 1000), dep('6-a', ['5-park']), dep('7-b', ['6-a']), dep('8-c', [])], now);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-integration-failure-stamp.test.cjs — a worktree_integration_failed
|
|
3
|
+
* finalize stamps integrateBranch's failure subtype onto the job row, and the
|
|
4
|
+
* RCA report names it.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-integration-failure-stamp.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test } from 'vitest';
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const { stampIntegrationFailure } = require('../scheduler.cjs');
|
|
14
|
+
const { buildRcaMarkdown } = require('../lib/rcaReport.cjs');
|
|
15
|
+
|
|
16
|
+
test('stamps content_conflict kind and conflicted paths', () => {
|
|
17
|
+
const row = { slug: 'x' };
|
|
18
|
+
stampIntegrationFailure(row, { ok: false, failureKind: 'content_conflict', conflictedPaths: ['a.md'] });
|
|
19
|
+
assert.equal(row.integrationFailureKind, 'content_conflict');
|
|
20
|
+
assert.deepEqual(row.integrationConflictPaths, ['a.md']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('blocking_paths stamps kind only; no failure clears stale fields', () => {
|
|
24
|
+
const row = { integrationConflictPaths: ['old'] };
|
|
25
|
+
stampIntegrationFailure(row, { ok: false, failureKind: 'blocking_paths', blockingPaths: { tracked: ['f'], untracked: [] } });
|
|
26
|
+
assert.equal(row.integrationFailureKind, 'blocking_paths');
|
|
27
|
+
assert.equal('integrationConflictPaths' in row, false);
|
|
28
|
+
stampIntegrationFailure(row, null);
|
|
29
|
+
assert.equal('integrationFailureKind' in row, false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('RCA markdown names the subtype and conflicted paths', () => {
|
|
33
|
+
const job = {
|
|
34
|
+
slug: 'x', runId: 'r', integrationFailureKind: 'content_conflict',
|
|
35
|
+
integrationConflictPaths: ['docs/asset-pipeline.md'],
|
|
36
|
+
};
|
|
37
|
+
const md = buildRcaMarkdown({
|
|
38
|
+
job, verdict: 'worktree_integration_failed', meta: null, logTail: '', acText: '', failureClass: 'unknown',
|
|
39
|
+
});
|
|
40
|
+
assert.ok(md.includes('Integration failure subtype: content_conflict (conflicted paths: docs/asset-pipeline.md)'));
|
|
41
|
+
});
|
|
@@ -210,7 +210,10 @@ test('needs_review with an in-window commit heals exactly as today (regression
|
|
|
210
210
|
'- [ ] `npm test` passes',
|
|
211
211
|
].join('\n'));
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
// Window opens a minute back: the only commit here is initRepo's, and git's --since
|
|
214
|
+
// compares whole seconds, so `new Date()` (taken AFTER initRepo) drops it out of the
|
|
215
|
+
// window whenever the clock crosses a second boundary under load.
|
|
216
|
+
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
|
214
217
|
const queuePath = writeProjectQueue(projectCwd, [
|
|
215
218
|
{
|
|
216
219
|
slug: '20-clean-feature',
|
|
@@ -266,7 +269,6 @@ test('computeLooksDone: no declared paths on the PRD → null, never fabricates
|
|
|
266
269
|
test('needs_review row with autoFixAttempted:true is never also stamped looksDone (PRD 1136 — mutually exclusive)', async () => {
|
|
267
270
|
const projectCwd = path.join(tmpHome, 'proj-already-fix-planned');
|
|
268
271
|
initRepo(projectCwd);
|
|
269
|
-
registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
|
|
270
272
|
writePrd(projectCwd, '30-example', [
|
|
271
273
|
'# Implementation notes',
|
|
272
274
|
'Edit `src/thing.js`.',
|
|
@@ -301,6 +303,11 @@ test('needs_review row with autoFixAttempted:true is never also stamped looksDon
|
|
|
301
303
|
JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'still broken' }),
|
|
302
304
|
]);
|
|
303
305
|
|
|
306
|
+
// Register LAST: the project only becomes discoverable once its PRD and queue row both
|
|
307
|
+
// exist, so a straggling background reconcile from an earlier test can't see the PRD
|
|
308
|
+
// without its row and mint a 'quarantined' adopt row over this needs_review one.
|
|
309
|
+
registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
|
|
310
|
+
|
|
304
311
|
commitFile(projectCwd, 'src/thing.js', 'hello', 'later commit touching the declared path');
|
|
305
312
|
|
|
306
313
|
await reverifyNeedsReview();
|
|
@@ -118,6 +118,29 @@ test('selectMechanicalRecoveryTarget: kill-switch SM_MECHANICAL_RECOVERY_DISABLE
|
|
|
118
118
|
expect(selectMechanicalRecoveryTarget(eligibleJob())).toBeNull();
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
const conflictJob = (o = {}) => eligibleJob({ integrationFailureKind: 'content_conflict', integrationBaseHeadSha: 'aaa111', ...o });
|
|
122
|
+
|
|
123
|
+
test('selectMechanicalRecoveryTarget: content_conflict with unchanged HEAD is skipped', () => {
|
|
124
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), 'aaa111')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('selectMechanicalRecoveryTarget: content_conflict with a moved HEAD returns a target', () => {
|
|
128
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), 'bbb222')).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('selectMechanicalRecoveryTarget: blocking_paths with unchanged HEAD still returns a target', () => {
|
|
132
|
+
const job = eligibleJob({ integrationFailureKind: 'blocking_paths', integrationBaseHeadSha: 'aaa111' });
|
|
133
|
+
expect(selectMechanicalRecoveryTarget(job, 'aaa111')).not.toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('selectMechanicalRecoveryTarget: a pre-mc-01 row with no integrationFailureKind still returns a target', () => {
|
|
137
|
+
expect(selectMechanicalRecoveryTarget(eligibleJob(), 'aaa111')).not.toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('selectMechanicalRecoveryTarget: null currentHeadSha never skips', () => {
|
|
141
|
+
expect(selectMechanicalRecoveryTarget(conflictJob(), null)).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
121
144
|
test('MECHANICALLY_RESOLVABLE_VERDICTS: closed set contains exactly worktree_integration_failed', () => {
|
|
122
145
|
expect(Array.from(MECHANICALLY_RESOLVABLE_VERDICTS)).toEqual(['worktree_integration_failed']);
|
|
123
146
|
});
|
|
@@ -26,6 +26,14 @@ const path = require('node:path');
|
|
|
26
26
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-review-autoresolve-test-'));
|
|
27
27
|
process.env.HOME = tmpHome;
|
|
28
28
|
|
|
29
|
+
// Fixture cwd must be a REAL, writable dir: every transitionJob out of
|
|
30
|
+
// needs_review fire-and-forgets a history append under <cwd>/session-manager-operations.
|
|
31
|
+
// A fake path ('/home/user/project') made that append fail with EACCES *after*
|
|
32
|
+
// the synchronous test returned, so its console.error landed while vitest was
|
|
33
|
+
// closing the worker ("Closing rpc while onUserConsoleLog was pending" -> exit 1).
|
|
34
|
+
const PROJECT_CWD = path.join(tmpHome, 'project');
|
|
35
|
+
fs.mkdirSync(PROJECT_CWD, { recursive: true });
|
|
36
|
+
|
|
29
37
|
const {
|
|
30
38
|
selectExhaustedNeedsReviewTargets,
|
|
31
39
|
applyNeedsReviewAutoResolve,
|
|
@@ -41,7 +49,7 @@ const THRESHOLD_MS = 30 * MIN_MS;
|
|
|
41
49
|
function exhaustedJob(overrides = {}) {
|
|
42
50
|
return {
|
|
43
51
|
slug: 'exhausted-row',
|
|
44
|
-
cwd:
|
|
52
|
+
cwd: PROJECT_CWD,
|
|
45
53
|
status: 'needs_review',
|
|
46
54
|
autoFixAttempted: true,
|
|
47
55
|
autoFixOutcome: 'no-plan',
|
|
@@ -153,16 +161,16 @@ test('applyNeedsReviewAutoResolve race-guards against a row that moved off needs
|
|
|
153
161
|
test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and the third row becomes eligible', () => {
|
|
154
162
|
const now = Date.now();
|
|
155
163
|
const rowA = {
|
|
156
|
-
slug: 'a-first', cwd:
|
|
164
|
+
slug: 'a-first', cwd: PROJECT_CWD, status: 'completed',
|
|
157
165
|
};
|
|
158
166
|
const rowB = exhaustedJob({
|
|
159
167
|
slug: 'b-middle',
|
|
160
|
-
cwd:
|
|
168
|
+
cwd: PROJECT_CWD,
|
|
161
169
|
exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
|
|
162
170
|
dependsOn: ['a-first'],
|
|
163
171
|
});
|
|
164
172
|
const rowC = {
|
|
165
|
-
slug: 'c-last', cwd:
|
|
173
|
+
slug: 'c-last', cwd: PROJECT_CWD, status: 'pending', dependsOn: ['b-middle'],
|
|
166
174
|
};
|
|
167
175
|
const jobs = [rowA, rowB, rowC];
|
|
168
176
|
|
|
@@ -182,8 +190,8 @@ test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and th
|
|
|
182
190
|
});
|
|
183
191
|
|
|
184
192
|
test('a generic (non-auto-resolved) skipped dep still blocks forever — the carve-out is narrow', () => {
|
|
185
|
-
const rowB = { slug: 'b-middle', cwd:
|
|
186
|
-
const rowC = { slug: 'c-last', cwd:
|
|
193
|
+
const rowB = { slug: 'b-middle', cwd: PROJECT_CWD, status: 'skipped' }; // no needsReviewAutoResolvedSkip marker
|
|
194
|
+
const rowC = { slug: 'c-last', cwd: PROJECT_CWD, status: 'pending', dependsOn: ['b-middle'] };
|
|
187
195
|
const { batch } = pickForProject([rowB, rowC], new Set(), 5);
|
|
188
196
|
assert.equal(batch.length, 0, 'a PRD-source-vanished skip must remain a permanent block, unaffected by this carve-out');
|
|
189
197
|
});
|
|
@@ -103,8 +103,8 @@ test('tickQueue dispatches the healthy project while the torn project is skipped
|
|
|
103
103
|
expect(fs.existsSync(path.join(torn.cwd, 'ran-here.marker'))).toBe(false);
|
|
104
104
|
expect(fs.readFileSync(torn.queueFile).equals(before)).toBe(true);
|
|
105
105
|
|
|
106
|
-
const health = await require('../health.cjs').check();
|
|
106
|
+
const health = await require('../health.cjs').check({ skipTypecheck: true });
|
|
107
107
|
expect(health.components.scheduler_queue.ok).toBe(false);
|
|
108
108
|
expect(health.components.scheduler_queue.quarantinedCwds).toEqual([torn.cwd]);
|
|
109
109
|
expect(health.issues.some((i) => i.includes(torn.cwd))).toBe(true);
|
|
110
|
-
}, 120_000); //
|
|
110
|
+
}, 120_000); // skipTypecheck: this asserts scheduler_queue only; tsc was the tens of seconds
|
|
@@ -38,6 +38,13 @@ const { execFileSync } = require('node:child_process');
|
|
|
38
38
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'stranded-autofix-park-test-'));
|
|
39
39
|
process.env.HOME = tmpHome;
|
|
40
40
|
|
|
41
|
+
// Fixture cwd must be a REAL, writable dir: transitionJob out of needs_review
|
|
42
|
+
// fire-and-forgets a history append under <cwd>/session-manager-operations. A fake
|
|
43
|
+
// path made it fail EACCES AFTER the sync test returned, so its console.error hit
|
|
44
|
+
// vitest mid worker-close ("Closing rpc while onUserConsoleLog was pending").
|
|
45
|
+
const PROJECT_CWD = path.join(tmpHome, 'project');
|
|
46
|
+
fs.mkdirSync(PROJECT_CWD, { recursive: true });
|
|
47
|
+
|
|
41
48
|
const {
|
|
42
49
|
isStrandedAutoFixPark,
|
|
43
50
|
isExhaustedAutoFix,
|
|
@@ -72,7 +79,7 @@ function registerActiveProject(cwd, slug) {
|
|
|
72
79
|
function strandedJob(overrides = {}) {
|
|
73
80
|
return {
|
|
74
81
|
slug: '1218-fo-01-move-scripts-lib-into-src-main-lib',
|
|
75
|
-
cwd:
|
|
82
|
+
cwd: PROJECT_CWD,
|
|
76
83
|
status: 'needs_review',
|
|
77
84
|
exitCode: 0,
|
|
78
85
|
verifierVerdict: 'transcript_errors',
|
|
@@ -137,7 +137,9 @@ test('inode-change rotation invalidates and rebuilds the index — no stale offs
|
|
|
137
137
|
// The chokidar watcher on the live sub picks up the rotation and rebuilds
|
|
138
138
|
// the index via readDelta's inode-change reset — poll (bounded) rather
|
|
139
139
|
// than assume a fixed debounce delay.
|
|
140
|
-
|
|
140
|
+
// 30s ceiling, not 5s: the watcher event is starved under heavy CPU contention; the loop
|
|
141
|
+
// exits the instant the condition holds so idle cost is unchanged.
|
|
142
|
+
const deadline = Date.now() + 30000;
|
|
141
143
|
while (Date.now() < deadline) {
|
|
142
144
|
page = await pageEvents(tabId, 0, 0);
|
|
143
145
|
if (page.totalLines === 1 && page.events[0]?.data === 'new-a') break;
|
|
@@ -146,7 +148,7 @@ test('inode-change rotation invalidates and rebuilds the index — no stale offs
|
|
|
146
148
|
expect(page.totalLines).toBe(1);
|
|
147
149
|
expect(page.events[0].data).toBe('new-a');
|
|
148
150
|
closeTab(tabId);
|
|
149
|
-
},
|
|
151
|
+
}, 40000);
|
|
150
152
|
|
|
151
153
|
test('a partial trailing line is not indexed until it completes', async () => {
|
|
152
154
|
const sessionId = 'sess-partial';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-worktree-epic-path.test.cjs — transcriptPath()/subscribe() resolve
|
|
3
|
+
* an Epic's transcript through epicTranscriptPath (worktree encoding), while a
|
|
4
|
+
* non-Epic tab keeps the plain project encoding. Includes a parity check of the
|
|
5
|
+
* renderer's transcriptExists() (EpicTerminalPane's resume-vs-create decision)
|
|
6
|
+
* against the REAL transcriptPath instead of a constant mock.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-worktree-epic-path.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
import { transcriptExists } from '../../renderer/lib/transcriptExists';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
let tmpHome;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let tr;
|
|
23
|
+
let resolver;
|
|
24
|
+
let encodeCwd;
|
|
25
|
+
let proj;
|
|
26
|
+
const SID = 'b8a5d6f4-0092-4db0-a687-009241f2a8b2';
|
|
27
|
+
const WT = '/tmp/sm-wt-test-epic-worktree-dir';
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
originalHome = process.env.HOME;
|
|
31
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-home-'));
|
|
32
|
+
process.env.HOME = tmpHome;
|
|
33
|
+
tr = require('../transcripts.cjs');
|
|
34
|
+
resolver = require('../lib/epicTranscriptPath.cjs');
|
|
35
|
+
({ encodeCwd } = require('../lib/encodeCwd.cjs'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
process.env.HOME = originalHome;
|
|
40
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
resolver.__resetCacheForTests();
|
|
45
|
+
proj = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-proj-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
fs.rmSync(proj, { recursive: true, force: true });
|
|
50
|
+
fs.rmSync(path.join(tmpHome, '.claude'), { recursive: true, force: true });
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const projectsDir = () => path.join(tmpHome, '.claude', 'projects');
|
|
55
|
+
const plain = (cwd, id = SID) => path.join(projectsDir(), encodeCwd(cwd), `${id}.jsonl`);
|
|
56
|
+
|
|
57
|
+
function writeIndex(sessionId = SID) {
|
|
58
|
+
const dir = path.join(proj, 'session-manager-operations', 'prompt-sessions');
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
const s = { id: 'epic-1', cwd: proj, claudeSessionId: sessionId, status: 'active', worktree: { dir: WT, branch: 'sm-epic/epic-1', baseCwd: proj, status: 'active' } };
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions: { 'epic-1': s }, events: {} }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeTranscript(p, body = '{"type":"user"}\n') {
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
fs.writeFileSync(p, body);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('worktree-present: transcript only under the worktree encoding resolves there', () => {
|
|
70
|
+
writeIndex();
|
|
71
|
+
fs.mkdirSync(WT, { recursive: true });
|
|
72
|
+
try {
|
|
73
|
+
writeTranscript(plain(WT));
|
|
74
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
75
|
+
} finally {
|
|
76
|
+
fs.rmSync(WT, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('worktree-swept: worktree dir gone from disk, transcript still found', () => {
|
|
81
|
+
writeIndex();
|
|
82
|
+
expect(fs.existsSync(WT)).toBe(false);
|
|
83
|
+
writeTranscript(plain(WT));
|
|
84
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('non-Epic tab resolves to exactly the pre-change path, even when an Epic exists', () => {
|
|
88
|
+
writeIndex();
|
|
89
|
+
writeTranscript(plain(WT));
|
|
90
|
+
const rawTab = 'raw-tab-id-123';
|
|
91
|
+
expect(tr.transcriptPath(proj, rawTab)).toBe(path.join(os.homedir(), '.claude', 'projects', encodeCwd(proj), `${rawTab}.jsonl`));
|
|
92
|
+
// no index at all
|
|
93
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-other-'));
|
|
94
|
+
try {
|
|
95
|
+
expect(tr.transcriptPath(other, SID)).toBe(plain(other));
|
|
96
|
+
expect(path.isAbsolute(tr.transcriptPath(other, SID))).toBe(true);
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('zero-byte transcript counts as existing and does not break usageFor', async () => {
|
|
103
|
+
writeIndex();
|
|
104
|
+
writeTranscript(plain(WT), '');
|
|
105
|
+
expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
|
|
106
|
+
const out = await tr.usageFor(proj, [SID]);
|
|
107
|
+
expect(out).toHaveProperty(SID);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('subscribe does NOT mkdir when a transcript exists elsewhere (worktree encoding)', async () => {
|
|
111
|
+
writeIndex();
|
|
112
|
+
writeTranscript(plain(WT));
|
|
113
|
+
const before = fs.readdirSync(projectsDir()).sort();
|
|
114
|
+
const res = await tr.subscribe({ tabId: 'tab-wt', cwd: proj, sessionUuid: SID });
|
|
115
|
+
try {
|
|
116
|
+
expect(res.ok).toBe(true);
|
|
117
|
+
expect(res.path).toBe(plain(WT));
|
|
118
|
+
expect(fs.readdirSync(projectsDir()).sort()).toEqual(before);
|
|
119
|
+
expect(fs.existsSync(path.dirname(plain(proj)))).toBe(false);
|
|
120
|
+
} finally {
|
|
121
|
+
tr.closeTab('tab-wt');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('subscribe of a brand-new Epic watches the spawn-cwd path and creates its dir', async () => {
|
|
126
|
+
writeIndex();
|
|
127
|
+
const res = await tr.subscribe({ tabId: 'tab-new', cwd: proj, sessionUuid: SID });
|
|
128
|
+
try {
|
|
129
|
+
expect(res.ok).toBe(true);
|
|
130
|
+
// no transcript anywhere → resolver's first candidate (spawn cwd), whose dir the CLI will write
|
|
131
|
+
expect(fs.existsSync(path.dirname(res.path))).toBe(true);
|
|
132
|
+
expect(fs.existsSync(res.path)).toBe(false);
|
|
133
|
+
} finally {
|
|
134
|
+
tr.closeTab('tab-new');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('parity: renderer transcriptExists (Terminal resume decision) sees a worktree-only transcript', async () => {
|
|
139
|
+
writeIndex();
|
|
140
|
+
vi.stubGlobal('window', {
|
|
141
|
+
api: {
|
|
142
|
+
transcripts: { pathFor: async (c, s) => tr.transcriptPath(c, s) },
|
|
143
|
+
config: { exists: async (p) => fs.existsSync(p) },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(await transcriptExists(proj, SID)).toBe(false);
|
|
147
|
+
writeTranscript(plain(WT));
|
|
148
|
+
expect(await transcriptExists(proj, SID)).toBe(true);
|
|
149
|
+
// zero-byte still "exists"
|
|
150
|
+
writeTranscript(plain(WT), '');
|
|
151
|
+
expect(await transcriptExists(proj, SID)).toBe(true);
|
|
152
|
+
// raw tab never resolves into the Epic's worktree encoding
|
|
153
|
+
expect(await transcriptExists(proj, 'raw-tab')).toBe(false);
|
|
154
|
+
});
|