kanbango 3.5.0 → 3.6.2
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 +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/CHANGELOG.md +39 -0
- package/agent-playbook.js +8 -1
- package/agents/qa-e2e-tester.md +315 -0
- package/agents/qa-tester.md +182 -0
- package/agents/temida.md +81 -0
- package/bin/kanban.js +140 -2
- package/index.html +66 -5
- package/index.js +2 -0
- package/kanban.js +155 -19
- package/mcp-server.js +4 -1
- package/package.json +1 -1
- package/plan.js +5 -2
- package/tests/fixtures/fake-opencode.js +69 -0
- package/tests/index.js +19 -0
- package/tests/kanban-cli.js +118 -0
- package/tests/kanban.js +104 -0
- package/tests/run.js +4 -0
- package/workflow.js +460 -0
package/workflow.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const kanban = require('./kanban.js');
|
|
5
|
+
|
|
6
|
+
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
7
|
+
const CONFIG_PATH = path.join(BACKLOG, 'kanbango.json');
|
|
8
|
+
const JOBS_DIR = path.join(BACKLOG, '.workflow');
|
|
9
|
+
|
|
10
|
+
/** PIDs spawned by this process — only these may be SIGTERM'd on timeout. */
|
|
11
|
+
const ownedPids = new Set();
|
|
12
|
+
/** In-flight run_ids to prevent double-spawn within this process. */
|
|
13
|
+
const runningLocal = new Set();
|
|
14
|
+
|
|
15
|
+
function workflowError(code, message, hint, details = {}) {
|
|
16
|
+
return kanban.createKanbanError(code, message, hint, details, false, 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isNonEmptyString(value) {
|
|
20
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertWorkflowShape(workflow) {
|
|
24
|
+
if (!workflow || typeof workflow !== 'object' || Array.isArray(workflow)) {
|
|
25
|
+
throw workflowError(
|
|
26
|
+
'CONFIG_INVALID',
|
|
27
|
+
'workflow config must be an object',
|
|
28
|
+
'Set backlog/kanbango.json with a workflow object',
|
|
29
|
+
{ field: 'workflow' }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (typeof workflow.enabled !== 'boolean') {
|
|
33
|
+
throw workflowError(
|
|
34
|
+
'CONFIG_INVALID',
|
|
35
|
+
'workflow.enabled must be a boolean',
|
|
36
|
+
'Use "enabled": true or false',
|
|
37
|
+
{ field: 'workflow.enabled' }
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const stringFields = ['command', 'testing_agent', 'e2e_agent', 'review_agent'];
|
|
41
|
+
for (const field of stringFields) {
|
|
42
|
+
if (workflow[field] !== undefined && !isNonEmptyString(workflow[field])) {
|
|
43
|
+
throw workflowError(
|
|
44
|
+
'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}` }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (workflow.timeout_ms !== undefined) {
|
|
52
|
+
if (!Number.isInteger(workflow.timeout_ms) || workflow.timeout_ms <= 0) {
|
|
53
|
+
throw workflowError(
|
|
54
|
+
'CONFIG_INVALID',
|
|
55
|
+
'workflow.timeout_ms must be a positive integer',
|
|
56
|
+
'Use milliseconds, e.g. 600000',
|
|
57
|
+
{ field: 'workflow.timeout_ms' }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadConfig() {
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT') {
|
|
69
|
+
return { enabled: false, missing: true };
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw workflowError(
|
|
79
|
+
'CONFIG_INVALID',
|
|
80
|
+
'backlog/kanbango.json is not valid JSON',
|
|
81
|
+
'Fix JSON syntax or remove the file to disable workflow',
|
|
82
|
+
{ reason: error.message }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
87
|
+
throw workflowError(
|
|
88
|
+
'CONFIG_INVALID',
|
|
89
|
+
'kanbango.json root must be an object',
|
|
90
|
+
'Use { "workflow": { ... } }',
|
|
91
|
+
{ field: 'root' }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assertWorkflowShape(parsed.workflow);
|
|
96
|
+
const w = parsed.workflow;
|
|
97
|
+
const envRunner = String(process.env.KANBANGO_WORKFLOW_RUNNER || '').trim();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
enabled: w.enabled,
|
|
101
|
+
missing: false,
|
|
102
|
+
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
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function newRunId() {
|
|
111
|
+
return `run-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function agentForStage(config, stage) {
|
|
115
|
+
if (stage === 'review') return config.review_agent;
|
|
116
|
+
return config.testing_agent;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildPrompt(taskId, stage, agent) {
|
|
120
|
+
const lines = [
|
|
121
|
+
`Kanbango workflow stage=${stage} agent=${agent} task_id=${taskId}.`,
|
|
122
|
+
`Read the kanbango task ${taskId} (kanban_read show view=execution).`,
|
|
123
|
+
'Perform your agent role.'
|
|
124
|
+
];
|
|
125
|
+
if (stage === 'testing') {
|
|
126
|
+
lines.push('Write/run tests only. End with a clear verdict line: PASS or FAIL or BLOCKED.');
|
|
127
|
+
}
|
|
128
|
+
if (stage === 'review') {
|
|
129
|
+
lines.push('After your verdict, the LAST line of the response MUST be exactly GATE: PASS or GATE: FAIL.');
|
|
130
|
+
}
|
|
131
|
+
return lines.join(' ');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function truncate(text, max = 2000) {
|
|
135
|
+
const s = String(text || '');
|
|
136
|
+
if (s.length <= max) return s;
|
|
137
|
+
return s.slice(s.length - max);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseTestingVerdict(stdout, stderr, exitCode) {
|
|
141
|
+
const text = `${stdout}\n${stderr}`;
|
|
142
|
+
if (/\bBLOCKED\b/i.test(text)) return 'blocked';
|
|
143
|
+
if (/\bPASS\b/i.test(text) && !/\bFAIL\b/i.test(text)) return 'pass';
|
|
144
|
+
if (/\bFAIL\b/i.test(text)) return 'fail';
|
|
145
|
+
if (exitCode !== 0) return 'fail';
|
|
146
|
+
return 'fail';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseReviewGate(stdout, stderr) {
|
|
150
|
+
const lines = `${stdout}\n${stderr}`
|
|
151
|
+
.split('\n')
|
|
152
|
+
.map((line) => line.trim())
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
const last = lines[lines.length - 1] || '';
|
|
155
|
+
if (last === 'GATE: PASS') return 'pass';
|
|
156
|
+
if (last === 'GATE: FAIL') return 'fail';
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeJob(job) {
|
|
161
|
+
await fs.mkdir(JOBS_DIR, { recursive: true });
|
|
162
|
+
const filePath = path.join(JOBS_DIR, `${job.run_id}.json`);
|
|
163
|
+
await fs.writeFile(filePath, JSON.stringify(job, null, 2) + '\n', 'utf-8');
|
|
164
|
+
return filePath;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function updateJob(runId, patch) {
|
|
168
|
+
const filePath = path.join(JOBS_DIR, `${runId}.json`);
|
|
169
|
+
let job = {};
|
|
170
|
+
try {
|
|
171
|
+
job = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
172
|
+
} catch {
|
|
173
|
+
job = { run_id: runId };
|
|
174
|
+
}
|
|
175
|
+
const next = { ...job, ...patch };
|
|
176
|
+
await fs.writeFile(filePath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function spawnRunner(config, prompt, agent) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const args = ['run', prompt, '--agent', agent, '--format', 'json', '--dir', process.cwd()];
|
|
183
|
+
let child;
|
|
184
|
+
try {
|
|
185
|
+
child = spawn(config.command, args, {
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
188
|
+
windowsHide: true,
|
|
189
|
+
env: process.env
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
resolve({
|
|
193
|
+
stdout: '',
|
|
194
|
+
stderr: error.message || String(error),
|
|
195
|
+
exitCode: 1,
|
|
196
|
+
timedOut: false,
|
|
197
|
+
pid: null,
|
|
198
|
+
argv: [config.command, ...args]
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const pid = child.pid;
|
|
204
|
+
if (pid) ownedPids.add(pid);
|
|
205
|
+
|
|
206
|
+
let stdout = '';
|
|
207
|
+
let stderr = '';
|
|
208
|
+
let settled = false;
|
|
209
|
+
let timedOut = false;
|
|
210
|
+
|
|
211
|
+
const timer = setTimeout(() => {
|
|
212
|
+
timedOut = true;
|
|
213
|
+
if (pid && ownedPids.has(pid)) {
|
|
214
|
+
try {
|
|
215
|
+
child.kill('SIGTERM');
|
|
216
|
+
} catch (killErr) {
|
|
217
|
+
// process may have already exited
|
|
218
|
+
if (killErr && killErr.code !== 'ESRCH') {
|
|
219
|
+
stderr += `\nkill failed: ${killErr.message || killErr}`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}, config.timeout_ms);
|
|
224
|
+
|
|
225
|
+
child.stdout.on('data', (chunk) => {
|
|
226
|
+
stdout += chunk.toString();
|
|
227
|
+
});
|
|
228
|
+
child.stderr.on('data', (chunk) => {
|
|
229
|
+
stderr += chunk.toString();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const finish = (exitCode) => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
if (pid) ownedPids.delete(pid);
|
|
237
|
+
resolve({
|
|
238
|
+
stdout,
|
|
239
|
+
stderr,
|
|
240
|
+
exitCode: exitCode == null ? 1 : exitCode,
|
|
241
|
+
timedOut,
|
|
242
|
+
pid,
|
|
243
|
+
argv: [config.command, ...args]
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
child.on('error', (error) => {
|
|
248
|
+
stderr += error.message || String(error);
|
|
249
|
+
finish(1);
|
|
250
|
+
});
|
|
251
|
+
child.on('close', (code) => finish(code));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function outcomeForResult(stage, result) {
|
|
256
|
+
if (result.timedOut) {
|
|
257
|
+
return {
|
|
258
|
+
verdict: 'blocked',
|
|
259
|
+
nextColumn: null,
|
|
260
|
+
workflowStatus: 'blocked',
|
|
261
|
+
summary: `timeout after runner; card stays in ${stage}`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (stage === 'testing') {
|
|
266
|
+
const verdict = parseTestingVerdict(result.stdout, result.stderr, result.exitCode);
|
|
267
|
+
if (verdict === 'pass') {
|
|
268
|
+
return {
|
|
269
|
+
verdict: 'pass',
|
|
270
|
+
nextColumn: 'review',
|
|
271
|
+
workflowStatus: 'pass',
|
|
272
|
+
summary: truncate(result.stdout || 'PASS', 500)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (verdict === 'blocked') {
|
|
276
|
+
return {
|
|
277
|
+
verdict: 'blocked',
|
|
278
|
+
nextColumn: 'active',
|
|
279
|
+
workflowStatus: 'blocked',
|
|
280
|
+
summary: truncate(result.stdout || result.stderr || 'BLOCKED', 500)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
verdict: 'fail',
|
|
285
|
+
nextColumn: 'active',
|
|
286
|
+
workflowStatus: 'fail',
|
|
287
|
+
summary: truncate(result.stdout || result.stderr || 'FAIL', 500)
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// review
|
|
292
|
+
const gate = parseReviewGate(result.stdout, result.stderr);
|
|
293
|
+
if (gate === 'pass') {
|
|
294
|
+
return {
|
|
295
|
+
verdict: 'pass',
|
|
296
|
+
nextColumn: 'done',
|
|
297
|
+
workflowStatus: 'pass',
|
|
298
|
+
summary: 'GATE: PASS'
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (gate === 'fail') {
|
|
302
|
+
return {
|
|
303
|
+
verdict: 'fail',
|
|
304
|
+
nextColumn: 'active',
|
|
305
|
+
workflowStatus: 'fail',
|
|
306
|
+
summary: 'GATE: FAIL'
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
verdict: 'blocked',
|
|
311
|
+
nextColumn: null,
|
|
312
|
+
workflowStatus: 'blocked',
|
|
313
|
+
summary: truncate(result.stdout || result.stderr || 'missing GATE marker', 500)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function finishRun(taskId, stage, agent, runId, result) {
|
|
318
|
+
const outcome = outcomeForResult(stage, result);
|
|
319
|
+
const finishedAt = new Date().toISOString();
|
|
320
|
+
const evidence = {
|
|
321
|
+
diff: '',
|
|
322
|
+
test_command: (result.argv || []).join(' '),
|
|
323
|
+
stdout: truncate(result.stdout),
|
|
324
|
+
stderr: truncate(result.stderr),
|
|
325
|
+
exit_code: result.exitCode,
|
|
326
|
+
created: finishedAt,
|
|
327
|
+
stage,
|
|
328
|
+
agent,
|
|
329
|
+
verdict: outcome.verdict,
|
|
330
|
+
summary: outcome.summary
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const patch = {
|
|
334
|
+
appendEvidence: evidence,
|
|
335
|
+
workflow: {
|
|
336
|
+
stage,
|
|
337
|
+
status: outcome.workflowStatus,
|
|
338
|
+
agent,
|
|
339
|
+
run_id: runId,
|
|
340
|
+
finished_at: finishedAt
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
if (outcome.nextColumn) {
|
|
344
|
+
patch.column = outcome.nextColumn;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await kanban.updateTask(taskId, patch);
|
|
348
|
+
await updateJob(runId, {
|
|
349
|
+
status: outcome.workflowStatus,
|
|
350
|
+
finished_at: finishedAt,
|
|
351
|
+
verdict: outcome.verdict,
|
|
352
|
+
exit_code: result.exitCode,
|
|
353
|
+
timed_out: Boolean(result.timedOut)
|
|
354
|
+
});
|
|
355
|
+
runningLocal.delete(runId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function startRun(task, stage, config) {
|
|
359
|
+
const agent = agentForStage(config, stage);
|
|
360
|
+
const runId = newRunId();
|
|
361
|
+
const startedAt = new Date().toISOString();
|
|
362
|
+
|
|
363
|
+
runningLocal.add(runId);
|
|
364
|
+
|
|
365
|
+
await kanban.updateTask(task.id, {
|
|
366
|
+
workflow: {
|
|
367
|
+
stage,
|
|
368
|
+
status: 'running',
|
|
369
|
+
agent,
|
|
370
|
+
run_id: runId,
|
|
371
|
+
started_at: startedAt
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await writeJob({
|
|
376
|
+
run_id: runId,
|
|
377
|
+
task_id: task.id,
|
|
378
|
+
stage,
|
|
379
|
+
agent,
|
|
380
|
+
started_at: startedAt,
|
|
381
|
+
status: 'running'
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Spawn outside any board lock (caller already released lock).
|
|
385
|
+
setImmediate(async () => {
|
|
386
|
+
const prompt = buildPrompt(task.id, stage, agent);
|
|
387
|
+
const result = await spawnRunner(config, prompt, agent);
|
|
388
|
+
if (result.pid) {
|
|
389
|
+
await updateJob(runId, { pid: result.pid });
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await finishRun(task.id, stage, agent, runId, result);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error('workflow finish failed:', error && error.message ? error.message : error);
|
|
395
|
+
runningLocal.delete(runId);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return { run_id: runId, agent, stage, status: 'running' };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Called after a successful column change into testing|review.
|
|
404
|
+
* Must not run inside withBoardLock.
|
|
405
|
+
*/
|
|
406
|
+
async function maybeEnqueueOnColumnEnter(task, previousColumn) {
|
|
407
|
+
if (!task || !kanban.WORKFLOW_STAGES.includes(task.column)) {
|
|
408
|
+
return { enqueued: false, reason: 'not_gate_column' };
|
|
409
|
+
}
|
|
410
|
+
if (previousColumn === task.column) {
|
|
411
|
+
return { enqueued: false, reason: 'same_column' };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const config = await loadConfig();
|
|
415
|
+
if (config.missing || !config.enabled) {
|
|
416
|
+
return { enqueued: false, reason: config.missing ? 'no_config' : 'disabled' };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Dedup: already running this stage
|
|
420
|
+
const wf = task.workflow;
|
|
421
|
+
if (wf && wf.status === 'running' && wf.stage === task.column) {
|
|
422
|
+
return { enqueued: false, reason: 'already_running' };
|
|
423
|
+
}
|
|
424
|
+
if (wf && wf.run_id && runningLocal.has(wf.run_id) && wf.stage === task.column) {
|
|
425
|
+
return { enqueued: false, reason: 'already_running_local' };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const fresh = await kanban.getTask(task.id);
|
|
429
|
+
if (fresh.workflow && fresh.workflow.status === 'running' && fresh.workflow.stage === task.column) {
|
|
430
|
+
return { enqueued: false, reason: 'already_running' };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const started = await startRun(fresh, task.column, config);
|
|
434
|
+
return { enqueued: true, ...started };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Test helper: wait until a run reaches a terminal workflow status. */
|
|
438
|
+
async function waitForIdle(taskId, timeoutMs = 15000) {
|
|
439
|
+
const deadline = Date.now() + timeoutMs;
|
|
440
|
+
let sawRunning = false;
|
|
441
|
+
while (Date.now() < deadline) {
|
|
442
|
+
const task = await kanban.getTask(taskId);
|
|
443
|
+
const status = task.workflow && task.workflow.status;
|
|
444
|
+
if (status === 'running') sawRunning = true;
|
|
445
|
+
if (status === 'pass' || status === 'fail' || status === 'blocked') return task;
|
|
446
|
+
if (sawRunning && status !== 'running') return task;
|
|
447
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`waitForIdle timeout for task ${taskId}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
module.exports = {
|
|
453
|
+
loadConfig,
|
|
454
|
+
maybeEnqueueOnColumnEnter,
|
|
455
|
+
parseTestingVerdict,
|
|
456
|
+
parseReviewGate,
|
|
457
|
+
waitForIdle,
|
|
458
|
+
JOBS_DIR,
|
|
459
|
+
CONFIG_PATH
|
|
460
|
+
};
|