kanbango 3.6.2 → 3.8.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/.ai/lessons.jsonl +5 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +1 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +38 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +34 -3
- package/agent-playbook.js +30 -13
- package/bin/kanban.js +62 -7
- package/config-wizard.js +441 -0
- package/index.html +67 -36
- package/kanban.js +429 -20
- package/mcp-server.js +87 -22
- package/package.json +1 -1
- package/plan.js +31 -27
- package/tests/run.js +5 -0
- package/workflow.js +364 -18
package/workflow.js
CHANGED
|
@@ -7,11 +7,25 @@ const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
|
7
7
|
const CONFIG_PATH = path.join(BACKLOG, 'kanbango.json');
|
|
8
8
|
const JOBS_DIR = path.join(BACKLOG, '.workflow');
|
|
9
9
|
|
|
10
|
+
const GATE_COLUMNS = ['testing', 'review'];
|
|
11
|
+
const DEFAULT_LABELS = {
|
|
12
|
+
icebox: 'Icebox',
|
|
13
|
+
planned: 'Planned',
|
|
14
|
+
active: 'NOW',
|
|
15
|
+
testing: 'Testing',
|
|
16
|
+
review: 'Review',
|
|
17
|
+
done: 'Done'
|
|
18
|
+
};
|
|
19
|
+
|
|
10
20
|
/** PIDs spawned by this process — only these may be SIGTERM'd on timeout. */
|
|
11
21
|
const ownedPids = new Set();
|
|
12
22
|
/** In-flight run_ids to prevent double-spawn within this process. */
|
|
13
23
|
const runningLocal = new Set();
|
|
14
24
|
|
|
25
|
+
let cachedConfig = null;
|
|
26
|
+
let cacheCwd = null;
|
|
27
|
+
let ensureTail = Promise.resolve();
|
|
28
|
+
|
|
15
29
|
function workflowError(code, message, hint, details = {}) {
|
|
16
30
|
return kanban.createKanbanError(code, message, hint, details, false, 400);
|
|
17
31
|
}
|
|
@@ -20,6 +34,85 @@ function isNonEmptyString(value) {
|
|
|
20
34
|
return typeof value === 'string' && value.trim().length > 0;
|
|
21
35
|
}
|
|
22
36
|
|
|
37
|
+
function parseAgentField(value, fieldName, defaultName) {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
return { enabled: true, name: defaultName };
|
|
40
|
+
}
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
if (!isNonEmptyString(value)) {
|
|
43
|
+
throw workflowError(
|
|
44
|
+
'CONFIG_INVALID',
|
|
45
|
+
`workflow.${fieldName} must be a non-empty string when set`,
|
|
46
|
+
`Fix workflow.${fieldName} in backlog/kanbango.json`,
|
|
47
|
+
{ field: `workflow.${fieldName}` }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return { enabled: true, name: value.trim() };
|
|
51
|
+
}
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
53
|
+
throw workflowError(
|
|
54
|
+
'CONFIG_INVALID',
|
|
55
|
+
`workflow.${fieldName} must be a string or { enabled, name }`,
|
|
56
|
+
`Use a name string or object with optional enabled/name`,
|
|
57
|
+
{ field: `workflow.${fieldName}` }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
61
|
+
throw workflowError(
|
|
62
|
+
'CONFIG_INVALID',
|
|
63
|
+
`workflow.${fieldName}.enabled must be a boolean`,
|
|
64
|
+
'Use true or false',
|
|
65
|
+
{ field: `workflow.${fieldName}.enabled` }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (value.name !== undefined && !isNonEmptyString(value.name)) {
|
|
69
|
+
throw workflowError(
|
|
70
|
+
'CONFIG_INVALID',
|
|
71
|
+
`workflow.${fieldName}.name must be a non-empty string when set`,
|
|
72
|
+
`Fix workflow.${fieldName}.name in backlog/kanbango.json`,
|
|
73
|
+
{ field: `workflow.${fieldName}.name` }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
enabled: value.enabled === undefined ? true : value.enabled,
|
|
78
|
+
name: isNonEmptyString(value.name) ? value.name.trim() : defaultName
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseGateColumn(value, columnId) {
|
|
83
|
+
if (value === undefined) {
|
|
84
|
+
return { enabled: true, label: DEFAULT_LABELS[columnId] };
|
|
85
|
+
}
|
|
86
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
87
|
+
throw workflowError(
|
|
88
|
+
'CONFIG_INVALID',
|
|
89
|
+
`columns.${columnId} must be an object`,
|
|
90
|
+
`Use { "enabled": true, "label": "${DEFAULT_LABELS[columnId]}" }`,
|
|
91
|
+
{ field: `columns.${columnId}` }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') {
|
|
95
|
+
throw workflowError(
|
|
96
|
+
'CONFIG_INVALID',
|
|
97
|
+
`columns.${columnId}.enabled must be a boolean`,
|
|
98
|
+
'Use true or false',
|
|
99
|
+
{ field: `columns.${columnId}.enabled` }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (value.label !== undefined && !isNonEmptyString(value.label)) {
|
|
103
|
+
throw workflowError(
|
|
104
|
+
'CONFIG_INVALID',
|
|
105
|
+
`columns.${columnId}.label must be a non-empty string when set`,
|
|
106
|
+
`Fix columns.${columnId}.label in backlog/kanbango.json`,
|
|
107
|
+
{ field: `columns.${columnId}.label` }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
enabled: value.enabled === undefined ? true : value.enabled,
|
|
112
|
+
label: isNonEmptyString(value.label) ? value.label.trim() : DEFAULT_LABELS[columnId]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
23
116
|
function assertWorkflowShape(workflow) {
|
|
24
117
|
if (!workflow || typeof workflow !== 'object' || Array.isArray(workflow)) {
|
|
25
118
|
throw workflowError(
|
|
@@ -37,14 +130,23 @@ function assertWorkflowShape(workflow) {
|
|
|
37
130
|
{ field: 'workflow.enabled' }
|
|
38
131
|
);
|
|
39
132
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
133
|
+
if (workflow.command !== undefined && !isNonEmptyString(workflow.command)) {
|
|
134
|
+
throw workflowError(
|
|
135
|
+
'CONFIG_INVALID',
|
|
136
|
+
'workflow.command must be a non-empty string when set',
|
|
137
|
+
'Fix workflow.command in backlog/kanbango.json',
|
|
138
|
+
{ field: 'workflow.command' }
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (workflow.e2e_agent !== undefined && !isNonEmptyString(workflow.e2e_agent)
|
|
142
|
+
&& !(workflow.e2e_agent && typeof workflow.e2e_agent === 'object')) {
|
|
143
|
+
// Keep legacy string validation; object form not used for spawn yet.
|
|
144
|
+
if (typeof workflow.e2e_agent === 'string' && !isNonEmptyString(workflow.e2e_agent)) {
|
|
43
145
|
throw workflowError(
|
|
44
146
|
'CONFIG_INVALID',
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{ field:
|
|
147
|
+
'workflow.e2e_agent must be a non-empty string when set',
|
|
148
|
+
'Fix workflow.e2e_agent in backlog/kanbango.json',
|
|
149
|
+
{ field: 'workflow.e2e_agent' }
|
|
48
150
|
);
|
|
49
151
|
}
|
|
50
152
|
}
|
|
@@ -58,6 +160,132 @@ function assertWorkflowShape(workflow) {
|
|
|
58
160
|
);
|
|
59
161
|
}
|
|
60
162
|
}
|
|
163
|
+
// Validate agent fields early (parseAgentField throws CONFIG_INVALID).
|
|
164
|
+
parseAgentField(workflow.testing_agent, 'testing_agent', 'qa-tester');
|
|
165
|
+
parseAgentField(workflow.review_agent, 'review_agent', 'temida');
|
|
166
|
+
if (workflow.e2e_agent !== undefined && typeof workflow.e2e_agent === 'string') {
|
|
167
|
+
parseAgentField(workflow.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertColumnsShape(columns) {
|
|
172
|
+
if (columns === undefined) return;
|
|
173
|
+
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
|
|
174
|
+
throw workflowError(
|
|
175
|
+
'CONFIG_INVALID',
|
|
176
|
+
'columns config must be an object',
|
|
177
|
+
'Use { "testing": { "enabled": true, "label": "Testing" }, "review": { ... } }',
|
|
178
|
+
{ field: 'columns' }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
for (const key of Object.keys(columns)) {
|
|
182
|
+
if (!GATE_COLUMNS.includes(key)) {
|
|
183
|
+
throw workflowError(
|
|
184
|
+
'CONFIG_INVALID',
|
|
185
|
+
`columns.${key} is not configurable`,
|
|
186
|
+
'Only testing and review may be set under columns',
|
|
187
|
+
{ field: `columns.${key}`, allowed: GATE_COLUMNS }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
parseGateColumn(columns.testing, 'testing');
|
|
192
|
+
parseGateColumn(columns.review, 'review');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildTransitions(testingOn, reviewOn) {
|
|
196
|
+
const afterActive = testingOn ? 'testing' : (reviewOn ? 'review' : 'done');
|
|
197
|
+
const afterTesting = reviewOn ? 'review' : 'done';
|
|
198
|
+
|
|
199
|
+
const transitions = {
|
|
200
|
+
icebox: ['planned'],
|
|
201
|
+
planned: ['active', 'icebox', afterActive],
|
|
202
|
+
active: ['planned', afterActive, 'icebox'],
|
|
203
|
+
done: ['active']
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Dedupe while preserving order
|
|
207
|
+
for (const key of Object.keys(transitions)) {
|
|
208
|
+
transitions[key] = [...new Set(transitions[key])];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (testingOn) {
|
|
212
|
+
transitions.testing = ['active', afterTesting];
|
|
213
|
+
}
|
|
214
|
+
if (reviewOn) {
|
|
215
|
+
transitions.review = ['active', 'done'];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return transitions;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildActiveCols(testingOn, reviewOn) {
|
|
222
|
+
const cols = ['active', 'planned', 'icebox'];
|
|
223
|
+
if (testingOn) cols.push('testing');
|
|
224
|
+
if (reviewOn) cols.push('review');
|
|
225
|
+
cols.push('done');
|
|
226
|
+
return cols;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function planDoneTarget(testingOn, reviewOn) {
|
|
230
|
+
if (testingOn) return 'testing';
|
|
231
|
+
if (reviewOn) return 'review';
|
|
232
|
+
return 'done';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function defaultConfig() {
|
|
236
|
+
const testingOn = true;
|
|
237
|
+
const reviewOn = true;
|
|
238
|
+
return {
|
|
239
|
+
missing: true,
|
|
240
|
+
enabled: false,
|
|
241
|
+
command: 'opencode',
|
|
242
|
+
testing_agent: { enabled: true, name: 'qa-tester' },
|
|
243
|
+
e2e_agent: { enabled: true, name: 'qa-e2e-tester' },
|
|
244
|
+
review_agent: { enabled: true, name: 'temida' },
|
|
245
|
+
timeout_ms: 600000,
|
|
246
|
+
columns: {
|
|
247
|
+
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
248
|
+
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
249
|
+
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
250
|
+
testing: { enabled: true, label: DEFAULT_LABELS.testing },
|
|
251
|
+
review: { enabled: true, label: DEFAULT_LABELS.review },
|
|
252
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
253
|
+
},
|
|
254
|
+
active_cols: buildActiveCols(testingOn, reviewOn),
|
|
255
|
+
workflow_stages: GATE_COLUMNS.slice(),
|
|
256
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
257
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
258
|
+
move_order: ['icebox', 'planned', 'active', 'testing', 'review', 'done'],
|
|
259
|
+
board_columns: [
|
|
260
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
261
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned },
|
|
262
|
+
{ id: 'testing', label: DEFAULT_LABELS.testing },
|
|
263
|
+
{ id: 'review', label: DEFAULT_LABELS.review },
|
|
264
|
+
{ id: 'done', label: DEFAULT_LABELS.done }
|
|
265
|
+
]
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function shapePublicConfig(config) {
|
|
270
|
+
return {
|
|
271
|
+
columns: config.columns,
|
|
272
|
+
active_cols: config.active_cols.slice(),
|
|
273
|
+
workflow_stages: config.workflow_stages.slice(),
|
|
274
|
+
transitions: Object.fromEntries(
|
|
275
|
+
Object.entries(config.transitions).map(([k, v]) => [k, v.slice()])
|
|
276
|
+
),
|
|
277
|
+
plan_done_column: config.plan_done_column,
|
|
278
|
+
move_order: config.move_order.slice(),
|
|
279
|
+
board_columns: config.board_columns.map((c) => ({ id: c.id, label: c.label })),
|
|
280
|
+
workflow: {
|
|
281
|
+
enabled: config.enabled,
|
|
282
|
+
command: config.command,
|
|
283
|
+
testing_agent: { ...config.testing_agent },
|
|
284
|
+
e2e_agent: { ...config.e2e_agent },
|
|
285
|
+
review_agent: { ...config.review_agent },
|
|
286
|
+
timeout_ms: config.timeout_ms
|
|
287
|
+
}
|
|
288
|
+
};
|
|
61
289
|
}
|
|
62
290
|
|
|
63
291
|
async function loadConfig() {
|
|
@@ -66,7 +294,7 @@ async function loadConfig() {
|
|
|
66
294
|
raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
67
295
|
} catch (error) {
|
|
68
296
|
if (error.code === 'ENOENT') {
|
|
69
|
-
return
|
|
297
|
+
return defaultConfig();
|
|
70
298
|
}
|
|
71
299
|
throw error;
|
|
72
300
|
}
|
|
@@ -87,26 +315,121 @@ async function loadConfig() {
|
|
|
87
315
|
throw workflowError(
|
|
88
316
|
'CONFIG_INVALID',
|
|
89
317
|
'kanbango.json root must be an object',
|
|
90
|
-
'Use { "workflow": { ... } }',
|
|
318
|
+
'Use { "workflow": { ... }, "columns": { ... } }',
|
|
91
319
|
{ field: 'root' }
|
|
92
320
|
);
|
|
93
321
|
}
|
|
94
322
|
|
|
95
|
-
|
|
96
|
-
|
|
323
|
+
assertColumnsShape(parsed.columns);
|
|
324
|
+
|
|
325
|
+
// workflow optional when only columns are configured
|
|
326
|
+
let w = parsed.workflow;
|
|
327
|
+
if (w === undefined) {
|
|
328
|
+
w = { enabled: false };
|
|
329
|
+
}
|
|
330
|
+
assertWorkflowShape(w);
|
|
331
|
+
|
|
332
|
+
const testingCol = parseGateColumn(parsed.columns && parsed.columns.testing, 'testing');
|
|
333
|
+
const reviewCol = parseGateColumn(parsed.columns && parsed.columns.review, 'review');
|
|
334
|
+
const testingOn = testingCol.enabled;
|
|
335
|
+
const reviewOn = reviewCol.enabled;
|
|
336
|
+
|
|
337
|
+
const testingAgent = parseAgentField(w.testing_agent, 'testing_agent', 'qa-tester');
|
|
338
|
+
const reviewAgent = parseAgentField(w.review_agent, 'review_agent', 'temida');
|
|
339
|
+
let e2eAgent = { enabled: true, name: 'qa-e2e-tester' };
|
|
340
|
+
if (w.e2e_agent !== undefined) {
|
|
341
|
+
if (typeof w.e2e_agent === 'string') {
|
|
342
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
343
|
+
} else {
|
|
344
|
+
e2eAgent = parseAgentField(w.e2e_agent, 'e2e_agent', 'qa-e2e-tester');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
97
348
|
const envRunner = String(process.env.KANBANGO_WORKFLOW_RUNNER || '').trim();
|
|
349
|
+
const activeCols = buildActiveCols(testingOn, reviewOn);
|
|
350
|
+
const workflowStages = GATE_COLUMNS.filter((col) => (col === 'testing' ? testingOn : reviewOn));
|
|
351
|
+
const moveOrder = ['icebox', 'planned', 'active']
|
|
352
|
+
.concat(testingOn ? ['testing'] : [])
|
|
353
|
+
.concat(reviewOn ? ['review'] : [])
|
|
354
|
+
.concat(['done']);
|
|
355
|
+
|
|
356
|
+
const boardColumns = [
|
|
357
|
+
{ id: 'icebox', label: DEFAULT_LABELS.icebox },
|
|
358
|
+
{ id: 'planned', label: DEFAULT_LABELS.planned }
|
|
359
|
+
];
|
|
360
|
+
if (testingOn) boardColumns.push({ id: 'testing', label: testingCol.label });
|
|
361
|
+
if (reviewOn) boardColumns.push({ id: 'review', label: reviewCol.label });
|
|
362
|
+
boardColumns.push({ id: 'done', label: DEFAULT_LABELS.done });
|
|
98
363
|
|
|
99
364
|
return {
|
|
100
|
-
enabled: w.enabled,
|
|
101
365
|
missing: false,
|
|
366
|
+
enabled: w.enabled,
|
|
102
367
|
command: envRunner || (isNonEmptyString(w.command) ? w.command.trim() : 'opencode'),
|
|
103
|
-
testing_agent:
|
|
104
|
-
e2e_agent:
|
|
105
|
-
review_agent:
|
|
106
|
-
timeout_ms: Number.isInteger(w.timeout_ms) && w.timeout_ms > 0 ? w.timeout_ms : 600000
|
|
368
|
+
testing_agent: testingAgent,
|
|
369
|
+
e2e_agent: e2eAgent,
|
|
370
|
+
review_agent: reviewAgent,
|
|
371
|
+
timeout_ms: Number.isInteger(w.timeout_ms) && w.timeout_ms > 0 ? w.timeout_ms : 600000,
|
|
372
|
+
columns: {
|
|
373
|
+
icebox: { enabled: true, label: DEFAULT_LABELS.icebox },
|
|
374
|
+
planned: { enabled: true, label: DEFAULT_LABELS.planned },
|
|
375
|
+
active: { enabled: true, label: DEFAULT_LABELS.active },
|
|
376
|
+
testing: testingCol,
|
|
377
|
+
review: reviewCol,
|
|
378
|
+
done: { enabled: true, label: DEFAULT_LABELS.done }
|
|
379
|
+
},
|
|
380
|
+
active_cols: activeCols,
|
|
381
|
+
workflow_stages: workflowStages,
|
|
382
|
+
transitions: buildTransitions(testingOn, reviewOn),
|
|
383
|
+
plan_done_column: planDoneTarget(testingOn, reviewOn),
|
|
384
|
+
move_order: moveOrder,
|
|
385
|
+
board_columns: boardColumns
|
|
107
386
|
};
|
|
108
387
|
}
|
|
109
388
|
|
|
389
|
+
async function migrateDisabledGateCards(config) {
|
|
390
|
+
const migrated = [];
|
|
391
|
+
for (const col of GATE_COLUMNS) {
|
|
392
|
+
if (config.columns[col].enabled) continue;
|
|
393
|
+
const tasks = await kanban.listTasksInKnownColumn(col);
|
|
394
|
+
for (const task of tasks) {
|
|
395
|
+
await kanban.relocateTask(task.id, 'planned');
|
|
396
|
+
migrated.push({ id: task.id, from: col, to: 'planned' });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return migrated;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Load board config, apply layout to kanban, migrate cards out of disabled gates.
|
|
404
|
+
* Always re-reads backlog/kanbango.json (file is small) so enable/disable changes apply.
|
|
405
|
+
* Migration is idempotent: second call finds no cards in disabled columns.
|
|
406
|
+
*/
|
|
407
|
+
async function ensureBoardConfig({ force = false } = {}) {
|
|
408
|
+
const run = ensureTail.then(async () => {
|
|
409
|
+
const cwd = process.cwd();
|
|
410
|
+
// force kept for API/tests; always reload from disk so config edits are live.
|
|
411
|
+
void force;
|
|
412
|
+
const config = await loadConfig();
|
|
413
|
+
kanban.applyBoardLayout({
|
|
414
|
+
cols: config.active_cols,
|
|
415
|
+
transitions: config.transitions,
|
|
416
|
+
workflowStages: config.workflow_stages
|
|
417
|
+
});
|
|
418
|
+
await migrateDisabledGateCards(config);
|
|
419
|
+
|
|
420
|
+
cachedConfig = config;
|
|
421
|
+
cacheCwd = cwd;
|
|
422
|
+
return config;
|
|
423
|
+
});
|
|
424
|
+
ensureTail = run.then(() => undefined, () => undefined);
|
|
425
|
+
return run;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function clearConfigCache() {
|
|
429
|
+
cachedConfig = null;
|
|
430
|
+
cacheCwd = null;
|
|
431
|
+
}
|
|
432
|
+
|
|
110
433
|
function newRunId() {
|
|
111
434
|
return `run-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
112
435
|
}
|
|
@@ -265,9 +588,11 @@ function outcomeForResult(stage, result) {
|
|
|
265
588
|
if (stage === 'testing') {
|
|
266
589
|
const verdict = parseTestingVerdict(result.stdout, result.stderr, result.exitCode);
|
|
267
590
|
if (verdict === 'pass') {
|
|
591
|
+
const config = cachedConfig || defaultConfig();
|
|
592
|
+
const nextColumn = config.columns.review.enabled ? 'review' : 'done';
|
|
268
593
|
return {
|
|
269
594
|
verdict: 'pass',
|
|
270
|
-
nextColumn
|
|
595
|
+
nextColumn,
|
|
271
596
|
workflowStatus: 'pass',
|
|
272
597
|
summary: truncate(result.stdout || 'PASS', 500)
|
|
273
598
|
};
|
|
@@ -356,7 +681,8 @@ async function finishRun(taskId, stage, agent, runId, result) {
|
|
|
356
681
|
}
|
|
357
682
|
|
|
358
683
|
async function startRun(task, stage, config) {
|
|
359
|
-
const
|
|
684
|
+
const agentField = agentForStage(config, stage);
|
|
685
|
+
const agent = agentField.name;
|
|
360
686
|
const runId = newRunId();
|
|
361
687
|
const startedAt = new Date().toISOString();
|
|
362
688
|
|
|
@@ -411,11 +737,20 @@ async function maybeEnqueueOnColumnEnter(task, previousColumn) {
|
|
|
411
737
|
return { enqueued: false, reason: 'same_column' };
|
|
412
738
|
}
|
|
413
739
|
|
|
414
|
-
const config = await
|
|
740
|
+
const config = await ensureBoardConfig();
|
|
415
741
|
if (config.missing || !config.enabled) {
|
|
416
742
|
return { enqueued: false, reason: config.missing ? 'no_config' : 'disabled' };
|
|
417
743
|
}
|
|
418
744
|
|
|
745
|
+
if (!config.columns[task.column] || !config.columns[task.column].enabled) {
|
|
746
|
+
return { enqueued: false, reason: 'column_disabled' };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const agentField = agentForStage(config, task.column);
|
|
750
|
+
if (!agentField.enabled) {
|
|
751
|
+
return { enqueued: false, reason: 'agent_disabled' };
|
|
752
|
+
}
|
|
753
|
+
|
|
419
754
|
// Dedup: already running this stage
|
|
420
755
|
const wf = task.workflow;
|
|
421
756
|
if (wf && wf.status === 'running' && wf.stage === task.column) {
|
|
@@ -449,12 +784,23 @@ async function waitForIdle(taskId, timeoutMs = 15000) {
|
|
|
449
784
|
throw new Error(`waitForIdle timeout for task ${taskId}`);
|
|
450
785
|
}
|
|
451
786
|
|
|
787
|
+
async function planDoneColumn() {
|
|
788
|
+
const config = await ensureBoardConfig();
|
|
789
|
+
return config.plan_done_column;
|
|
790
|
+
}
|
|
791
|
+
|
|
452
792
|
module.exports = {
|
|
453
793
|
loadConfig,
|
|
794
|
+
ensureBoardConfig,
|
|
795
|
+
clearConfigCache,
|
|
796
|
+
shapePublicConfig,
|
|
454
797
|
maybeEnqueueOnColumnEnter,
|
|
455
798
|
parseTestingVerdict,
|
|
456
799
|
parseReviewGate,
|
|
457
800
|
waitForIdle,
|
|
801
|
+
planDoneColumn,
|
|
802
|
+
buildTransitions,
|
|
803
|
+
DEFAULT_LABELS,
|
|
458
804
|
JOBS_DIR,
|
|
459
805
|
CONFIG_PATH
|
|
460
806
|
};
|