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/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
- const stringFields = ['command', 'testing_agent', 'e2e_agent', 'review_agent'];
41
- for (const field of stringFields) {
42
- if (workflow[field] !== undefined && !isNonEmptyString(workflow[field])) {
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
- `workflow.${field} must be a non-empty string when set`,
46
- `Fix workflow.${field} in backlog/kanbango.json`,
47
- { field: `workflow.${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 { enabled: false, missing: true };
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
- assertWorkflowShape(parsed.workflow);
96
- const w = parsed.workflow;
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: isNonEmptyString(w.testing_agent) ? w.testing_agent.trim() : 'qa-tester',
104
- e2e_agent: isNonEmptyString(w.e2e_agent) ? w.e2e_agent.trim() : 'qa-e2e-tester',
105
- review_agent: isNonEmptyString(w.review_agent) ? w.review_agent.trim() : 'temida',
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: 'review',
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 agent = agentForStage(config, stage);
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 loadConfig();
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
  };