@coralai/sps-cli 0.28.7 → 0.30.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/README.md +72 -2
- package/dist/commands/agentCommand.d.ts.map +1 -1
- package/dist/commands/agentCommand.js +163 -7
- package/dist/commands/agentCommand.js.map +1 -1
- package/dist/commands/agentRenderer.d.ts +1 -0
- package/dist/commands/agentRenderer.d.ts.map +1 -1
- package/dist/commands/agentRenderer.js +22 -10
- package/dist/commands/agentRenderer.js.map +1 -1
- package/dist/commands/cardDashboard.d.ts.map +1 -1
- package/dist/commands/cardDashboard.js +1 -0
- package/dist/commands/cardDashboard.js.map +1 -1
- package/dist/commands/monitorTick.d.ts.map +1 -1
- package/dist/commands/monitorTick.js +3 -1
- package/dist/commands/monitorTick.js.map +1 -1
- package/dist/commands/pipelineRunner.d.ts +3 -0
- package/dist/commands/pipelineRunner.d.ts.map +1 -0
- package/dist/commands/pipelineRunner.js +242 -0
- package/dist/commands/pipelineRunner.js.map +1 -0
- package/dist/commands/pipelineTick.d.ts.map +1 -1
- package/dist/commands/pipelineTick.js +4 -2
- package/dist/commands/pipelineTick.js.map +1 -1
- package/dist/commands/qaTick.d.ts.map +1 -1
- package/dist/commands/qaTick.js +4 -2
- package/dist/commands/qaTick.js.map +1 -1
- package/dist/commands/schedulerTick.d.ts.map +1 -1
- package/dist/commands/schedulerTick.js +3 -1
- package/dist/commands/schedulerTick.js.map +1 -1
- package/dist/commands/tick.d.ts.map +1 -1
- package/dist/commands/tick.js +24 -9
- package/dist/commands/tick.js.map +1 -1
- package/dist/commands/workerLaunch.d.ts.map +1 -1
- package/dist/commands/workerLaunch.js +4 -2
- package/dist/commands/workerLaunch.js.map +1 -1
- package/dist/core/pipelineConfig.d.ts +97 -0
- package/dist/core/pipelineConfig.d.ts.map +1 -0
- package/dist/core/pipelineConfig.js +176 -0
- package/dist/core/pipelineConfig.js.map +1 -0
- package/dist/core/projectPipelineAdapter.d.ts +69 -0
- package/dist/core/projectPipelineAdapter.d.ts.map +1 -0
- package/dist/core/projectPipelineAdapter.js +236 -0
- package/dist/core/projectPipelineAdapter.js.map +1 -0
- package/dist/daemon/daemonClient.d.ts.map +1 -1
- package/dist/daemon/daemonClient.js +7 -0
- package/dist/daemon/daemonClient.js.map +1 -1
- package/dist/daemon/sessionDaemon.d.ts.map +1 -1
- package/dist/daemon/sessionDaemon.js +45 -3
- package/dist/daemon/sessionDaemon.js.map +1 -1
- package/dist/engines/CloseoutEngine.d.ts +3 -1
- package/dist/engines/CloseoutEngine.d.ts.map +1 -1
- package/dist/engines/CloseoutEngine.js +9 -7
- package/dist/engines/CloseoutEngine.js.map +1 -1
- package/dist/engines/EventHandler.d.ts +3 -0
- package/dist/engines/EventHandler.d.ts.map +1 -1
- package/dist/engines/EventHandler.js +5 -3
- package/dist/engines/EventHandler.js.map +1 -1
- package/dist/engines/ExecutionEngine.d.ts +3 -1
- package/dist/engines/ExecutionEngine.d.ts.map +1 -1
- package/dist/engines/ExecutionEngine.js +32 -29
- package/dist/engines/ExecutionEngine.js.map +1 -1
- package/dist/engines/MonitorEngine.d.ts +3 -1
- package/dist/engines/MonitorEngine.d.ts.map +1 -1
- package/dist/engines/MonitorEngine.js +16 -13
- package/dist/engines/MonitorEngine.js.map +1 -1
- package/dist/engines/SchedulerEngine.d.ts +3 -1
- package/dist/engines/SchedulerEngine.d.ts.map +1 -1
- package/dist/engines/SchedulerEngine.js +11 -9
- package/dist/engines/SchedulerEngine.js.map +1 -1
- package/dist/engines/engine-pipeline-adapter.test.d.ts +2 -0
- package/dist/engines/engine-pipeline-adapter.test.d.ts.map +1 -0
- package/dist/engines/engine-pipeline-adapter.test.js +677 -0
- package/dist/engines/engine-pipeline-adapter.test.js.map +1 -0
- package/dist/interfaces/ACPClient.d.ts +11 -0
- package/dist/interfaces/ACPClient.d.ts.map +1 -1
- package/dist/interfaces/AgentRuntime.d.ts +4 -1
- package/dist/interfaces/AgentRuntime.d.ts.map +1 -1
- package/dist/main.js +49 -3
- package/dist/main.js.map +1 -1
- package/dist/manager/supervisor.d.ts.map +1 -1
- package/dist/manager/supervisor.js +6 -0
- package/dist/manager/supervisor.js.map +1 -1
- package/dist/manager/worker-manager-impl.d.ts +1 -0
- package/dist/manager/worker-manager-impl.d.ts.map +1 -1
- package/dist/manager/worker-manager-impl.js +37 -5
- package/dist/manager/worker-manager-impl.js.map +1 -1
- package/dist/manager/worker-manager.d.ts +5 -0
- package/dist/manager/worker-manager.d.ts.map +1 -1
- package/dist/models/types.d.ts +2 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/providers/ACPWorkerRuntime.d.ts +3 -1
- package/dist/providers/ACPWorkerRuntime.d.ts.map +1 -1
- package/dist/providers/ACPWorkerRuntime.js +2 -1
- package/dist/providers/ACPWorkerRuntime.js.map +1 -1
- package/dist/providers/PlaneTaskBackend.d.ts.map +1 -1
- package/dist/providers/PlaneTaskBackend.js +31 -9
- package/dist/providers/PlaneTaskBackend.js.map +1 -1
- package/dist/providers/adapters/AcpSdkAdapter.d.ts.map +1 -1
- package/dist/providers/adapters/AcpSdkAdapter.js +23 -6
- package/dist/providers/adapters/AcpSdkAdapter.js.map +1 -1
- package/package.json +2 -1
- package/profiles/critic.md +35 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: verify all engines use ProjectPipelineAdapter states
|
|
3
|
+
* instead of hardcoded 'Planning'/'Backlog'/'Todo'/'Inprogress'/'QA'/'Done'.
|
|
4
|
+
*
|
|
5
|
+
* Uses a custom pipeline YAML with non-default state names to prove
|
|
6
|
+
* configurability end-to-end.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { mkdtempSync, mkdirSync, cpSync, rmSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { ProjectPipelineAdapter } from '../core/projectPipelineAdapter.js';
|
|
13
|
+
import { ExecutionEngine } from './ExecutionEngine.js';
|
|
14
|
+
import { CloseoutEngine } from './CloseoutEngine.js';
|
|
15
|
+
import { MonitorEngine } from './MonitorEngine.js';
|
|
16
|
+
import { SchedulerEngine } from './SchedulerEngine.js';
|
|
17
|
+
import { SPSEventHandler } from './EventHandler.js';
|
|
18
|
+
import { RuntimeStore } from '../core/runtimeStore.js';
|
|
19
|
+
import { writeState, createIdleWorkerSlot } from '../core/state.js';
|
|
20
|
+
// ─── Custom state names (must match __fixtures__/custom-pipeline.yaml) ──
|
|
21
|
+
const CUSTOM = {
|
|
22
|
+
planning: 'Planned',
|
|
23
|
+
backlog: 'Queue',
|
|
24
|
+
ready: 'Ready',
|
|
25
|
+
active: 'Working',
|
|
26
|
+
review: 'Review',
|
|
27
|
+
done: 'Shipped',
|
|
28
|
+
};
|
|
29
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
30
|
+
function makeTempDir() {
|
|
31
|
+
return mkdtempSync(join(tmpdir(), 'sps-engine-adapter-test-'));
|
|
32
|
+
}
|
|
33
|
+
function makeConfig(overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
PROJECT_NAME: 'test-project',
|
|
36
|
+
PROJECT_DIR: '/tmp/test-project',
|
|
37
|
+
GITLAB_PROJECT: 'test/project',
|
|
38
|
+
GITLAB_PROJECT_ID: '1',
|
|
39
|
+
GITLAB_MERGE_BRANCH: 'main',
|
|
40
|
+
PM_TOOL: 'plane',
|
|
41
|
+
MR_MODE: 'create',
|
|
42
|
+
WORKER_TOOL: 'claude',
|
|
43
|
+
WORKER_TRANSPORT: 'proc',
|
|
44
|
+
MAX_CONCURRENT_WORKERS: 2,
|
|
45
|
+
WORKER_RESTART_LIMIT: 3,
|
|
46
|
+
MAX_ACTIONS_PER_TICK: 5,
|
|
47
|
+
INPROGRESS_TIMEOUT_HOURS: 4,
|
|
48
|
+
MONITOR_AUTO_QA: true,
|
|
49
|
+
CONFLICT_DEFAULT: 'parallel',
|
|
50
|
+
TICK_LOCK_TIMEOUT_MINUTES: 10,
|
|
51
|
+
WORKER_LAUNCH_TIMEOUT_S: 60,
|
|
52
|
+
WORKER_IDLE_TIMEOUT_M: 30,
|
|
53
|
+
raw: {},
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function makeDefaultState(maxWorkers) {
|
|
58
|
+
const workers = {};
|
|
59
|
+
for (let i = 0; i < maxWorkers; i++) {
|
|
60
|
+
workers[`worker-${i}`] = createIdleWorkerSlot();
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
version: 1,
|
|
64
|
+
generation: 0,
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
updatedBy: 'test',
|
|
67
|
+
workers,
|
|
68
|
+
activeCards: {},
|
|
69
|
+
leases: {},
|
|
70
|
+
worktreeEvidence: {},
|
|
71
|
+
worktreeCleanup: [],
|
|
72
|
+
sessions: {},
|
|
73
|
+
integrationQueues: {},
|
|
74
|
+
pendingPMActions: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function makeCard(seq, state, overrides = {}) {
|
|
78
|
+
return {
|
|
79
|
+
id: `card-${seq}`,
|
|
80
|
+
seq,
|
|
81
|
+
name: `Test card ${seq}`,
|
|
82
|
+
desc: `Description for card ${seq}`,
|
|
83
|
+
state,
|
|
84
|
+
labels: [],
|
|
85
|
+
meta: {},
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function makeCtx(tempDir, config) {
|
|
90
|
+
const stateFile = join(tempDir, 'state.json');
|
|
91
|
+
const logsDir = join(tempDir, 'logs');
|
|
92
|
+
mkdirSync(logsDir, { recursive: true });
|
|
93
|
+
return {
|
|
94
|
+
projectName: 'test-project',
|
|
95
|
+
config,
|
|
96
|
+
paths: {
|
|
97
|
+
repoDir: tempDir,
|
|
98
|
+
stateFile,
|
|
99
|
+
lockFile: join(tempDir, 'tick.lock'),
|
|
100
|
+
logsDir,
|
|
101
|
+
pipelineOrderFile: join(tempDir, 'pipeline_order.json'),
|
|
102
|
+
},
|
|
103
|
+
pmTool: config.PM_TOOL,
|
|
104
|
+
workerTool: config.WORKER_TOOL,
|
|
105
|
+
maxWorkers: config.MAX_CONCURRENT_WORKERS,
|
|
106
|
+
mrMode: config.MR_MODE,
|
|
107
|
+
mergeBranch: config.GITLAB_MERGE_BRANCH,
|
|
108
|
+
validate: () => ({ ok: true, errors: [] }),
|
|
109
|
+
reload: () => { },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function makeTaskBackend() {
|
|
113
|
+
return {
|
|
114
|
+
listAll: vi.fn().mockResolvedValue([]),
|
|
115
|
+
listByState: vi.fn().mockResolvedValue([]),
|
|
116
|
+
getBySeq: vi.fn().mockResolvedValue(null),
|
|
117
|
+
move: vi.fn().mockResolvedValue(undefined),
|
|
118
|
+
addLabel: vi.fn().mockResolvedValue(undefined),
|
|
119
|
+
removeLabel: vi.fn().mockResolvedValue(undefined),
|
|
120
|
+
claim: vi.fn().mockResolvedValue(undefined),
|
|
121
|
+
releaseClaim: vi.fn().mockResolvedValue(undefined),
|
|
122
|
+
comment: vi.fn().mockResolvedValue(undefined),
|
|
123
|
+
create: vi.fn().mockResolvedValue(null),
|
|
124
|
+
checklistCreate: vi.fn().mockResolvedValue(undefined),
|
|
125
|
+
checklistList: vi.fn().mockResolvedValue([]),
|
|
126
|
+
metaRead: vi.fn().mockResolvedValue({}),
|
|
127
|
+
metaWrite: vi.fn().mockResolvedValue(undefined),
|
|
128
|
+
bootstrap: vi.fn().mockResolvedValue(undefined),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function makeRepoBackend() {
|
|
132
|
+
return {
|
|
133
|
+
ensureCleanBase: vi.fn().mockResolvedValue(undefined),
|
|
134
|
+
ensureBranch: vi.fn().mockResolvedValue(undefined),
|
|
135
|
+
ensureWorktree: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
commit: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
138
|
+
createOrUpdateMr: vi.fn().mockResolvedValue({ url: '', iid: 1 }),
|
|
139
|
+
getMrStatus: vi.fn().mockResolvedValue({ exists: false, state: 'none', merged: false }),
|
|
140
|
+
mergeMr: vi.fn().mockResolvedValue({ merged: true }),
|
|
141
|
+
detectMerged: vi.fn().mockResolvedValue(false),
|
|
142
|
+
rebase: vi.fn().mockResolvedValue({ success: true, conflictFiles: [] }),
|
|
143
|
+
removeWorktree: vi.fn().mockResolvedValue(undefined),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function makeWorkerManager() {
|
|
147
|
+
const response = {
|
|
148
|
+
accepted: true,
|
|
149
|
+
slot: 'worker-0',
|
|
150
|
+
workerId: 'test-project:worker-0:1',
|
|
151
|
+
pid: 99999,
|
|
152
|
+
sessionId: 'test-session',
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
run: vi.fn().mockResolvedValue(response),
|
|
156
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
157
|
+
inspect: vi.fn().mockReturnValue([]),
|
|
158
|
+
onEvent: vi.fn(),
|
|
159
|
+
cleanup: vi.fn(),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function makeSupervisor() {
|
|
163
|
+
return {
|
|
164
|
+
spawn: vi.fn(),
|
|
165
|
+
kill: vi.fn().mockResolvedValue(undefined),
|
|
166
|
+
remove: vi.fn(),
|
|
167
|
+
get: vi.fn().mockReturnValue(null),
|
|
168
|
+
list: vi.fn().mockReturnValue([]),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ─── Setup pipeline adapter from YAML fixture ──────────────────────
|
|
172
|
+
function setupAdapterWithCustomYaml(tempDir, config) {
|
|
173
|
+
// Copy the custom YAML to the temp project dir
|
|
174
|
+
const pipelinesDir = join(tempDir, '.sps', 'pipelines');
|
|
175
|
+
mkdirSync(pipelinesDir, { recursive: true });
|
|
176
|
+
cpSync(join(__dirname, '__fixtures__', 'custom-pipeline.yaml'), join(pipelinesDir, 'custom-pipeline.yaml'));
|
|
177
|
+
return new ProjectPipelineAdapter(config, tempDir);
|
|
178
|
+
}
|
|
179
|
+
// ─── Tests ──────────────────────────────────────────────────────────
|
|
180
|
+
describe('ProjectPipelineAdapter YAML loading', () => {
|
|
181
|
+
let tempDir;
|
|
182
|
+
beforeEach(() => { tempDir = makeTempDir(); });
|
|
183
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
184
|
+
it('loads custom state names from YAML', () => {
|
|
185
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
186
|
+
const adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
187
|
+
expect(adapter.states.planning).toBe(CUSTOM.planning);
|
|
188
|
+
expect(adapter.states.backlog).toBe(CUSTOM.backlog);
|
|
189
|
+
expect(adapter.states.ready).toBe(CUSTOM.ready);
|
|
190
|
+
expect(adapter.states.active).toBe(CUSTOM.active);
|
|
191
|
+
expect(adapter.states.review).toBe(CUSTOM.review);
|
|
192
|
+
expect(adapter.states.done).toBe(CUSTOM.done);
|
|
193
|
+
});
|
|
194
|
+
it('loads custom stages from YAML', () => {
|
|
195
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
196
|
+
const adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
197
|
+
expect(adapter.stages).toHaveLength(2);
|
|
198
|
+
expect(adapter.stages[0].name).toBe('develop');
|
|
199
|
+
expect(adapter.stages[0].triggerState).toBe('Ready');
|
|
200
|
+
expect(adapter.stages[0].activeState).toBe('Working');
|
|
201
|
+
expect(adapter.stages[0].onCompleteState).toBe('Review');
|
|
202
|
+
expect(adapter.stages[1].name).toBe('integrate');
|
|
203
|
+
expect(adapter.stages[1].triggerState).toBe('Review');
|
|
204
|
+
expect(adapter.stages[1].onCompleteState).toBe('Shipped');
|
|
205
|
+
});
|
|
206
|
+
it('activeStates uses custom names', () => {
|
|
207
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
208
|
+
const adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
209
|
+
expect(adapter.activeStates).toContain('Planned');
|
|
210
|
+
expect(adapter.activeStates).toContain('Queue');
|
|
211
|
+
expect(adapter.activeStates).toContain('Ready');
|
|
212
|
+
expect(adapter.activeStates).toContain('Working');
|
|
213
|
+
expect(adapter.activeStates).toContain('Review');
|
|
214
|
+
expect(adapter.activeStates).not.toContain('Done');
|
|
215
|
+
expect(adapter.activeStates).not.toContain('Shipped');
|
|
216
|
+
});
|
|
217
|
+
it('derivePmState maps lease phases to custom states', () => {
|
|
218
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
219
|
+
const adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
220
|
+
expect(adapter.derivePmState('queued')).toBe('Ready');
|
|
221
|
+
expect(adapter.derivePmState('coding')).toBe('Working');
|
|
222
|
+
expect(adapter.derivePmState('merging')).toBe('Review');
|
|
223
|
+
expect(adapter.derivePmState('resolving_conflict')).toBe('Review');
|
|
224
|
+
expect(adapter.derivePmState('closing')).toBe('Review');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe('SchedulerEngine uses adapter states', () => {
|
|
228
|
+
let tempDir;
|
|
229
|
+
let taskBackend;
|
|
230
|
+
let adapter;
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
tempDir = makeTempDir();
|
|
233
|
+
taskBackend = makeTaskBackend();
|
|
234
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
235
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
236
|
+
});
|
|
237
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
238
|
+
it('lists cards by custom planning state', async () => {
|
|
239
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
240
|
+
const ctx = makeCtx(tempDir, config);
|
|
241
|
+
const state = makeDefaultState(2);
|
|
242
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
243
|
+
// Return a card with AI-PIPELINE label
|
|
244
|
+
const card = makeCard('1', CUSTOM.planning, { labels: ['AI-PIPELINE'] });
|
|
245
|
+
taskBackend.listByState.mockResolvedValue([card]);
|
|
246
|
+
const engine = new SchedulerEngine(ctx, taskBackend, adapter);
|
|
247
|
+
await engine.tick({ dryRun: true });
|
|
248
|
+
// Should call listByState with custom 'Planned' state (not 'Planning')
|
|
249
|
+
expect(taskBackend.listByState).toHaveBeenCalledWith('Planned');
|
|
250
|
+
});
|
|
251
|
+
it('moves cards to custom backlog state', async () => {
|
|
252
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
253
|
+
const ctx = makeCtx(tempDir, config);
|
|
254
|
+
const state = makeDefaultState(2);
|
|
255
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
256
|
+
const card = makeCard('1', CUSTOM.planning, { labels: ['AI-PIPELINE'] });
|
|
257
|
+
taskBackend.listByState.mockResolvedValue([card]);
|
|
258
|
+
const engine = new SchedulerEngine(ctx, taskBackend, adapter);
|
|
259
|
+
const result = await engine.tick();
|
|
260
|
+
// Should move to custom 'Queue' state (not 'Backlog')
|
|
261
|
+
expect(taskBackend.move).toHaveBeenCalledWith('1', 'Queue');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
describe('ExecutionEngine uses adapter states', () => {
|
|
265
|
+
let tempDir;
|
|
266
|
+
let taskBackend;
|
|
267
|
+
let repoBackend;
|
|
268
|
+
let workerManager;
|
|
269
|
+
let adapter;
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
tempDir = makeTempDir();
|
|
272
|
+
taskBackend = makeTaskBackend();
|
|
273
|
+
repoBackend = makeRepoBackend();
|
|
274
|
+
workerManager = makeWorkerManager();
|
|
275
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
276
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
277
|
+
});
|
|
278
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
279
|
+
it('lists backlog cards by custom state', async () => {
|
|
280
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
281
|
+
const ctx = makeCtx(tempDir, config);
|
|
282
|
+
const state = makeDefaultState(2);
|
|
283
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
284
|
+
const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
285
|
+
await engine.tick({ dryRun: true });
|
|
286
|
+
// Should query custom states: 'Working' (active), 'Queue' (backlog), 'Ready' (ready)
|
|
287
|
+
const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
|
|
288
|
+
expect(calls).toContain('Working'); // listRuntimeAwareInprogressCards
|
|
289
|
+
expect(calls).toContain('Queue'); // backlog cards
|
|
290
|
+
expect(calls).toContain('Ready'); // todo cards (at least 2 calls)
|
|
291
|
+
});
|
|
292
|
+
it('prepares card: moves to custom ready state', async () => {
|
|
293
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
294
|
+
const ctx = makeCtx(tempDir, config);
|
|
295
|
+
const state = makeDefaultState(2);
|
|
296
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
297
|
+
// Return a backlog card, empty for other states
|
|
298
|
+
const backlogCard = makeCard('1', CUSTOM.backlog);
|
|
299
|
+
taskBackend.listByState.mockImplementation((s) => {
|
|
300
|
+
if (s === CUSTOM.backlog)
|
|
301
|
+
return Promise.resolve([backlogCard]);
|
|
302
|
+
return Promise.resolve([]);
|
|
303
|
+
});
|
|
304
|
+
const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
305
|
+
await engine.tick();
|
|
306
|
+
// prepare phase should move Backlog → Ready (custom states)
|
|
307
|
+
expect(taskBackend.move).toHaveBeenCalledWith('1', 'Ready');
|
|
308
|
+
});
|
|
309
|
+
it('launches card: moves to custom active state', async () => {
|
|
310
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
311
|
+
const ctx = makeCtx(tempDir, config);
|
|
312
|
+
const state = makeDefaultState(2);
|
|
313
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
314
|
+
// Return a Todo/Ready card ready to launch
|
|
315
|
+
const readyCard = makeCard('1', CUSTOM.ready);
|
|
316
|
+
taskBackend.listByState.mockImplementation((s) => {
|
|
317
|
+
if (s === CUSTOM.ready)
|
|
318
|
+
return Promise.resolve([readyCard]);
|
|
319
|
+
return Promise.resolve([]);
|
|
320
|
+
});
|
|
321
|
+
const engine = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
322
|
+
await engine.tick();
|
|
323
|
+
// launch phase should move Ready → Working (custom states)
|
|
324
|
+
expect(taskBackend.move).toHaveBeenCalledWith('1', 'Working');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
describe('CloseoutEngine uses adapter states', () => {
|
|
328
|
+
let tempDir;
|
|
329
|
+
let taskBackend;
|
|
330
|
+
let repoBackend;
|
|
331
|
+
let workerManager;
|
|
332
|
+
let adapter;
|
|
333
|
+
beforeEach(() => {
|
|
334
|
+
tempDir = makeTempDir();
|
|
335
|
+
taskBackend = makeTaskBackend();
|
|
336
|
+
repoBackend = makeRepoBackend();
|
|
337
|
+
workerManager = makeWorkerManager();
|
|
338
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
339
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
340
|
+
});
|
|
341
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
342
|
+
it('lists cards by custom review state', async () => {
|
|
343
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
344
|
+
const ctx = makeCtx(tempDir, config);
|
|
345
|
+
const state = makeDefaultState(2);
|
|
346
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
347
|
+
const engine = new CloseoutEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
348
|
+
await engine.tick();
|
|
349
|
+
// Should call listByState with custom 'Review' (not 'QA')
|
|
350
|
+
expect(taskBackend.listByState).toHaveBeenCalledWith('Review');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
describe('MonitorEngine uses adapter states', () => {
|
|
354
|
+
let tempDir;
|
|
355
|
+
let taskBackend;
|
|
356
|
+
let repoBackend;
|
|
357
|
+
let supervisor;
|
|
358
|
+
let adapter;
|
|
359
|
+
beforeEach(() => {
|
|
360
|
+
tempDir = makeTempDir();
|
|
361
|
+
taskBackend = makeTaskBackend();
|
|
362
|
+
repoBackend = makeRepoBackend();
|
|
363
|
+
supervisor = makeSupervisor();
|
|
364
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
365
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
366
|
+
});
|
|
367
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
368
|
+
it('lists inprogress cards by custom active state', async () => {
|
|
369
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
370
|
+
const ctx = makeCtx(tempDir, config);
|
|
371
|
+
const state = makeDefaultState(2);
|
|
372
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
373
|
+
const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
|
|
374
|
+
await engine.tick();
|
|
375
|
+
// Should call listByState with custom 'Working' (not 'Inprogress')
|
|
376
|
+
const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
|
|
377
|
+
expect(calls).toContain('Working');
|
|
378
|
+
});
|
|
379
|
+
it('checkBlockedCards iterates custom state names', async () => {
|
|
380
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
381
|
+
const ctx = makeCtx(tempDir, config);
|
|
382
|
+
const state = makeDefaultState(2);
|
|
383
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
384
|
+
const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
|
|
385
|
+
await engine.tick();
|
|
386
|
+
// checkBlockedCards should iterate Queue/Ready/Working/Review (not Backlog/Todo/Inprogress/QA)
|
|
387
|
+
const calls = taskBackend.listByState.mock.calls.map(c => c[0]);
|
|
388
|
+
expect(calls).toContain('Queue');
|
|
389
|
+
expect(calls).toContain('Ready');
|
|
390
|
+
expect(calls).toContain('Review');
|
|
391
|
+
});
|
|
392
|
+
it('auto-retry moves to custom ready state', async () => {
|
|
393
|
+
const config = makeConfig({ PROJECT_DIR: tempDir, MONITOR_AUTO_QA: true });
|
|
394
|
+
const ctx = makeCtx(tempDir, config);
|
|
395
|
+
// Set up state with an active card that has a stale slot
|
|
396
|
+
const state = makeDefaultState(2);
|
|
397
|
+
state.workers['worker-0'] = {
|
|
398
|
+
...createIdleWorkerSlot(),
|
|
399
|
+
status: 'active',
|
|
400
|
+
seq: 1,
|
|
401
|
+
branch: 'feature/1-test',
|
|
402
|
+
worktree: '/tmp/wt-1',
|
|
403
|
+
claimedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
404
|
+
lastHeartbeat: null,
|
|
405
|
+
mode: 'print',
|
|
406
|
+
transport: 'proc',
|
|
407
|
+
outputFile: '/tmp/non-existent-output.jsonl',
|
|
408
|
+
};
|
|
409
|
+
state.activeCards['1'] = {
|
|
410
|
+
seq: 1,
|
|
411
|
+
state: 'Working',
|
|
412
|
+
worker: 'worker-0',
|
|
413
|
+
mrUrl: null,
|
|
414
|
+
conflictDomains: [],
|
|
415
|
+
startedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
416
|
+
retryCount: 0,
|
|
417
|
+
};
|
|
418
|
+
state.leases['1'] = {
|
|
419
|
+
seq: 1,
|
|
420
|
+
pmStateObserved: 'Working',
|
|
421
|
+
phase: 'coding',
|
|
422
|
+
slot: 'worker-0',
|
|
423
|
+
branch: 'feature/1-test',
|
|
424
|
+
worktree: '/tmp/wt-1',
|
|
425
|
+
sessionId: null,
|
|
426
|
+
runId: null,
|
|
427
|
+
claimedAt: new Date(Date.now() - 600_000).toISOString(),
|
|
428
|
+
retryCount: 0,
|
|
429
|
+
lastTransitionAt: new Date(Date.now() - 600_000).toISOString(),
|
|
430
|
+
};
|
|
431
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
432
|
+
// Return the card as being in 'Working' state
|
|
433
|
+
const card = makeCard('1', CUSTOM.active);
|
|
434
|
+
taskBackend.listByState.mockImplementation((s) => {
|
|
435
|
+
if (s === CUSTOM.active)
|
|
436
|
+
return Promise.resolve([card]);
|
|
437
|
+
return Promise.resolve([]);
|
|
438
|
+
});
|
|
439
|
+
// getMrStatus returns no MR (so it's a genuine stale runtime)
|
|
440
|
+
repoBackend.getMrStatus.mockResolvedValue({
|
|
441
|
+
exists: false,
|
|
442
|
+
state: 'none',
|
|
443
|
+
merged: false,
|
|
444
|
+
});
|
|
445
|
+
const engine = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
|
|
446
|
+
await engine.tick();
|
|
447
|
+
// With MONITOR_AUTO_QA, stale runtime should be moved to Review (custom, not QA)
|
|
448
|
+
const moveCalls = taskBackend.move.mock.calls;
|
|
449
|
+
if (moveCalls.length > 0) {
|
|
450
|
+
// Should use 'Review' (custom) not 'QA' (default)
|
|
451
|
+
const targets = moveCalls.map(c => c[1]);
|
|
452
|
+
expect(targets.every(t => t !== 'QA')).toBe(true);
|
|
453
|
+
// Check for either Review (auto-qa) or Ready (auto-retry)
|
|
454
|
+
expect(targets.some(t => t === 'Review' || t === 'Ready')).toBe(true);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
describe('SPSEventHandler uses adapter states', () => {
|
|
459
|
+
let tempDir;
|
|
460
|
+
let taskBackend;
|
|
461
|
+
let adapter;
|
|
462
|
+
beforeEach(() => {
|
|
463
|
+
tempDir = makeTempDir();
|
|
464
|
+
taskBackend = makeTaskBackend();
|
|
465
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
466
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
467
|
+
});
|
|
468
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
469
|
+
it('onCompleted moves to custom done state for integration', async () => {
|
|
470
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
471
|
+
const ctx = makeCtx(tempDir, config);
|
|
472
|
+
const state = makeDefaultState(2);
|
|
473
|
+
state.leases['1'] = {
|
|
474
|
+
seq: 1,
|
|
475
|
+
pmStateObserved: 'Review',
|
|
476
|
+
phase: 'merging',
|
|
477
|
+
slot: 'worker-0',
|
|
478
|
+
branch: 'feature/1-test',
|
|
479
|
+
worktree: '/tmp/wt-1',
|
|
480
|
+
sessionId: null,
|
|
481
|
+
runId: null,
|
|
482
|
+
claimedAt: new Date().toISOString(),
|
|
483
|
+
retryCount: 0,
|
|
484
|
+
lastTransitionAt: new Date().toISOString(),
|
|
485
|
+
};
|
|
486
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
487
|
+
const runtimeStore = new RuntimeStore({
|
|
488
|
+
paths: { stateFile: ctx.paths.stateFile },
|
|
489
|
+
maxWorkers: config.MAX_CONCURRENT_WORKERS,
|
|
490
|
+
});
|
|
491
|
+
const handler = new SPSEventHandler({
|
|
492
|
+
taskBackend,
|
|
493
|
+
runtimeStore,
|
|
494
|
+
project: 'test-project',
|
|
495
|
+
pipelineAdapter: adapter,
|
|
496
|
+
});
|
|
497
|
+
// Simulate a completed integration event
|
|
498
|
+
handler.handle({
|
|
499
|
+
type: 'run.completed',
|
|
500
|
+
taskId: '1',
|
|
501
|
+
cardId: '1',
|
|
502
|
+
workerId: 'test-project:worker-0:1',
|
|
503
|
+
timestamp: new Date().toISOString(),
|
|
504
|
+
phase: 'integration',
|
|
505
|
+
slot: 'worker-0',
|
|
506
|
+
project: 'test-project',
|
|
507
|
+
state: 'completed',
|
|
508
|
+
exitCode: 0,
|
|
509
|
+
completionResult: { status: 'completed', reason: 'already_merged' },
|
|
510
|
+
});
|
|
511
|
+
// Allow async handlers to complete
|
|
512
|
+
await new Promise(r => setTimeout(r, 100));
|
|
513
|
+
// Should move to 'Shipped' (custom done), not 'Done'
|
|
514
|
+
expect(taskBackend.move).toHaveBeenCalledWith('1', 'Shipped');
|
|
515
|
+
});
|
|
516
|
+
it('onCompleted moves to custom review state for development', async () => {
|
|
517
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
518
|
+
const ctx = makeCtx(tempDir, config);
|
|
519
|
+
const state = makeDefaultState(2);
|
|
520
|
+
state.leases['1'] = {
|
|
521
|
+
seq: 1,
|
|
522
|
+
pmStateObserved: 'Working',
|
|
523
|
+
phase: 'coding',
|
|
524
|
+
slot: 'worker-0',
|
|
525
|
+
branch: 'feature/1-test',
|
|
526
|
+
worktree: '/tmp/wt-1',
|
|
527
|
+
sessionId: null,
|
|
528
|
+
runId: null,
|
|
529
|
+
claimedAt: new Date().toISOString(),
|
|
530
|
+
retryCount: 0,
|
|
531
|
+
lastTransitionAt: new Date().toISOString(),
|
|
532
|
+
};
|
|
533
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
534
|
+
const runtimeStore = new RuntimeStore({
|
|
535
|
+
paths: { stateFile: ctx.paths.stateFile },
|
|
536
|
+
maxWorkers: config.MAX_CONCURRENT_WORKERS,
|
|
537
|
+
});
|
|
538
|
+
const handler = new SPSEventHandler({
|
|
539
|
+
taskBackend,
|
|
540
|
+
runtimeStore,
|
|
541
|
+
project: 'test-project',
|
|
542
|
+
pipelineAdapter: adapter,
|
|
543
|
+
});
|
|
544
|
+
// Simulate a completed development event
|
|
545
|
+
handler.handle({
|
|
546
|
+
type: 'run.completed',
|
|
547
|
+
taskId: '1',
|
|
548
|
+
cardId: '1',
|
|
549
|
+
workerId: 'test-project:worker-0:1',
|
|
550
|
+
timestamp: new Date().toISOString(),
|
|
551
|
+
phase: 'development',
|
|
552
|
+
slot: 'worker-0',
|
|
553
|
+
project: 'test-project',
|
|
554
|
+
state: 'completed',
|
|
555
|
+
exitCode: 0,
|
|
556
|
+
completionResult: { status: 'completed', reason: 'branch_pushed' },
|
|
557
|
+
});
|
|
558
|
+
// Allow async handlers to complete
|
|
559
|
+
await new Promise(r => setTimeout(r, 100));
|
|
560
|
+
// Should move to 'Review' (custom review), not 'QA'
|
|
561
|
+
expect(taskBackend.move).toHaveBeenCalledWith('1', 'Review');
|
|
562
|
+
});
|
|
563
|
+
it('releaseSlot sets custom review state in pmStateObserved', async () => {
|
|
564
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
565
|
+
const ctx = makeCtx(tempDir, config);
|
|
566
|
+
const state = makeDefaultState(2);
|
|
567
|
+
state.workers['worker-0'] = {
|
|
568
|
+
...createIdleWorkerSlot(),
|
|
569
|
+
status: 'active',
|
|
570
|
+
seq: 1,
|
|
571
|
+
branch: 'feature/1-test',
|
|
572
|
+
worktree: '/tmp/wt-1',
|
|
573
|
+
claimedAt: new Date().toISOString(),
|
|
574
|
+
lastHeartbeat: null,
|
|
575
|
+
};
|
|
576
|
+
state.activeCards['1'] = {
|
|
577
|
+
seq: 1,
|
|
578
|
+
state: 'Working',
|
|
579
|
+
worker: 'worker-0',
|
|
580
|
+
mrUrl: null,
|
|
581
|
+
conflictDomains: [],
|
|
582
|
+
startedAt: new Date().toISOString(),
|
|
583
|
+
};
|
|
584
|
+
state.leases['1'] = {
|
|
585
|
+
seq: 1,
|
|
586
|
+
pmStateObserved: 'Working',
|
|
587
|
+
phase: 'coding',
|
|
588
|
+
slot: 'worker-0',
|
|
589
|
+
branch: 'feature/1-test',
|
|
590
|
+
worktree: '/tmp/wt-1',
|
|
591
|
+
sessionId: null,
|
|
592
|
+
runId: null,
|
|
593
|
+
claimedAt: new Date().toISOString(),
|
|
594
|
+
retryCount: 0,
|
|
595
|
+
lastTransitionAt: new Date().toISOString(),
|
|
596
|
+
};
|
|
597
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
598
|
+
const runtimeStore = new RuntimeStore({
|
|
599
|
+
paths: { stateFile: ctx.paths.stateFile },
|
|
600
|
+
maxWorkers: config.MAX_CONCURRENT_WORKERS,
|
|
601
|
+
});
|
|
602
|
+
const handler = new SPSEventHandler({
|
|
603
|
+
taskBackend,
|
|
604
|
+
runtimeStore,
|
|
605
|
+
project: 'test-project',
|
|
606
|
+
pipelineAdapter: adapter,
|
|
607
|
+
});
|
|
608
|
+
// Simulate development completion
|
|
609
|
+
handler.handle({
|
|
610
|
+
type: 'run.completed',
|
|
611
|
+
taskId: '1',
|
|
612
|
+
cardId: '1',
|
|
613
|
+
workerId: 'test-project:worker-0:1',
|
|
614
|
+
timestamp: new Date().toISOString(),
|
|
615
|
+
phase: 'development',
|
|
616
|
+
slot: 'worker-0',
|
|
617
|
+
project: 'test-project',
|
|
618
|
+
state: 'completed',
|
|
619
|
+
exitCode: 0,
|
|
620
|
+
completionResult: { status: 'completed', reason: 'branch_pushed' },
|
|
621
|
+
});
|
|
622
|
+
await new Promise(r => setTimeout(r, 100));
|
|
623
|
+
// Verify runtime state has custom review state
|
|
624
|
+
const freshState = runtimeStore.readState();
|
|
625
|
+
if (freshState.leases['1']) {
|
|
626
|
+
expect(freshState.leases['1'].pmStateObserved).toBe('Review');
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
describe('Full pipeline flow with custom states (dry-run)', () => {
|
|
631
|
+
let tempDir;
|
|
632
|
+
let taskBackend;
|
|
633
|
+
let repoBackend;
|
|
634
|
+
let workerManager;
|
|
635
|
+
let adapter;
|
|
636
|
+
beforeEach(() => {
|
|
637
|
+
tempDir = makeTempDir();
|
|
638
|
+
taskBackend = makeTaskBackend();
|
|
639
|
+
repoBackend = makeRepoBackend();
|
|
640
|
+
workerManager = makeWorkerManager();
|
|
641
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
642
|
+
adapter = setupAdapterWithCustomYaml(tempDir, config);
|
|
643
|
+
});
|
|
644
|
+
afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); });
|
|
645
|
+
it('never uses default state names when custom YAML is loaded', async () => {
|
|
646
|
+
const config = makeConfig({ PROJECT_DIR: tempDir });
|
|
647
|
+
const ctx = makeCtx(tempDir, config);
|
|
648
|
+
const state = makeDefaultState(2);
|
|
649
|
+
writeState(ctx.paths.stateFile, state, 'test');
|
|
650
|
+
const DEFAULTS = ['Planning', 'Backlog', 'Todo', 'Inprogress', 'QA', 'Done'];
|
|
651
|
+
// Run all engines in sequence
|
|
652
|
+
const scheduler = new SchedulerEngine(ctx, taskBackend, adapter);
|
|
653
|
+
await scheduler.tick();
|
|
654
|
+
const execution = new ExecutionEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
655
|
+
await execution.tick();
|
|
656
|
+
const closeout = new CloseoutEngine(ctx, taskBackend, repoBackend, workerManager, adapter);
|
|
657
|
+
await closeout.tick();
|
|
658
|
+
const supervisor = makeSupervisor();
|
|
659
|
+
const monitor = new MonitorEngine(ctx, taskBackend, repoBackend, undefined, supervisor, adapter);
|
|
660
|
+
await monitor.tick();
|
|
661
|
+
// Collect all calls to listByState and move
|
|
662
|
+
const listCalls = taskBackend.listByState.mock.calls.map(c => c[0]);
|
|
663
|
+
const moveCalls = taskBackend.move.mock.calls.map(c => c[1]);
|
|
664
|
+
const allStateCalls = [...listCalls, ...moveCalls];
|
|
665
|
+
// No call should use any default state name
|
|
666
|
+
for (const call of allStateCalls) {
|
|
667
|
+
expect(DEFAULTS).not.toContain(call);
|
|
668
|
+
}
|
|
669
|
+
// Should use custom state names instead
|
|
670
|
+
expect(listCalls).toContain('Planned'); // Scheduler
|
|
671
|
+
expect(listCalls).toContain('Queue'); // Execution (backlog)
|
|
672
|
+
expect(listCalls).toContain('Ready'); // Execution (todo)
|
|
673
|
+
expect(listCalls).toContain('Working'); // Execution (inprogress) + Monitor
|
|
674
|
+
expect(listCalls).toContain('Review'); // Closeout + Monitor blocked check
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
//# sourceMappingURL=engine-pipeline-adapter.test.js.map
|