tlc-claude-code 2.5.0 → 2.6.1
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/.claude/commands/tlc/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +164 -6
- package/.claude/commands/tlc/ci.md +178 -414
- package/.claude/commands/tlc/coverage.md +34 -0
- package/.claude/commands/tlc/deploy.md +19 -6
- package/.claude/commands/tlc/discuss.md +34 -0
- package/.claude/commands/tlc/docs.md +35 -1
- package/.claude/commands/tlc/e2e.md +300 -0
- package/.claude/commands/tlc/edge-cases.md +35 -1
- package/.claude/commands/tlc/init.md +38 -8
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +33 -0
- package/.claude/commands/tlc/quick.md +33 -0
- package/.claude/commands/tlc/release.md +85 -135
- package/.claude/commands/tlc/restore.md +14 -0
- package/.claude/commands/tlc/review.md +76 -1
- package/.claude/commands/tlc/tlc.md +134 -0
- package/.claude/commands/tlc/verify.md +64 -65
- package/.claude/commands/tlc/watchci.md +10 -0
- package/.claude/hooks/tlc-block-tools.sh +13 -0
- package/.claude/hooks/tlc-session-init.sh +29 -0
- package/CODING-STANDARDS.md +35 -10
- package/package.json +1 -1
- package/server/lib/block-tools-hook.js +23 -0
- package/server/lib/e2e/acceptance-parser.js +132 -0
- package/server/lib/e2e/acceptance-parser.test.js +110 -0
- package/server/lib/e2e/framework-detector.js +47 -0
- package/server/lib/e2e/framework-detector.test.js +94 -0
- package/server/lib/e2e/log-assertions.js +107 -0
- package/server/lib/e2e/log-assertions.test.js +68 -0
- package/server/lib/e2e/test-generator.js +159 -0
- package/server/lib/e2e/test-generator.test.js +121 -0
- package/server/lib/e2e/verify-runner.js +191 -0
- package/server/lib/e2e/verify-runner.test.js +167 -0
- package/server/lib/hooks/block-tools-hook.test.js +54 -0
- package/server/lib/orchestration/cli-dispatch.js +16 -1
- package/server/lib/orchestration/cli-dispatch.test.js +94 -8
- package/server/lib/orchestration/completion-checker.js +101 -0
- package/server/lib/orchestration/completion-checker.test.js +177 -0
- package/server/lib/orchestration/result-verifier.js +143 -0
- package/server/lib/orchestration/result-verifier.test.js +291 -0
- package/server/lib/orchestration/session-dispatcher.js +99 -0
- package/server/lib/orchestration/session-dispatcher.test.js +215 -0
- package/server/lib/orchestration/session-status.js +147 -0
- package/server/lib/orchestration/session-status.test.js +130 -0
- package/server/lib/release/agent-runner-updates.js +24 -0
- package/server/lib/release/agent-runner-updates.test.js +22 -0
- package/server/lib/release/changelog-generator.js +142 -0
- package/server/lib/release/changelog-generator.test.js +113 -0
- package/server/lib/release/ci-watcher.js +83 -0
- package/server/lib/release/ci-watcher.test.js +81 -0
- package/server/lib/release/health-checker.js +111 -0
- package/server/lib/release/health-checker.test.js +121 -0
- package/server/lib/release/release-pipeline.js +187 -0
- package/server/lib/release/release-pipeline.test.js +262 -0
- package/server/lib/release/version-bumper.js +183 -0
- package/server/lib/release/version-bumper.test.js +142 -0
- package/server/lib/routing-preamble.integration.test.js +12 -0
- package/server/lib/routing-preamble.js +13 -2
- package/server/lib/routing-preamble.test.js +49 -0
- package/server/lib/scaffolding/ci-detector.js +139 -0
- package/server/lib/scaffolding/ci-detector.test.js +198 -0
- package/server/lib/scaffolding/ci-scaffolder.js +347 -0
- package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
- package/server/lib/scaffolding/deploy-detector.js +135 -0
- package/server/lib/scaffolding/deploy-detector.test.js +106 -0
- package/server/lib/scaffolding/health-scaffold.js +374 -0
- package/server/lib/scaffolding/health-scaffold.test.js +99 -0
- package/server/lib/scaffolding/logger-scaffold.js +196 -0
- package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
- package/server/lib/scaffolding/migration-detector.js +78 -0
- package/server/lib/scaffolding/migration-detector.test.js +127 -0
- package/server/lib/scaffolding/snapshot-manager.js +142 -0
- package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
- package/server/lib/task-router-config.js +50 -20
- package/server/lib/task-router-config.test.js +29 -15
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const { checkCompletions } = require('./completion-checker.js');
|
|
7
|
+
|
|
8
|
+
function makeSession(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
sessionId: 'session-1',
|
|
11
|
+
taskName: 'Task 1',
|
|
12
|
+
startedAt: '2026-03-30T08:00:00.000Z',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeSessionsFile(activeSessionsPath, sessions) {
|
|
18
|
+
fs.mkdirSync(path.dirname(activeSessionsPath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(activeSessionsPath, JSON.stringify(sessions, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('completion-checker', () => {
|
|
23
|
+
const tempDirs = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
while (tempDirs.length > 0) {
|
|
28
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeActiveSessionsPath() {
|
|
33
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'completion-checker-test-'));
|
|
34
|
+
tempDirs.push(tempDir);
|
|
35
|
+
return path.join(tempDir, '.tlc', '.active-sessions.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('returns empty when there are no active sessions', async () => {
|
|
39
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
40
|
+
writeSessionsFile(activeSessionsPath, []);
|
|
41
|
+
|
|
42
|
+
const fetch = vi.fn();
|
|
43
|
+
const result = await checkCompletions({
|
|
44
|
+
activeSessionsPath,
|
|
45
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
46
|
+
fetch,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
completions: [],
|
|
51
|
+
failures: [],
|
|
52
|
+
stillRunning: 0,
|
|
53
|
+
});
|
|
54
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
55
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reports a completed session and removes it from the file', async () => {
|
|
59
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
60
|
+
writeSessionsFile(activeSessionsPath, [makeSession()]);
|
|
61
|
+
|
|
62
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: async () => ({ status: 'completed' }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await checkCompletions({
|
|
68
|
+
activeSessionsPath,
|
|
69
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
70
|
+
fetch,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(fetch).toHaveBeenCalledWith('http://orchestrator.test/sessions/session-1');
|
|
74
|
+
expect(result).toEqual({
|
|
75
|
+
completions: [makeSession()],
|
|
76
|
+
failures: [],
|
|
77
|
+
stillRunning: 0,
|
|
78
|
+
});
|
|
79
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports a failed session with reason', async () => {
|
|
83
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
84
|
+
writeSessionsFile(activeSessionsPath, [makeSession()]);
|
|
85
|
+
|
|
86
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: async () => ({ status: 'failed', reason: 'Tests failed' }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await checkCompletions({
|
|
92
|
+
activeSessionsPath,
|
|
93
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
94
|
+
fetch,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
completions: [],
|
|
99
|
+
failures: [{ ...makeSession(), reason: 'Tests failed' }],
|
|
100
|
+
stillRunning: 0,
|
|
101
|
+
});
|
|
102
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles multiple sessions with mixed running and completed states', async () => {
|
|
106
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
107
|
+
const running = makeSession({ sessionId: 'session-1', taskName: 'Task 1' });
|
|
108
|
+
const completed = makeSession({ sessionId: 'session-2', taskName: 'Task 2' });
|
|
109
|
+
const failed = makeSession({ sessionId: 'session-3', taskName: 'Task 3' });
|
|
110
|
+
writeSessionsFile(activeSessionsPath, [running, completed, failed]);
|
|
111
|
+
|
|
112
|
+
const fetch = vi.fn().mockImplementation(async (url) => {
|
|
113
|
+
if (url.endsWith('/session-1')) {
|
|
114
|
+
return { ok: true, json: async () => ({ status: 'running' }) };
|
|
115
|
+
}
|
|
116
|
+
if (url.endsWith('/session-2')) {
|
|
117
|
+
return { ok: true, json: async () => ({ status: 'completed' }) };
|
|
118
|
+
}
|
|
119
|
+
if (url.endsWith('/session-3')) {
|
|
120
|
+
return { ok: true, json: async () => ({ status: 'failed', reason: 'Rejected' }) };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Unexpected URL: ${url}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await checkCompletions({
|
|
126
|
+
activeSessionsPath,
|
|
127
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
128
|
+
fetch,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result).toEqual({
|
|
132
|
+
completions: [completed],
|
|
133
|
+
failures: [{ ...failed, reason: 'Rejected' }],
|
|
134
|
+
stillRunning: 1,
|
|
135
|
+
});
|
|
136
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([running]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns an error result when the orchestrator is unreachable', async () => {
|
|
140
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
141
|
+
const session = makeSession();
|
|
142
|
+
writeSessionsFile(activeSessionsPath, [session]);
|
|
143
|
+
|
|
144
|
+
const fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
145
|
+
|
|
146
|
+
const result = await checkCompletions({
|
|
147
|
+
activeSessionsPath,
|
|
148
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
149
|
+
fetch,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result).toEqual({
|
|
153
|
+
completions: [],
|
|
154
|
+
failures: [],
|
|
155
|
+
stillRunning: -1,
|
|
156
|
+
error: 'orchestrator unreachable',
|
|
157
|
+
});
|
|
158
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([session]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('treats a missing file as no active sessions', async () => {
|
|
162
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
163
|
+
|
|
164
|
+
const result = await checkCompletions({
|
|
165
|
+
activeSessionsPath,
|
|
166
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
167
|
+
fetch: vi.fn(),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({
|
|
171
|
+
completions: [],
|
|
172
|
+
failures: [],
|
|
173
|
+
stillRunning: 0,
|
|
174
|
+
});
|
|
175
|
+
expect(fs.existsSync(activeSessionsPath)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result Verifier
|
|
3
|
+
* Verifies that a dispatched agent actually produced meaningful work.
|
|
4
|
+
* Codex can exit 0 but produce nothing — this module catches that.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { execSync: defaultExecSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const TEST_FILE_PATTERN = /\.(test|spec)\./;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Take a snapshot of the current git state for later comparison.
|
|
13
|
+
* @param {{ worktreePath: string, exec?: Function }} options
|
|
14
|
+
* @returns {{ gitHash: string, trackedFiles: string[], untrackedFiles: string[] }}
|
|
15
|
+
*/
|
|
16
|
+
function takeSnapshot({ worktreePath, exec = defaultExecSync }) {
|
|
17
|
+
const run = (cmd) => String(exec(cmd, { cwd: worktreePath })).trim();
|
|
18
|
+
|
|
19
|
+
const gitHash = run('git rev-parse HEAD');
|
|
20
|
+
const trackedRaw = run('git ls-files');
|
|
21
|
+
const untrackedRaw = run('git ls-files --others --exclude-standard');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
gitHash,
|
|
25
|
+
trackedFiles: trackedRaw ? trackedRaw.split('\n').filter(Boolean) : [],
|
|
26
|
+
untrackedFiles: untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify that an agent dispatch actually produced meaningful results.
|
|
32
|
+
* @param {{
|
|
33
|
+
* worktreePath: string,
|
|
34
|
+
* expectedFiles: string[],
|
|
35
|
+
* preSnapshot: { gitHash: string, trackedFiles: string[], untrackedFiles: string[] },
|
|
36
|
+
* exec?: Function,
|
|
37
|
+
* runTests?: boolean,
|
|
38
|
+
* testCommand?: string
|
|
39
|
+
* }} options
|
|
40
|
+
* @returns {{
|
|
41
|
+
* success: boolean,
|
|
42
|
+
* filesChanged: string[],
|
|
43
|
+
* testsAdded: string[],
|
|
44
|
+
* testsPassing: boolean | null,
|
|
45
|
+
* summary: string,
|
|
46
|
+
* failureReason: string | null
|
|
47
|
+
* }}
|
|
48
|
+
*/
|
|
49
|
+
function verifyResult({
|
|
50
|
+
worktreePath,
|
|
51
|
+
expectedFiles = [],
|
|
52
|
+
preSnapshot,
|
|
53
|
+
exec = defaultExecSync,
|
|
54
|
+
runTests = false,
|
|
55
|
+
testCommand = 'npm test',
|
|
56
|
+
}) {
|
|
57
|
+
const run = (cmd) => String(exec(cmd, { cwd: worktreePath })).trim();
|
|
58
|
+
|
|
59
|
+
// 1. Detect file changes via git diff
|
|
60
|
+
let filesChanged = [];
|
|
61
|
+
try {
|
|
62
|
+
const diffOutput = run(`git diff --name-only ${preSnapshot.gitHash} HEAD`);
|
|
63
|
+
filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : [];
|
|
64
|
+
|
|
65
|
+
// Also pick up untracked files that appeared after the snapshot
|
|
66
|
+
const untrackedRaw = run('git ls-files --others --exclude-standard');
|
|
67
|
+
const untrackedNow = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
|
|
68
|
+
const newUntracked = untrackedNow.filter(
|
|
69
|
+
(f) => !preSnapshot.untrackedFiles.includes(f)
|
|
70
|
+
);
|
|
71
|
+
filesChanged = [...new Set([...filesChanged, ...newUntracked])];
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// If git diff itself fails (e.g., timeout), report timeout
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
filesChanged: [],
|
|
77
|
+
testsAdded: [],
|
|
78
|
+
testsPassing: null,
|
|
79
|
+
summary: `Verification failed: ${err.message}`,
|
|
80
|
+
failureReason: err.code === 'ETIMEDOUT' ? 'timeout' : 'timeout',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. No changes at all — Codex exited 0 but did nothing
|
|
85
|
+
if (filesChanged.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
filesChanged: [],
|
|
89
|
+
testsAdded: [],
|
|
90
|
+
testsPassing: null,
|
|
91
|
+
summary: 'No files were changed. The agent exited successfully but produced no output.',
|
|
92
|
+
failureReason: 'no_changes',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. Identify test files
|
|
97
|
+
const testsAdded = filesChanged.filter((f) => TEST_FILE_PATTERN.test(f));
|
|
98
|
+
|
|
99
|
+
// 4. If no tests were added/modified, it's a partial result
|
|
100
|
+
if (testsAdded.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
filesChanged,
|
|
104
|
+
testsAdded: [],
|
|
105
|
+
testsPassing: null,
|
|
106
|
+
summary: `${filesChanged.length} file(s) changed but no test files were added or modified.`,
|
|
107
|
+
failureReason: 'no_tests',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 5. Optionally run tests
|
|
112
|
+
let testsPassing = null;
|
|
113
|
+
if (runTests) {
|
|
114
|
+
try {
|
|
115
|
+
run(testCommand);
|
|
116
|
+
testsPassing = true;
|
|
117
|
+
} catch (_) {
|
|
118
|
+
return {
|
|
119
|
+
success: false,
|
|
120
|
+
filesChanged,
|
|
121
|
+
testsAdded,
|
|
122
|
+
testsPassing: false,
|
|
123
|
+
summary: `${filesChanged.length} file(s) changed with ${testsAdded.length} test file(s), but tests are failing.`,
|
|
124
|
+
failureReason: 'tests_failing',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 6. Success
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
filesChanged,
|
|
133
|
+
testsAdded,
|
|
134
|
+
testsPassing,
|
|
135
|
+
summary: `${filesChanged.length} file(s) changed, ${testsAdded.length} test file(s) added/modified.${testsPassing === true ? ' All tests passing.' : ''}`,
|
|
136
|
+
failureReason: null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
takeSnapshot,
|
|
142
|
+
verifyResult,
|
|
143
|
+
};
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { takeSnapshot, verifyResult } = require('./result-verifier.js');
|
|
4
|
+
|
|
5
|
+
describe('result-verifier', () => {
|
|
6
|
+
describe('takeSnapshot', () => {
|
|
7
|
+
it('returns git state with hash, tracked, and untracked files', () => {
|
|
8
|
+
const exec = vi.fn()
|
|
9
|
+
.mockReturnValueOnce('abc1234') // git rev-parse HEAD
|
|
10
|
+
.mockReturnValueOnce('src/a.js\nsrc/b.js\n') // git ls-files
|
|
11
|
+
.mockReturnValueOnce('new-file.js\n'); // git ls-files --others --exclude-standard
|
|
12
|
+
|
|
13
|
+
const snapshot = takeSnapshot({ worktreePath: '/tmp/wt', exec });
|
|
14
|
+
|
|
15
|
+
expect(exec).toHaveBeenCalledWith('git rev-parse HEAD', { cwd: '/tmp/wt' });
|
|
16
|
+
expect(exec).toHaveBeenCalledWith('git ls-files', { cwd: '/tmp/wt' });
|
|
17
|
+
expect(exec).toHaveBeenCalledWith(
|
|
18
|
+
'git ls-files --others --exclude-standard',
|
|
19
|
+
{ cwd: '/tmp/wt' }
|
|
20
|
+
);
|
|
21
|
+
expect(snapshot).toEqual({
|
|
22
|
+
gitHash: 'abc1234',
|
|
23
|
+
trackedFiles: ['src/a.js', 'src/b.js'],
|
|
24
|
+
untrackedFiles: ['new-file.js'],
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles empty repos gracefully', () => {
|
|
29
|
+
const exec = vi.fn()
|
|
30
|
+
.mockReturnValueOnce('def5678')
|
|
31
|
+
.mockReturnValueOnce('')
|
|
32
|
+
.mockReturnValueOnce('');
|
|
33
|
+
|
|
34
|
+
const snapshot = takeSnapshot({ worktreePath: '/tmp/empty', exec });
|
|
35
|
+
|
|
36
|
+
expect(snapshot).toEqual({
|
|
37
|
+
gitHash: 'def5678',
|
|
38
|
+
trackedFiles: [],
|
|
39
|
+
untrackedFiles: [],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('verifyResult', () => {
|
|
45
|
+
function makePreSnapshot(overrides = {}) {
|
|
46
|
+
return {
|
|
47
|
+
gitHash: 'aaa1111',
|
|
48
|
+
trackedFiles: ['src/main.js'],
|
|
49
|
+
untrackedFiles: [],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeExec({ diffOutput = '', testExitCode = 0 } = {}) {
|
|
55
|
+
const fn = vi.fn().mockImplementation((cmd) => {
|
|
56
|
+
if (cmd.startsWith('git diff')) {
|
|
57
|
+
return diffOutput;
|
|
58
|
+
}
|
|
59
|
+
if (cmd.startsWith('git rev-parse')) {
|
|
60
|
+
return 'bbb2222';
|
|
61
|
+
}
|
|
62
|
+
if (cmd.startsWith('git ls-files --others')) {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
if (cmd.startsWith('git ls-files')) {
|
|
66
|
+
return 'src/main.js\n';
|
|
67
|
+
}
|
|
68
|
+
// Test runner
|
|
69
|
+
if (testExitCode !== 0) {
|
|
70
|
+
const err = new Error('tests failed');
|
|
71
|
+
err.status = testExitCode;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
return 'Tests passed';
|
|
75
|
+
});
|
|
76
|
+
return fn;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('detects file changes via git diff', () => {
|
|
80
|
+
const exec = makeExec({
|
|
81
|
+
diffOutput: 'src/main.js\nsrc/utils.js\nsrc/utils.test.js\n',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = verifyResult({
|
|
85
|
+
worktreePath: '/tmp/wt',
|
|
86
|
+
expectedFiles: [],
|
|
87
|
+
preSnapshot: makePreSnapshot(),
|
|
88
|
+
exec,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.filesChanged).toEqual(['src/main.js', 'src/utils.js', 'src/utils.test.js']);
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('detects no changes and returns failure', () => {
|
|
96
|
+
const exec = makeExec({ diffOutput: '' });
|
|
97
|
+
|
|
98
|
+
const result = verifyResult({
|
|
99
|
+
worktreePath: '/tmp/wt',
|
|
100
|
+
expectedFiles: [],
|
|
101
|
+
preSnapshot: makePreSnapshot(),
|
|
102
|
+
exec,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.success).toBe(false);
|
|
106
|
+
expect(result.failureReason).toBe('no_changes');
|
|
107
|
+
expect(result.filesChanged).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('detects test files added', () => {
|
|
111
|
+
const exec = makeExec({
|
|
112
|
+
diffOutput: 'src/widget.js\nsrc/widget.test.js\nsrc/widget.spec.ts\n',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const result = verifyResult({
|
|
116
|
+
worktreePath: '/tmp/wt',
|
|
117
|
+
expectedFiles: [],
|
|
118
|
+
preSnapshot: makePreSnapshot(),
|
|
119
|
+
exec,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.testsAdded).toEqual(['src/widget.test.js', 'src/widget.spec.ts']);
|
|
123
|
+
expect(result.success).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('detects no test files and returns partial failure', () => {
|
|
127
|
+
const exec = makeExec({
|
|
128
|
+
diffOutput: 'src/feature.js\nsrc/helpers.js\n',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = verifyResult({
|
|
132
|
+
worktreePath: '/tmp/wt',
|
|
133
|
+
expectedFiles: [],
|
|
134
|
+
preSnapshot: makePreSnapshot(),
|
|
135
|
+
exec,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result.success).toBe(false);
|
|
139
|
+
expect(result.failureReason).toBe('no_tests');
|
|
140
|
+
expect(result.testsAdded).toEqual([]);
|
|
141
|
+
expect(result.filesChanged).toEqual(['src/feature.js', 'src/helpers.js']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('detects test failures', () => {
|
|
145
|
+
const exec = vi.fn().mockImplementation((cmd) => {
|
|
146
|
+
if (cmd.startsWith('git diff')) {
|
|
147
|
+
return 'src/foo.js\nsrc/foo.test.js\n';
|
|
148
|
+
}
|
|
149
|
+
if (cmd.startsWith('git rev-parse')) {
|
|
150
|
+
return 'bbb2222';
|
|
151
|
+
}
|
|
152
|
+
if (cmd.startsWith('git ls-files --others')) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
if (cmd.startsWith('git ls-files')) {
|
|
156
|
+
return 'src/main.js\nsrc/foo.js\nsrc/foo.test.js\n';
|
|
157
|
+
}
|
|
158
|
+
// test runner command fails
|
|
159
|
+
const err = new Error('test suite failed');
|
|
160
|
+
err.status = 1;
|
|
161
|
+
throw err;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = verifyResult({
|
|
165
|
+
worktreePath: '/tmp/wt',
|
|
166
|
+
expectedFiles: [],
|
|
167
|
+
preSnapshot: makePreSnapshot(),
|
|
168
|
+
exec,
|
|
169
|
+
runTests: true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result.success).toBe(false);
|
|
173
|
+
expect(result.failureReason).toBe('tests_failing');
|
|
174
|
+
expect(result.testsPassing).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns success when files changed and tests pass', () => {
|
|
178
|
+
const exec = vi.fn().mockImplementation((cmd) => {
|
|
179
|
+
if (cmd.startsWith('git diff')) {
|
|
180
|
+
return 'src/api.js\nsrc/api.test.js\n';
|
|
181
|
+
}
|
|
182
|
+
if (cmd.startsWith('git rev-parse')) {
|
|
183
|
+
return 'ccc3333';
|
|
184
|
+
}
|
|
185
|
+
if (cmd.startsWith('git ls-files --others')) {
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
if (cmd.startsWith('git ls-files')) {
|
|
189
|
+
return 'src/main.js\nsrc/api.js\nsrc/api.test.js\n';
|
|
190
|
+
}
|
|
191
|
+
return 'All tests passed';
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = verifyResult({
|
|
195
|
+
worktreePath: '/tmp/wt',
|
|
196
|
+
expectedFiles: [],
|
|
197
|
+
preSnapshot: makePreSnapshot(),
|
|
198
|
+
exec,
|
|
199
|
+
runTests: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
expect(result.testsPassing).toBe(true);
|
|
204
|
+
expect(result.failureReason).toBeNull();
|
|
205
|
+
expect(result.filesChanged).toEqual(['src/api.js', 'src/api.test.js']);
|
|
206
|
+
expect(result.testsAdded).toEqual(['src/api.test.js']);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('handles exec timeout by returning timeout failure', () => {
|
|
210
|
+
const exec = vi.fn().mockImplementation((cmd) => {
|
|
211
|
+
if (cmd.startsWith('git diff')) {
|
|
212
|
+
const err = new Error('Command timed out');
|
|
213
|
+
err.code = 'ETIMEDOUT';
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
if (cmd.startsWith('git rev-parse')) {
|
|
217
|
+
return 'abc123';
|
|
218
|
+
}
|
|
219
|
+
if (cmd.startsWith('git ls-files --others')) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
if (cmd.startsWith('git ls-files')) {
|
|
223
|
+
return '';
|
|
224
|
+
}
|
|
225
|
+
return '';
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const result = verifyResult({
|
|
229
|
+
worktreePath: '/tmp/wt',
|
|
230
|
+
expectedFiles: [],
|
|
231
|
+
preSnapshot: makePreSnapshot(),
|
|
232
|
+
exec,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.success).toBe(false);
|
|
236
|
+
expect(result.failureReason).toBe('timeout');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('returns partial when implementation exists but no tests', () => {
|
|
240
|
+
const exec = makeExec({
|
|
241
|
+
diffOutput: 'src/new-module.js\nlib/helpers.js\n',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = verifyResult({
|
|
245
|
+
worktreePath: '/tmp/wt',
|
|
246
|
+
expectedFiles: [],
|
|
247
|
+
preSnapshot: makePreSnapshot(),
|
|
248
|
+
exec,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result.success).toBe(false);
|
|
252
|
+
expect(result.failureReason).toBe('no_tests');
|
|
253
|
+
expect(result.filesChanged.length).toBe(2);
|
|
254
|
+
expect(result.testsAdded).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('produces a human-readable summary', () => {
|
|
258
|
+
const exec = makeExec({
|
|
259
|
+
diffOutput: 'src/component.js\nsrc/component.test.js\n',
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = verifyResult({
|
|
263
|
+
worktreePath: '/tmp/wt',
|
|
264
|
+
expectedFiles: [],
|
|
265
|
+
preSnapshot: makePreSnapshot(),
|
|
266
|
+
exec,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(typeof result.summary).toBe('string');
|
|
270
|
+
expect(result.summary.length).toBeGreaterThan(0);
|
|
271
|
+
expect(result.summary).toContain('2'); // file count
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('skips test execution when runTests is false or omitted', () => {
|
|
275
|
+
const exec = makeExec({
|
|
276
|
+
diffOutput: 'src/api.js\nsrc/api.test.js\n',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const result = verifyResult({
|
|
280
|
+
worktreePath: '/tmp/wt',
|
|
281
|
+
expectedFiles: [],
|
|
282
|
+
preSnapshot: makePreSnapshot(),
|
|
283
|
+
exec,
|
|
284
|
+
// runTests is not set — defaults to false
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(result.testsPassing).toBeNull();
|
|
288
|
+
expect(result.success).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
async function readActiveSessions(activeSessionsPath) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = await fs.readFile(activeSessionsPath, 'utf8');
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function writeActiveSessions(activeSessionsPath, sessions) {
|
|
15
|
+
await fs.mkdir(path.dirname(activeSessionsPath), { recursive: true });
|
|
16
|
+
await fs.writeFile(activeSessionsPath, JSON.stringify(sessions, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function dispatchToOrchestrator({
|
|
20
|
+
orchestratorUrl,
|
|
21
|
+
project,
|
|
22
|
+
tasks = [],
|
|
23
|
+
phaseBranch,
|
|
24
|
+
activeSessionsPath,
|
|
25
|
+
fetch = globalThis.fetch,
|
|
26
|
+
}) {
|
|
27
|
+
void phaseBranch;
|
|
28
|
+
|
|
29
|
+
if (tasks.length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
dispatched: 0,
|
|
32
|
+
sessions: [],
|
|
33
|
+
errors: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const baseUrl = String(orchestratorUrl).replace(/\/+$/, '');
|
|
38
|
+
const sessions = [];
|
|
39
|
+
const activeSessions = [];
|
|
40
|
+
const errors = [];
|
|
41
|
+
|
|
42
|
+
for (const task of tasks) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`${baseUrl}/sessions`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
project,
|
|
49
|
+
pool: 'local-tmux',
|
|
50
|
+
command: task.provider,
|
|
51
|
+
prompt: task.prompt,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!response || !response.ok) {
|
|
56
|
+
errors.push(`${task.name}: ${response ? response.status : 'request failed'}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = await response.json();
|
|
61
|
+
if (!payload || !payload.id) {
|
|
62
|
+
errors.push(`${task.name}: invalid session response`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
sessions.push({ id: payload.id, taskName: task.name });
|
|
67
|
+
activeSessions.push({
|
|
68
|
+
sessionId: payload.id,
|
|
69
|
+
taskName: task.name,
|
|
70
|
+
startedAt: new Date().toISOString(),
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (sessions.length === 0 && errors.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
dispatched: 0,
|
|
76
|
+
errors: ['orchestrator unreachable'],
|
|
77
|
+
fallback: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
errors.push(`${task.name}: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (activeSessions.length > 0) {
|
|
86
|
+
const existing = await readActiveSessions(activeSessionsPath);
|
|
87
|
+
await writeActiveSessions(activeSessionsPath, [...existing, ...activeSessions]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
dispatched: sessions.length,
|
|
92
|
+
sessions,
|
|
93
|
+
errors,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
dispatchToOrchestrator,
|
|
99
|
+
};
|