tlc-claude-code 2.4.10 → 2.6.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/.claude/commands/tlc/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +203 -27
- 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/issues.md +46 -0
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +76 -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 +80 -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 +9 -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/github/config.js +458 -0
- package/server/lib/github/config.test.js +385 -0
- package/server/lib/github/gh-client.js +303 -0
- package/server/lib/github/gh-client.test.js +499 -0
- package/server/lib/github/gh-projects.js +594 -0
- package/server/lib/github/gh-projects.test.js +583 -0
- package/server/lib/github/index.js +19 -0
- package/server/lib/github/plan-sync.js +456 -0
- package/server/lib/github/plan-sync.test.js +805 -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,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
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
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 { dispatchToOrchestrator } = require('./session-dispatcher.js');
|
|
7
|
+
|
|
8
|
+
function makeTask(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
name: 'Task 1',
|
|
11
|
+
provider: 'codex',
|
|
12
|
+
prompt: 'Implement feature',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('session-dispatcher', () => {
|
|
18
|
+
const tempDirs = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
while (tempDirs.length > 0) {
|
|
23
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function makeActiveSessionsPath() {
|
|
28
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-dispatcher-test-'));
|
|
29
|
+
tempDirs.push(tempDir);
|
|
30
|
+
return path.join(tempDir, '.tlc', '.active-sessions.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('dispatches 3 tasks and creates 3 sessions', async () => {
|
|
34
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
35
|
+
const tasks = [
|
|
36
|
+
makeTask({ name: 'Task 1', provider: 'claude', prompt: 'Prompt 1' }),
|
|
37
|
+
makeTask({ name: 'Task 2', provider: 'codex', prompt: 'Prompt 2' }),
|
|
38
|
+
makeTask({ name: 'Task 3', provider: 'gemini', prompt: 'Prompt 3' }),
|
|
39
|
+
];
|
|
40
|
+
const fetch = vi
|
|
41
|
+
.fn()
|
|
42
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
|
|
43
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-2' }) })
|
|
44
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-3' }) });
|
|
45
|
+
|
|
46
|
+
const result = await dispatchToOrchestrator({
|
|
47
|
+
orchestratorUrl: 'http://orchestrator.test/',
|
|
48
|
+
project: 'tlc',
|
|
49
|
+
tasks,
|
|
50
|
+
phaseBranch: 'phase/test',
|
|
51
|
+
activeSessionsPath,
|
|
52
|
+
fetch,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
56
|
+
expect(fetch).toHaveBeenNthCalledWith(1, 'http://orchestrator.test/sessions', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
project: 'tlc',
|
|
61
|
+
pool: 'local-tmux',
|
|
62
|
+
command: 'claude',
|
|
63
|
+
prompt: 'Prompt 1',
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
expect(fetch).toHaveBeenNthCalledWith(2, 'http://orchestrator.test/sessions', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
project: 'tlc',
|
|
71
|
+
pool: 'local-tmux',
|
|
72
|
+
command: 'codex',
|
|
73
|
+
prompt: 'Prompt 2',
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
expect(fetch).toHaveBeenNthCalledWith(3, 'http://orchestrator.test/sessions', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'content-type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
project: 'tlc',
|
|
81
|
+
pool: 'local-tmux',
|
|
82
|
+
command: 'gemini',
|
|
83
|
+
prompt: 'Prompt 3',
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
expect(result).toEqual({
|
|
87
|
+
dispatched: 3,
|
|
88
|
+
sessions: [
|
|
89
|
+
{ id: 'session-1', taskName: 'Task 1' },
|
|
90
|
+
{ id: 'session-2', taskName: 'Task 2' },
|
|
91
|
+
{ id: 'session-3', taskName: 'Task 3' },
|
|
92
|
+
],
|
|
93
|
+
errors: [],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('writes session IDs to the active sessions file', async () => {
|
|
98
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
99
|
+
const fetch = vi
|
|
100
|
+
.fn()
|
|
101
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
|
|
102
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-2' }) });
|
|
103
|
+
|
|
104
|
+
await dispatchToOrchestrator({
|
|
105
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
106
|
+
project: 'tlc',
|
|
107
|
+
tasks: [
|
|
108
|
+
makeTask({ name: 'Task 1', prompt: 'Prompt 1' }),
|
|
109
|
+
makeTask({ name: 'Task 2', prompt: 'Prompt 2' }),
|
|
110
|
+
],
|
|
111
|
+
phaseBranch: 'phase/test',
|
|
112
|
+
activeSessionsPath,
|
|
113
|
+
fetch,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const written = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'));
|
|
117
|
+
expect(written).toHaveLength(2);
|
|
118
|
+
expect(written[0]).toEqual({
|
|
119
|
+
sessionId: 'session-1',
|
|
120
|
+
taskName: 'Task 1',
|
|
121
|
+
startedAt: expect.any(String),
|
|
122
|
+
});
|
|
123
|
+
expect(written[1]).toEqual({
|
|
124
|
+
sessionId: 'session-2',
|
|
125
|
+
taskName: 'Task 2',
|
|
126
|
+
startedAt: expect.any(String),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns fallback when the orchestrator is unreachable', async () => {
|
|
131
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
132
|
+
const fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
133
|
+
|
|
134
|
+
const result = await dispatchToOrchestrator({
|
|
135
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
136
|
+
project: 'tlc',
|
|
137
|
+
tasks: [makeTask()],
|
|
138
|
+
phaseBranch: 'phase/test',
|
|
139
|
+
activeSessionsPath,
|
|
140
|
+
fetch,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual({
|
|
144
|
+
dispatched: 0,
|
|
145
|
+
errors: ['orchestrator unreachable'],
|
|
146
|
+
fallback: true,
|
|
147
|
+
});
|
|
148
|
+
expect(fs.existsSync(activeSessionsPath)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports errors for partial failure', async () => {
|
|
152
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
153
|
+
const fetch = vi
|
|
154
|
+
.fn()
|
|
155
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-1' }) })
|
|
156
|
+
.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) })
|
|
157
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 'session-3' }) });
|
|
158
|
+
|
|
159
|
+
const result = await dispatchToOrchestrator({
|
|
160
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
161
|
+
project: 'tlc',
|
|
162
|
+
tasks: [
|
|
163
|
+
makeTask({ name: 'Task 1', prompt: 'Prompt 1' }),
|
|
164
|
+
makeTask({ name: 'Task 2', prompt: 'Prompt 2' }),
|
|
165
|
+
makeTask({ name: 'Task 3', prompt: 'Prompt 3' }),
|
|
166
|
+
],
|
|
167
|
+
phaseBranch: 'phase/test',
|
|
168
|
+
activeSessionsPath,
|
|
169
|
+
fetch,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(result).toEqual({
|
|
173
|
+
dispatched: 2,
|
|
174
|
+
sessions: [
|
|
175
|
+
{ id: 'session-1', taskName: 'Task 1' },
|
|
176
|
+
{ id: 'session-3', taskName: 'Task 3' },
|
|
177
|
+
],
|
|
178
|
+
errors: ['Task 2: 500'],
|
|
179
|
+
});
|
|
180
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([
|
|
181
|
+
{
|
|
182
|
+
sessionId: 'session-1',
|
|
183
|
+
taskName: 'Task 1',
|
|
184
|
+
startedAt: expect.any(String),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
sessionId: 'session-3',
|
|
188
|
+
taskName: 'Task 3',
|
|
189
|
+
startedAt: expect.any(String),
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns dispatched 0 for an empty task list', async () => {
|
|
195
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
196
|
+
const fetch = vi.fn();
|
|
197
|
+
|
|
198
|
+
const result = await dispatchToOrchestrator({
|
|
199
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
200
|
+
project: 'tlc',
|
|
201
|
+
tasks: [],
|
|
202
|
+
phaseBranch: 'phase/test',
|
|
203
|
+
activeSessionsPath,
|
|
204
|
+
fetch,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual({
|
|
208
|
+
dispatched: 0,
|
|
209
|
+
sessions: [],
|
|
210
|
+
errors: [],
|
|
211
|
+
});
|
|
212
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
213
|
+
expect(fs.existsSync(activeSessionsPath)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|