bullswarm 0.3.0 → 0.4.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/package.json +1 -1
- package/src/cli.js +5 -2
- package/src/lib/route.js +13 -2
- package/src/lib/state.js +1 -1
- package/src/workflow/cli.js +172 -0
- package/src/workflow/runner.js +195 -0
- package/src/workflow/runtime.js +258 -0
- package/src/workflow/template.js +76 -0
- package/src/workflow/tui.js +236 -0
- package/src/workflow/validate.js +205 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getAllMeterReadings } from './meters/registry.js';
|
|
|
14
14
|
import { judgeContent } from './lib/verify.js';
|
|
15
15
|
import { getVersion } from './lib/version.js';
|
|
16
16
|
import { release } from './lib/release.js';
|
|
17
|
+
import { cmdWorkflow } from './workflow/cli.js';
|
|
17
18
|
|
|
18
19
|
export const BULLSWARM_DIR = join(homedir(), '.bullswarm');
|
|
19
20
|
|
|
@@ -99,7 +100,7 @@ async function cmdRun(opts) {
|
|
|
99
100
|
|
|
100
101
|
const route = pickPool(lane, eligiblePools, {
|
|
101
102
|
callerEligible: opts['no-caller'] !== true,
|
|
102
|
-
callerName: state.config.callerName ?? 'claude',
|
|
103
|
+
callerName: state.config.callerName ?? 'claude-code',
|
|
103
104
|
now,
|
|
104
105
|
});
|
|
105
106
|
if (gated.length && route.pick) {
|
|
@@ -360,6 +361,8 @@ export async function main(argv) {
|
|
|
360
361
|
return cmdPools(opts);
|
|
361
362
|
case 'doctor':
|
|
362
363
|
return cmdDoctor(opts);
|
|
364
|
+
case 'workflow':
|
|
365
|
+
return cmdWorkflow(rest);
|
|
363
366
|
case 'version':
|
|
364
367
|
console.log(getVersion());
|
|
365
368
|
return 0;
|
|
@@ -367,7 +370,7 @@ export async function main(argv) {
|
|
|
367
370
|
return cmdRelease(opts);
|
|
368
371
|
default:
|
|
369
372
|
console.error(
|
|
370
|
-
`unknown verb "${verb}". try: setup | run | health | pools | doctor | version | release`,
|
|
373
|
+
`unknown verb "${verb}". try: setup | run | health | pools | doctor | workflow | version | release`,
|
|
371
374
|
);
|
|
372
375
|
return 2;
|
|
373
376
|
}
|
package/src/lib/route.js
CHANGED
|
@@ -84,7 +84,7 @@ export function isExhausted(pool) {
|
|
|
84
84
|
export function pickPool(lane, pools, opts = {}) {
|
|
85
85
|
const {
|
|
86
86
|
callerEligible = true,
|
|
87
|
-
callerName = 'claude',
|
|
87
|
+
callerName = 'claude-code',
|
|
88
88
|
now = Date.now(),
|
|
89
89
|
} = opts;
|
|
90
90
|
|
|
@@ -158,7 +158,18 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
158
158
|
|
|
159
159
|
// R5: the caller wins its lane only when no eligible delegate remains —
|
|
160
160
|
// or when the caller's own pool entry genuinely wins on merit. Dispatching
|
|
161
|
-
// the caller to itself as a subprocess is always wrong
|
|
161
|
+
// the caller to itself as a subprocess is always wrong — BUT only when a
|
|
162
|
+
// caller session actually exists. In workflow/batch contexts every pool is
|
|
163
|
+
// just a worker; opts.callerSession (default: callerEligible) controls it.
|
|
164
|
+
const hasCallerSession = opts.callerSession ?? callerEligible;
|
|
165
|
+
if (!hasCallerSession) {
|
|
166
|
+
return {
|
|
167
|
+
pick: { pool: winnerEntry.pool.name, connector: winnerEntry.pool },
|
|
168
|
+
keepOnClaude: false,
|
|
169
|
+
why: `most-behind capable pool (surplus ${Math.round(winnerEntry.pace * 10) / 10})`,
|
|
170
|
+
candidates,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
162
173
|
const isCaller =
|
|
163
174
|
winnerEntry.pool.isCaller === true ||
|
|
164
175
|
winnerEntry.pool.connector?.flags?.isCaller === true ||
|
package/src/lib/state.js
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// bullswarm workflow CLI — run | validate | list.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { loadWorkflow, runWorkflow } from './runner.js';
|
|
7
|
+
import { validateWorkflow, WorkflowValidationError } from './validate.js';
|
|
8
|
+
import { buildPoolsLive } from '../lib/config.js';
|
|
9
|
+
import { getAllMeterReadings } from '../meters/registry.js';
|
|
10
|
+
import { WorkflowTui } from './tui.js';
|
|
11
|
+
|
|
12
|
+
export const BULLSWARM_DIR = join(homedir(), '.bullswarm');
|
|
13
|
+
|
|
14
|
+
function workflowDirs() {
|
|
15
|
+
return [
|
|
16
|
+
join(process.cwd(), 'workflows'),
|
|
17
|
+
join(BULLSWARM_DIR, 'workflows'),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function discover() {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const dir of workflowDirs()) {
|
|
24
|
+
if (!existsSync(dir)) continue;
|
|
25
|
+
for (const f of readdirSync(dir).sort()) {
|
|
26
|
+
const p = join(dir, f);
|
|
27
|
+
if (!statSync(p).isFile() || !f.endsWith('.json')) continue;
|
|
28
|
+
try {
|
|
29
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'));
|
|
30
|
+
found.push({ name: doc.name ?? f.replace(/\.json$/, ''), path: p, valid: null });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
found.push({ name: f.replace(/\.json$/, ''), path: p, valid: `parse error: ${err.message}` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function cmdWorkflow(args) {
|
|
40
|
+
const [sub, ...rest] = args;
|
|
41
|
+
const opts = parseFlags(rest);
|
|
42
|
+
|
|
43
|
+
switch (sub) {
|
|
44
|
+
case 'run':
|
|
45
|
+
return wfRun(opts);
|
|
46
|
+
case 'validate':
|
|
47
|
+
return wfValidate(opts);
|
|
48
|
+
case 'list':
|
|
49
|
+
return wfList(opts);
|
|
50
|
+
default:
|
|
51
|
+
console.error('usage: bullswarm workflow <run|validate|list> [file|name] [--input k=v] [--resume id] [--json] [--quiet]');
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseFlags(argv) {
|
|
57
|
+
const out = { inputs: {}, rest: [] };
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === '--json') out.json = true;
|
|
61
|
+
else if (a === '--quiet') out.quiet = true;
|
|
62
|
+
else if (a === '--resume') out.resume = argv[++i];
|
|
63
|
+
else if (a === '--input') {
|
|
64
|
+
const kv = argv[++i] ?? '';
|
|
65
|
+
const eq = kv.indexOf('=');
|
|
66
|
+
if (eq > 0) out.inputs[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
67
|
+
} else if (a.startsWith('--')) {
|
|
68
|
+
out[a.slice(2)] = true;
|
|
69
|
+
} else out.rest.push(a);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function wfValidate(opts) {
|
|
75
|
+
const target = opts.rest[0];
|
|
76
|
+
if (!target) {
|
|
77
|
+
console.error('usage: bullswarm workflow validate <file-or-name>');
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
let doc, path;
|
|
81
|
+
try {
|
|
82
|
+
({ doc, path } = loadWorkflow(target, workflowDirs()));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(`✗ ${err.message}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const poolsInfo = await livePoolNames();
|
|
88
|
+
try {
|
|
89
|
+
const r = validateWorkflow(doc, { poolNames: poolsInfo.names });
|
|
90
|
+
console.log(`✓ ${path} is valid (${doc.phases?.length ?? 0} phases)`);
|
|
91
|
+
for (const w of r.warnings) console.log(` ⚠ ${w}`);
|
|
92
|
+
return 0;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof WorkflowValidationError) {
|
|
95
|
+
console.error(`✗ ${path}:`);
|
|
96
|
+
for (const issue of err.issues) console.error(` - ${issue}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function livePoolNames() {
|
|
104
|
+
try {
|
|
105
|
+
const { pools } = await buildPoolsLive(BULLSWARM_DIR, Date.now(), {
|
|
106
|
+
getReadings: getAllMeterReadings,
|
|
107
|
+
});
|
|
108
|
+
return { names: pools.map((p) => p.name), pools };
|
|
109
|
+
} catch {
|
|
110
|
+
return { names: [], pools: [] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function wfRun(opts) {
|
|
115
|
+
const target = opts.rest[0];
|
|
116
|
+
if (!target) {
|
|
117
|
+
console.error('usage: bullswarm workflow run <file-or-name> [--input k=v] [--resume runId]');
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let doc, path;
|
|
122
|
+
try {
|
|
123
|
+
({ doc, path } = loadWorkflow(target, workflowDirs()));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`✗ ${err.message}`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { names, pools } = await livePoolNames();
|
|
130
|
+
try {
|
|
131
|
+
validateWorkflow(doc, { poolNames: names });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof WorkflowValidationError) {
|
|
134
|
+
console.error(`✗ workflow invalid (nothing ran):`);
|
|
135
|
+
for (const issue of err.issues) console.error(` - ${issue}`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const tui = new WorkflowTui({ quiet: opts.quiet, json: opts.json });
|
|
142
|
+
const result = await runWorkflow({
|
|
143
|
+
bullswarmDir: BULLSWARM_DIR,
|
|
144
|
+
doc,
|
|
145
|
+
pools,
|
|
146
|
+
inputs: opts.inputs,
|
|
147
|
+
resumeRunId: opts.resume,
|
|
148
|
+
onEvent: (ev) => tui.handle(ev),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (opts.json) {
|
|
152
|
+
console.log(JSON.stringify(result.report, null, 2));
|
|
153
|
+
}
|
|
154
|
+
return result.report.status === 'completed' ? 0 : 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function wfList(opts) {
|
|
158
|
+
const found = discover();
|
|
159
|
+
if (opts.json) {
|
|
160
|
+
console.log(JSON.stringify({ workflows: found }, null, 2));
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
if (found.length === 0) {
|
|
164
|
+
console.log(`no workflows found in: ${workflowDirs().join(', ')}`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
for (const w of found) {
|
|
168
|
+
const mark = w.valid ? `✗ ${w.valid}` : '✓';
|
|
169
|
+
console.log(`${mark} ${w.name.padEnd(24)} ${w.path}`);
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// bullswarm workflow runner — load, validate, execute phases, report.
|
|
2
|
+
//
|
|
3
|
+
// Phase semantics:
|
|
4
|
+
// step onError: continue | fail (abort whole run) | skip-phase
|
|
5
|
+
// settings.stopOnPhaseFailure: abort after a phase with any failure
|
|
6
|
+
// Resume: steps whose recorded verdict is ok:true are skipped (R2).
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { validateWorkflow } from './validate.js';
|
|
12
|
+
import { WorkflowRuntime } from './runtime.js';
|
|
13
|
+
|
|
14
|
+
export function loadWorkflow(pathOrName, searchDirs) {
|
|
15
|
+
let path = pathOrName;
|
|
16
|
+
if (!path.endsWith('.json')) {
|
|
17
|
+
const candidates = searchDirs.flatMap((d) => [
|
|
18
|
+
join(d, `${pathOrName}.json`),
|
|
19
|
+
join(d, pathOrName, 'workflow.json'),
|
|
20
|
+
]);
|
|
21
|
+
path = candidates.find((p) => existsSync(p));
|
|
22
|
+
if (!path) {
|
|
23
|
+
throw new Error(`workflow "${pathOrName}" not found in: ${searchDirs.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!existsSync(path)) throw new Error(`workflow file not found: ${path}`);
|
|
27
|
+
return {
|
|
28
|
+
doc: JSON.parse(readFileSync(path, 'utf8')),
|
|
29
|
+
path,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function newRunId() {
|
|
34
|
+
return `wf-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Execute a workflow.
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.bullswarmDir
|
|
41
|
+
* @param {object} opts.doc validated workflow document
|
|
42
|
+
* @param {object} opts.pools live pools (buildPoolsLive)
|
|
43
|
+
* @param {object} opts.inputs runtime inputs (CLI --input k=v)
|
|
44
|
+
* @param {string} [opts.resumeRunId]
|
|
45
|
+
* @param {function} opts.onEvent UX event sink
|
|
46
|
+
*/
|
|
47
|
+
export async function runWorkflow(opts) {
|
|
48
|
+
const { bullswarmDir, doc, pools, inputs = {} } = opts;
|
|
49
|
+
const runsRoot = join(bullswarmDir, 'workflows');
|
|
50
|
+
mkdirSync(runsRoot, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const resuming = Boolean(opts.resumeRunId);
|
|
53
|
+
const runId = resuming ? opts.resumeRunId : newRunId();
|
|
54
|
+
const runDir = join(runsRoot, runId);
|
|
55
|
+
mkdirSync(runDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
let state;
|
|
58
|
+
if (resuming && existsSync(join(runDir, 'state.json'))) {
|
|
59
|
+
state = JSON.parse(readFileSync(join(runDir, 'state.json'), 'utf8'));
|
|
60
|
+
state.resumed = true;
|
|
61
|
+
} else {
|
|
62
|
+
if (resuming) {
|
|
63
|
+
throw new Error(`cannot resume: no state.json for run ${runId}`);
|
|
64
|
+
}
|
|
65
|
+
state = {
|
|
66
|
+
runId,
|
|
67
|
+
workflow: doc.name,
|
|
68
|
+
inputs: { ...Object.fromEntries(
|
|
69
|
+
Object.entries(doc.inputs ?? {}).map(([k, v]) => [k, v.default]),
|
|
70
|
+
), ...inputs },
|
|
71
|
+
settings: { escalateOnFail: true, concurrency: 4, ...(doc.settings ?? {}) },
|
|
72
|
+
outputs: {},
|
|
73
|
+
steps: [], // linear log: {phase, stepId, type, verdict summary}
|
|
74
|
+
startedAt: new Date().toISOString(),
|
|
75
|
+
resumed: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (opts.inputs && Object.keys(opts.inputs).length) {
|
|
79
|
+
state.inputs = { ...state.inputs, ...opts.inputs };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const runtime = new WorkflowRuntime({
|
|
83
|
+
bullswarmDir,
|
|
84
|
+
pools,
|
|
85
|
+
state,
|
|
86
|
+
runDir,
|
|
87
|
+
onEvent: opts.onEvent,
|
|
88
|
+
});
|
|
89
|
+
runtime.persist();
|
|
90
|
+
|
|
91
|
+
opts.onEvent?.({ type: 'workflow.started', runId, workflow: doc.name, phases: doc.phases.length, resumed: state.resumed });
|
|
92
|
+
|
|
93
|
+
let aborted = false;
|
|
94
|
+
let abortReason = null;
|
|
95
|
+
|
|
96
|
+
for (let pi = 0; pi < doc.phases.length && !aborted; pi++) {
|
|
97
|
+
const phase = doc.phases[pi];
|
|
98
|
+
opts.onEvent?.({ type: 'phase.started', index: pi, total: doc.phases.length, name: phase.name });
|
|
99
|
+
let phaseFailed = false;
|
|
100
|
+
|
|
101
|
+
for (const step of phase.steps ?? []) {
|
|
102
|
+
if (resuming && state.outputs[step.id]?.ok === true && step.type === 'run') {
|
|
103
|
+
opts.onEvent?.({ type: 'step.skipped', stepId: step.id });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let r;
|
|
107
|
+
try {
|
|
108
|
+
r = await runtime.runStep(step);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Step-level errors (bad template refs, unparseable fanout items, …)
|
|
111
|
+
// are step failures under onError semantics — never crashes.
|
|
112
|
+
r = { ok: false, why: err.message };
|
|
113
|
+
state.outputs[step.id] = { ok: false, why: err.message };
|
|
114
|
+
}
|
|
115
|
+
state.steps.push({
|
|
116
|
+
phase: phase.name,
|
|
117
|
+
stepId: step.id,
|
|
118
|
+
type: step.type,
|
|
119
|
+
ok: r.ok,
|
|
120
|
+
why: r.why ?? null,
|
|
121
|
+
});
|
|
122
|
+
runtime.persist();
|
|
123
|
+
|
|
124
|
+
if (!r.ok) {
|
|
125
|
+
phaseFailed = true;
|
|
126
|
+
const onError = step.onError ?? 'continue';
|
|
127
|
+
if (onError === 'fail') {
|
|
128
|
+
aborted = true;
|
|
129
|
+
abortReason = `step ${step.id} failed (onError: fail): ${r.why ?? 'unknown'}`;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (onError === 'skip-phase') {
|
|
133
|
+
opts.onEvent?.({ type: 'phase.skipped-rest', phase: phase.name, stepId: step.id });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
// 'continue' — record and move on
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!aborted && phaseFailed && (doc.settings?.stopOnPhaseFailure || state.settings.stopOnPhaseFailure)) {
|
|
141
|
+
aborted = true;
|
|
142
|
+
abortReason = `phase ${phase.name} had failures (stopOnPhaseFailure)`;
|
|
143
|
+
}
|
|
144
|
+
opts.onEvent?.({ type: 'phase.completed', index: pi, name: phase.name, failed: phaseFailed });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const finishedAt = new Date().toISOString();
|
|
148
|
+
state.finishedAt = finishedAt;
|
|
149
|
+
state.status = aborted ? 'failed' : 'completed';
|
|
150
|
+
if (abortReason) state.abortReason = abortReason;
|
|
151
|
+
runtime.persist();
|
|
152
|
+
|
|
153
|
+
const report = buildReport(state, doc, runDir);
|
|
154
|
+
writeFileSync(join(runDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
155
|
+
opts.onEvent?.({ type: 'workflow.completed', runId, status: state.status, report: report.summary });
|
|
156
|
+
|
|
157
|
+
return { runId, runDir, state, report };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildReport(state, doc, runDir) {
|
|
161
|
+
const stepResults = state.steps ?? [];
|
|
162
|
+
const fanoutSteps = Object.entries(state.outputs ?? {}).filter(
|
|
163
|
+
([, v]) => v && typeof v === 'object' && 'items' in v,
|
|
164
|
+
);
|
|
165
|
+
let fanoutOk = 0;
|
|
166
|
+
let fanoutFailed = 0;
|
|
167
|
+
for (const [, v] of fanoutSteps) {
|
|
168
|
+
fanoutOk += v.ok ?? 0;
|
|
169
|
+
fanoutFailed += v.failed ?? 0;
|
|
170
|
+
}
|
|
171
|
+
const simpleOk = stepResults.filter((s) => s.ok).length;
|
|
172
|
+
const simpleFailed = stepResults.filter((s) => s.ok === false).length;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
schemaVersion: 'bullswarm.workflow.report.v1',
|
|
176
|
+
runId: state.runId,
|
|
177
|
+
workflow: state.workflow,
|
|
178
|
+
status: state.status,
|
|
179
|
+
startedAt: state.startedAt,
|
|
180
|
+
finishedAt: state.finishedAt,
|
|
181
|
+
resumed: state.resumed === true,
|
|
182
|
+
abortReason: state.abortReason ?? null,
|
|
183
|
+
summary: {
|
|
184
|
+
stepsTotal: stepResults.length,
|
|
185
|
+
stepsOk: simpleOk,
|
|
186
|
+
stepsFailed: simpleFailed,
|
|
187
|
+
fanoutSteps: fanoutSteps.length,
|
|
188
|
+
fanoutOk,
|
|
189
|
+
fanoutFailed,
|
|
190
|
+
},
|
|
191
|
+
steps: stepResults,
|
|
192
|
+
outputs: state.outputs,
|
|
193
|
+
artifactsDir: runDir,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// bullswarm workflow runtime — executes a validated workflow document.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine:
|
|
4
|
+
// R1. Dispatch reuses watchOnce verbatim — same verdict contract, same
|
|
5
|
+
// quarantine side effects, same meter accounting as single runs.
|
|
6
|
+
// R2. State persists to disk after EVERY step; resume = skip ok:true.
|
|
7
|
+
// R3. Escalation is verdict-driven: failed step retries once on next pool
|
|
8
|
+
// by surplus (never the same pool, never more than once).
|
|
9
|
+
// R4. Concurrency limiter is global across fanout expansions.
|
|
10
|
+
// R5. onError: continue | fail (abort run) | skip-phase (rest of phase).
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { pickPool } from '../lib/route.js';
|
|
15
|
+
import { watchOnce } from '../lib/watch.js';
|
|
16
|
+
import { renderDeep, extractItems } from './template.js';
|
|
17
|
+
|
|
18
|
+
export class WorkflowRuntime {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {string} opts.bullswarmDir ~/.bullswarm
|
|
22
|
+
* @param {object} opts.pools buildPoolsLive result pools array
|
|
23
|
+
* @param {object} opts.state loaded workflow state (mutable)
|
|
24
|
+
* @param {string} opts.runDir artifact dir for this run
|
|
25
|
+
* @param {function} opts.onEvent (event) => void for UX rendering
|
|
26
|
+
*/
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.bullswarmDir = opts.bullswarmDir;
|
|
29
|
+
this.pools = opts.pools;
|
|
30
|
+
this.state = opts.state;
|
|
31
|
+
this.runDir = opts.runDir;
|
|
32
|
+
this.onEvent = opts.onEvent ?? (() => {});
|
|
33
|
+
this.limiter = null; // set from settings at run()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
persist() {
|
|
37
|
+
writeFileSync(join(this.runDir, 'state.json'), `${JSON.stringify(this.state, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
emit(type, payload) {
|
|
41
|
+
this.onEvent({ type, ...payload });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
scopeFor(step) {
|
|
45
|
+
return {
|
|
46
|
+
inputs: this.state.inputs,
|
|
47
|
+
outputs: this.state.outputs,
|
|
48
|
+
runId: this.state.runId,
|
|
49
|
+
wfDir: this.runDir,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Execute one dispatch (run or single fanout expansion).
|
|
55
|
+
* Returns verdict. Applies escalation per R3.
|
|
56
|
+
*/
|
|
57
|
+
async dispatch(step, taskText, targetDir, paths, opts = {}) {
|
|
58
|
+
const attemptPools = this.preparePools(step);
|
|
59
|
+
let lastVerdict = null;
|
|
60
|
+
|
|
61
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
62
|
+
const route = pickPool(step.lane ?? 'chore', attemptPools, {
|
|
63
|
+
callerEligible: false,
|
|
64
|
+
callerSession: false, // workflow context: every pool is a worker
|
|
65
|
+
now: Date.now(),
|
|
66
|
+
});
|
|
67
|
+
if (!route.pick) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
keepOnClaude: false,
|
|
71
|
+
why: `no eligible pool (${route.why})`,
|
|
72
|
+
pick: { pool: null },
|
|
73
|
+
meta: { exitCode: null },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const connector = route.pick.connector?.connector ?? route.pick.connector;
|
|
77
|
+
|
|
78
|
+
// Pin pool if requested (validation already checked existence)
|
|
79
|
+
const chosen = step.pool
|
|
80
|
+
? attemptPools.find((p) => p.name === step.pool)
|
|
81
|
+
: connector;
|
|
82
|
+
const conn = (chosen?.connector) ? chosen.connector : chosen;
|
|
83
|
+
|
|
84
|
+
this.emit('step.started', {
|
|
85
|
+
stepId: step.id,
|
|
86
|
+
item: opts.item,
|
|
87
|
+
pool: conn.name,
|
|
88
|
+
attempt,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const verdict = await watchOnce(conn, taskText, targetDir, paths, {
|
|
92
|
+
timeoutSec: step.timeoutSec ?? conn.timeoutSec ?? 900,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (verdict.ok || step.pool) {
|
|
96
|
+
// pinned pools don't escalate — you asked for THIS pool
|
|
97
|
+
return verdict;
|
|
98
|
+
}
|
|
99
|
+
lastVerdict = verdict;
|
|
100
|
+
// Escalate: drop the pool that just failed from this step's candidates.
|
|
101
|
+
const failedName = conn.name;
|
|
102
|
+
const idx = attemptPools.findIndex((p) => p.name === failedName);
|
|
103
|
+
if (idx >= 0) attemptPools.splice(idx, 1);
|
|
104
|
+
if (!opts.escalate || attemptPools.length === 0) break;
|
|
105
|
+
this.emit('step.escalate', {
|
|
106
|
+
stepId: step.id, item: opts.item,
|
|
107
|
+
from: failedName, why: verdict.why,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return lastVerdict;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
preparePools(step) {
|
|
114
|
+
// Fresh eligible list per dispatch: enabled, not quarantined/exhausted.
|
|
115
|
+
return this.pools.filter((p) => p.enabled !== false && !p.quarantine);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async runStep(step) {
|
|
119
|
+
const scope = this.scopeFor(step);
|
|
120
|
+
if (step.type === 'run') {
|
|
121
|
+
return this.runSingle(step, scope);
|
|
122
|
+
}
|
|
123
|
+
if (step.type === 'fanout') {
|
|
124
|
+
return this.runFanout(step, scope);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`unknown step type ${step.type}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async runSingle(step, scope) {
|
|
130
|
+
const rendered = renderDeep(
|
|
131
|
+
{
|
|
132
|
+
lane: step.lane ?? 'chore',
|
|
133
|
+
addDir: step.addDir,
|
|
134
|
+
prompt: step.prompt,
|
|
135
|
+
taskFile: step.taskFile,
|
|
136
|
+
},
|
|
137
|
+
scope,
|
|
138
|
+
);
|
|
139
|
+
const taskText = rendered.prompt
|
|
140
|
+
?? readFileSync(rendered.taskFile, 'utf8');
|
|
141
|
+
const targetDir = rendered.addDir ? String(rendered.addDir).replace(/^~/, process.env.HOME ?? '') : process.cwd();
|
|
142
|
+
|
|
143
|
+
const stamp = `${step.id}-${Date.now().toString(36)}`;
|
|
144
|
+
const paths = {
|
|
145
|
+
taskFile: join(this.runDir, `task-${stamp}.md`),
|
|
146
|
+
outFile: join(this.runDir, `out-${stamp}.md`),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const verdict = await this.dispatch(step, taskText, targetDir, paths, {
|
|
150
|
+
escalate: this.state.settings.escalateOnFail !== false,
|
|
151
|
+
});
|
|
152
|
+
this.recordOutput(step.id, verdict, paths);
|
|
153
|
+
return verdict;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async runFanout(step, scope) {
|
|
157
|
+
const items = extractItems(this.state, renderTemplate0(step.itemsFrom, scope));
|
|
158
|
+
const concurrency = Math.max(1, Math.min(
|
|
159
|
+
step.concurrency ?? this.state.settings.concurrency ?? 4,
|
|
160
|
+
this.state.settings.concurrency ?? Infinity,
|
|
161
|
+
));
|
|
162
|
+
const results = new Array(items.length).fill(null);
|
|
163
|
+
let cursor = 0;
|
|
164
|
+
let failures = 0;
|
|
165
|
+
const resumed = this.state.outputs?.[step.id]?.items ?? [];
|
|
166
|
+
|
|
167
|
+
const worker = async () => {
|
|
168
|
+
while (cursor < items.length) {
|
|
169
|
+
const i = cursor++;
|
|
170
|
+
const item = items[i];
|
|
171
|
+
|
|
172
|
+
// Resume: skip items whose saved verdict is ok:true (R2)
|
|
173
|
+
const prev = resumed[i];
|
|
174
|
+
if (prev?.verdict?.ok === true) {
|
|
175
|
+
results[i] = prev;
|
|
176
|
+
this.emit('item.skipped', { stepId: step.id, index: i, item });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const itemScope = { ...scope, item };
|
|
181
|
+
let template;
|
|
182
|
+
try {
|
|
183
|
+
template = renderDeep(step.stepTemplate, itemScope);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
results[i] = { verdict: { ok: false, why: err.message }, pool: null };
|
|
186
|
+
failures++;
|
|
187
|
+
this.emit('item.failed', { stepId: step.id, index: i, item, why: err.message });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stamp = `${step.id}-${i}-${Date.now().toString(36)}`;
|
|
192
|
+
const paths = {
|
|
193
|
+
taskFile: join(this.runDir, `task-${stamp}.md`),
|
|
194
|
+
outFile: join(this.runDir, `out-${stamp}.md`),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const targetDir = template.addDir
|
|
198
|
+
? String(template.addDir).replace(/^~/, process.env.HOME ?? '')
|
|
199
|
+
: process.cwd();
|
|
200
|
+
const taskText = template.prompt
|
|
201
|
+
?? readFileSync(String(template.taskFile), 'utf8');
|
|
202
|
+
|
|
203
|
+
this.emit('item.started', { stepId: step.id, index: i, total: items.length, item });
|
|
204
|
+
const verdict = await this.dispatch(
|
|
205
|
+
{ ...step, id: `${step.id}[${i}]` },
|
|
206
|
+
taskText, targetDir, paths,
|
|
207
|
+
{ item, escalate: this.state.settings.escalateOnFail !== false },
|
|
208
|
+
);
|
|
209
|
+
results[i] = { item, verdict, outFile: paths.outFile };
|
|
210
|
+
if (verdict.ok) {
|
|
211
|
+
this.emit('item.completed', { stepId: step.id, index: i, pool: verdict.pick?.pool, wall: verdict.meta?.wallSec });
|
|
212
|
+
} else {
|
|
213
|
+
failures++;
|
|
214
|
+
this.emit('item.failed', { stepId: step.id, index: i, why: verdict.why, pool: verdict.pick?.pool });
|
|
215
|
+
}
|
|
216
|
+
this.persist();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
|
221
|
+
|
|
222
|
+
const oks = results.filter((r) => r?.verdict?.ok === true).length;
|
|
223
|
+
this.state.outputs[step.id] = {
|
|
224
|
+
total: items.length,
|
|
225
|
+
ok: oks,
|
|
226
|
+
failed: items.length - oks,
|
|
227
|
+
items: results,
|
|
228
|
+
};
|
|
229
|
+
this.persist();
|
|
230
|
+
return { ok: failures === 0, results };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
recordOutput(stepId, verdict, paths) {
|
|
234
|
+
let outputText = null;
|
|
235
|
+
try {
|
|
236
|
+
if (paths.outFile && existsSync(paths.outFile)) {
|
|
237
|
+
outputText = readFileSync(paths.outFile, 'utf8');
|
|
238
|
+
}
|
|
239
|
+
} catch { /* non-fatal */ }
|
|
240
|
+
this.state.outputs[stepId] = {
|
|
241
|
+
ok: verdict.ok,
|
|
242
|
+
pool: verdict.pick?.pool ?? null,
|
|
243
|
+
why: verdict.why,
|
|
244
|
+
outFile: paths.outFile,
|
|
245
|
+
wallSec: verdict.meta?.wallSec,
|
|
246
|
+
outputText,
|
|
247
|
+
};
|
|
248
|
+
this.persist();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function renderTemplate0(str, scope) {
|
|
253
|
+
return str.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, ref) => {
|
|
254
|
+
const v = ref.trim().split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), scope);
|
|
255
|
+
if (v === undefined) throw new Error(`unresolved ref {{${ref.trim()}}}`);
|
|
256
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// bullswarm workflow — {{ref}} templating + JSON-path access.
|
|
2
|
+
//
|
|
3
|
+
// Scope precedence: loop item > inputs > outputs.<stepId> > run metadata.
|
|
4
|
+
// A missing reference throws (validation should have caught static cases;
|
|
5
|
+
// this catches runtime-only gaps like fanout item field typos).
|
|
6
|
+
|
|
7
|
+
export function getPath(obj, path) {
|
|
8
|
+
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Expand {{ref}} tokens in a string against a scope object.
|
|
13
|
+
* Supports {{item}}, {{item.path.to.field}}, {{inputs.x}}, {{outputs.stepId}},
|
|
14
|
+
* {{runId}}, {{wfDir}}. Non-string values are JSON-stringified.
|
|
15
|
+
*/
|
|
16
|
+
export function renderTemplate(str, scope) {
|
|
17
|
+
if (typeof str !== 'string') return str;
|
|
18
|
+
return str.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, ref) => {
|
|
19
|
+
const v = getPath(scope, ref.trim());
|
|
20
|
+
if (v === undefined) {
|
|
21
|
+
throw new Error(`template ref "{{${ref.trim()}}}" unresolved at render time`);
|
|
22
|
+
}
|
|
23
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Deep-render every string in a step-like object. */
|
|
28
|
+
export function renderDeep(obj, scope) {
|
|
29
|
+
if (typeof obj === 'string') return renderTemplate(obj, scope);
|
|
30
|
+
if (Array.isArray(obj)) return obj.map((v) => renderDeep(v, scope));
|
|
31
|
+
if (obj && typeof obj === 'object') {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(obj)) out[k] = renderDeep(v, scope);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract the items array for a fanout from workflow state by dotted path.
|
|
41
|
+
* Accepts:
|
|
42
|
+
* - a real array (inputs.files, outputs.step.items)
|
|
43
|
+
* - an outputs entry with outputText containing a JSON array
|
|
44
|
+
* (the common case: a discover step returns "[\"a.json\", ...]")
|
|
45
|
+
*/
|
|
46
|
+
export function extractItems(state, itemsFrom) {
|
|
47
|
+
const v = getPath(state, itemsFrom);
|
|
48
|
+
if (v === undefined) {
|
|
49
|
+
throw new Error(`fanout itemsFrom "${itemsFrom}" not found in workflow state`);
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(v)) return v;
|
|
52
|
+
|
|
53
|
+
// outputs.<stepId> envelope: try its recorded output text
|
|
54
|
+
if (v && typeof v === 'object' && typeof v.outputText === 'string') {
|
|
55
|
+
const parsed = parseJsonArray(v.outputText);
|
|
56
|
+
if (parsed) return parsed;
|
|
57
|
+
throw new Error(
|
|
58
|
+
`fanout itemsFrom "${itemsFrom}": step output is not a JSON array. ` +
|
|
59
|
+
'The discover step must return ONLY a JSON array of items.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`fanout itemsFrom "${itemsFrom}" must resolve to an array (got ${typeof v})`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parse the first JSON array found in a text blob, tolerating prose around it. */
|
|
66
|
+
export function parseJsonArray(text) {
|
|
67
|
+
const start = text.indexOf('[');
|
|
68
|
+
const end = text.lastIndexOf(']');
|
|
69
|
+
if (start === -1 || end <= start) return null;
|
|
70
|
+
try {
|
|
71
|
+
const arr = JSON.parse(text.slice(start, end + 1));
|
|
72
|
+
return Array.isArray(arr) ? arr : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// bullswarm workflow terminal UX — renders runtime events Claude-style.
|
|
2
|
+
//
|
|
3
|
+
// Marks: ✓ ok · ✗ fail · ⟡ running · ⋈ blocked · ⏭ skipped · ⏳ phase
|
|
4
|
+
// Non-TTY: emits compact JSONL lines instead (machine-consumable).
|
|
5
|
+
// Zero dependencies: plain ANSI, cursor-rewrites only for the spinner.
|
|
6
|
+
|
|
7
|
+
const MARK = {
|
|
8
|
+
ok: '✓',
|
|
9
|
+
fail: '✗',
|
|
10
|
+
running: '⟡',
|
|
11
|
+
blocked: '⋈',
|
|
12
|
+
skipped: '⏭',
|
|
13
|
+
pending: '·',
|
|
14
|
+
};
|
|
15
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
16
|
+
|
|
17
|
+
const DIM = '\x1b[2m';
|
|
18
|
+
const BOLD = '\x1b[1m';
|
|
19
|
+
const GREEN = '\x1b[32m';
|
|
20
|
+
const RED = '\x1b[31m';
|
|
21
|
+
const YELLOW = '\x1b[33m';
|
|
22
|
+
const CYAN = '\x1b[36m';
|
|
23
|
+
const RESET = '\x1b[0m';
|
|
24
|
+
|
|
25
|
+
function c(code, s) { return `${code}${s}${RESET}`; }
|
|
26
|
+
function fmtSecs(ms) {
|
|
27
|
+
if (ms == null) return '—';
|
|
28
|
+
const s = ms / 1000;
|
|
29
|
+
return s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${s.toFixed(1)}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WorkflowTui {
|
|
33
|
+
constructor({ quiet = false, json = false } = {}) {
|
|
34
|
+
this.quiet = quiet;
|
|
35
|
+
this.json = json;
|
|
36
|
+
this.isTTY = process.stdout.isTTY === true;
|
|
37
|
+
this.phaseIndex = 0;
|
|
38
|
+
this.phaseTotal = 0;
|
|
39
|
+
this.phaseName = '';
|
|
40
|
+
this.spinnerTimer = null;
|
|
41
|
+
this.spinFrame = 0;
|
|
42
|
+
this.liveLine = null; // current in-flight line to rewrite
|
|
43
|
+
this.counts = { ok: 0, fail: 0 };
|
|
44
|
+
this.startedAt = null;
|
|
45
|
+
if (!this.json && !this.isTTY) {
|
|
46
|
+
// Non-TTY human mode: no ANSI colors (plain marks still readable)
|
|
47
|
+
this.color = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
c(code, s) { return this.isTTY ? c(code, s) : s; }
|
|
52
|
+
|
|
53
|
+
startSpinner() {
|
|
54
|
+
if (!this.isTTY || this.json || this.spinnerTimer) return;
|
|
55
|
+
this.spinnerTimer = setInterval(() => {
|
|
56
|
+
this.spinFrame = (this.spinFrame + 1) % SPINNER_FRAMES.length;
|
|
57
|
+
if (this.liveLine) this.rewriteLive(this.liveLine.render());
|
|
58
|
+
}, 120);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
stopSpinner() {
|
|
62
|
+
if (this.spinnerTimer) { clearInterval(this.spinnerTimer); this.spinnerTimer = null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
rewriteLive(line) {
|
|
66
|
+
if (!this.isTTY) return;
|
|
67
|
+
process.stdout.write(`\r\x1b[K${line}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
commitLive() {
|
|
71
|
+
if (this.liveLine) {
|
|
72
|
+
if (this.isTTY) process.stdout.write('\n');
|
|
73
|
+
this.liveLine = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
print(line) {
|
|
78
|
+
if (this.json) return;
|
|
79
|
+
if (this.quiet && !line.includes('summary')) return;
|
|
80
|
+
this.commitLive();
|
|
81
|
+
console.log(line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
handle(event) {
|
|
85
|
+
switch (event.type) {
|
|
86
|
+
case 'workflow.started': {
|
|
87
|
+
this.startedAt = Date.now();
|
|
88
|
+
if (this.json) {
|
|
89
|
+
console.log(JSON.stringify({ ev: 'workflow.started', ...event }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const resumed = event.resumed ? this.c(YELLOW, ' (resumed)') : '';
|
|
93
|
+
this.print('');
|
|
94
|
+
this.print(this.c(BOLD, `bullswarm workflow · ${event.workflow} · run ${event.runId}`) + resumed);
|
|
95
|
+
this.print(this.c(DIM, `─`.repeat(58)));
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'phase.started': {
|
|
99
|
+
this.phaseIndex = event.index + 1;
|
|
100
|
+
this.phaseTotal = event.total;
|
|
101
|
+
this.phaseName = event.name;
|
|
102
|
+
if (this.json) {
|
|
103
|
+
console.log(JSON.stringify({ ev: 'phase.started', ...event }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.print('');
|
|
107
|
+
this.print(this.c(CYAN, `▐ phase ${event.index + 1}/${event.total} · ${event.name}`));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'step.started': {
|
|
111
|
+
if (this.json) {
|
|
112
|
+
console.log(JSON.stringify({ ev: 'step.started', ...event }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const label = event.item != null ? `${event.stepId}[${labelOf(event.item)}]` : event.stepId;
|
|
116
|
+
this.commitLive();
|
|
117
|
+
this.liveLine = makeLiveLine(MARK.running, label, event.pool, this);
|
|
118
|
+
this.rewriteLive(this.liveLine.render());
|
|
119
|
+
this.startSpinner();
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'item.started': {
|
|
123
|
+
if (this.json) {
|
|
124
|
+
console.log(JSON.stringify({ ev: 'item.started', ...event }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const idx = `${event.index + 1}/${event.total ?? '?'}`;
|
|
128
|
+
const label = `${event.stepId}[${idx}]`;
|
|
129
|
+
this.commitLive();
|
|
130
|
+
this.liveLine = makeLiveLine(MARK.running, label, event.pool ?? '…', this, itemPreview(event.item));
|
|
131
|
+
this.rewriteLive(this.liveLine.render());
|
|
132
|
+
this.startSpinner();
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'item.completed': {
|
|
136
|
+
this.counts.ok++;
|
|
137
|
+
if (this.json) {
|
|
138
|
+
console.log(JSON.stringify({ ev: 'item.completed', ...event }));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const label = `${event.stepId}[${event.index + 1}]`;
|
|
142
|
+
this.commitLive();
|
|
143
|
+
this.print(
|
|
144
|
+
` ${this.c(GREEN, MARK.ok)} ${label.padEnd(34)} ${this.c(DIM, String(event.pool ?? '').padEnd(14))} ${fmtSecs(event.wall * 1000)} ok`
|
|
145
|
+
);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'item.failed': {
|
|
149
|
+
this.counts.fail++;
|
|
150
|
+
if (this.json) {
|
|
151
|
+
console.log(JSON.stringify({ ev: 'item.failed', ...event }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const label = `${event.stepId}[${event.index + 1}]`;
|
|
155
|
+
this.commitLive();
|
|
156
|
+
const pool = event.pool ? `${event.pool}` : '—';
|
|
157
|
+
this.print(
|
|
158
|
+
` ${this.c(RED, MARK.fail)} ${label.padEnd(34)} ${pool.padEnd(14)} fail · ${(event.why ?? '').slice(0, 48)}`
|
|
159
|
+
);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'step.escalate': {
|
|
163
|
+
if (this.json) {
|
|
164
|
+
console.log(JSON.stringify({ ev: 'step.escalate', ...event }));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.commitLive();
|
|
168
|
+
this.print(` ${this.c(YELLOW, '↳ escalate')} ${event.from} → next surplus pool (${(event.why ?? '').slice(0, 40)})`);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'step.skipped': {
|
|
172
|
+
if (this.json) { console.log(JSON.stringify({ ev: 'step.skipped', ...event })); return; }
|
|
173
|
+
this.print(` ${this.c(YELLOW, MARK.skipped)} ${event.stepId.padEnd(34)} ${this.c(DIM, 'ok from previous run (resume)')}`);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'phase.skipped-rest': {
|
|
177
|
+
if (this.json) { console.log(JSON.stringify({ ev: 'phase.skipped-rest', ...event })); return; }
|
|
178
|
+
this.commitLive();
|
|
179
|
+
this.print(` ${this.c(YELLOW, MARK.skipped)} rest of phase "${event.phase}" skipped after ${event.stepId}`);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'phase.completed': {
|
|
183
|
+
this.stopSpinner();
|
|
184
|
+
this.commitLive();
|
|
185
|
+
if (this.json) { console.log(JSON.stringify({ ev: 'phase.completed', ...event })); return; }
|
|
186
|
+
const mark = event.failed ? this.c(RED, MARK.fail) : this.c(GREEN, MARK.ok);
|
|
187
|
+
this.print(`${mark} phase ${this.phaseIndex}/${this.phaseTotal} · ${event.name} done${event.failed ? this.c(YELLOW, ' (with failures)') : ''}`);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'workflow.completed': {
|
|
191
|
+
this.stopSpinner();
|
|
192
|
+
this.commitLive();
|
|
193
|
+
if (this.json) {
|
|
194
|
+
console.log(JSON.stringify({ ev: 'workflow.completed', ...event }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const elapsed = this.startedAt ? fmtSecs(Date.now() - this.startedAt) : '—';
|
|
198
|
+
const s = event.report ?? {};
|
|
199
|
+
this.print('');
|
|
200
|
+
this.print(this.c(DIM, '─'.repeat(58)));
|
|
201
|
+
const status = event.status === 'completed'
|
|
202
|
+
? this.c(GREEN, '✓ completed')
|
|
203
|
+
: this.c(RED, '✗ failed');
|
|
204
|
+
this.print(
|
|
205
|
+
`${status} steps ✓${s.stepsOk ?? 0}/✗${s.stepsFailed ?? 0} · fanout ✓${s.fanoutOk ?? 0}/✗${s.fanoutFailed ?? 0} · elapsed ${elapsed}`
|
|
206
|
+
);
|
|
207
|
+
if (event.runId) this.print(this.c(DIM, `report: ~/.bullswarm/workflows/${event.runId}/report.json`));
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
default:
|
|
211
|
+
if (this.json) console.log(JSON.stringify({ ev: event.type, ...event }));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function makeLiveLine(markChar, label, pool, tui, preview) {
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
const render = () => {
|
|
219
|
+
const frame = tui.isTTY ? SPINNER_FRAMES[tui.spinFrame] : MARK.pending;
|
|
220
|
+
const secs = ((Date.now() - start) / 1000).toFixed(0);
|
|
221
|
+
const pv = preview ? ` ${tui.c(DIM, preview)}` : '';
|
|
222
|
+
return ` ${tui.c(CYAN, frame)} ${String(label).padEnd(34)} ${String(pool).padEnd(14)} ${secs}s running${pv}`;
|
|
223
|
+
};
|
|
224
|
+
return { render };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function itemPreview(item) {
|
|
228
|
+
if (item == null) return '';
|
|
229
|
+
const s = typeof item === 'string' ? item : JSON.stringify(item);
|
|
230
|
+
return s.length > 40 ? `${s.slice(0, 38)}…` : s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function labelOf(item) {
|
|
234
|
+
const s = typeof item === 'string' ? item : JSON.stringify(item);
|
|
235
|
+
return s.length > 20 ? `${s.slice(0, 18)}…]` : s;
|
|
236
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// bullswarm workflow — schema validation for dynamic workflow documents.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine:
|
|
4
|
+
// W1. A workflow is a JSON document, validated fully BEFORE anything runs.
|
|
5
|
+
// W2. Template references must resolve at validation time, except
|
|
6
|
+
// {{item}} / {{item.*}} inside fanout stepTemplate (per-expansion).
|
|
7
|
+
// W3. Lanes and pinned pools are checked against the live registry so a
|
|
8
|
+
// typo never burns quota discovering itself mid-run.
|
|
9
|
+
|
|
10
|
+
const LANES = ['analyze', 'build', 'chore'];
|
|
11
|
+
const ON_ERROR = ['continue', 'fail', 'skip-phase'];
|
|
12
|
+
const STEP_TYPES = ['run', 'fanout'];
|
|
13
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
14
|
+
|
|
15
|
+
export class WorkflowValidationError extends Error {
|
|
16
|
+
constructor(issues) {
|
|
17
|
+
super(`workflow invalid: ${issues.length} problem(s)`);
|
|
18
|
+
this.issues = issues;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collect(issues, ok, msg) {
|
|
23
|
+
if (!ok) issues.push(msg);
|
|
24
|
+
return ok;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Extract {{ref}} tokens from a string. */
|
|
28
|
+
export function templateRefs(str) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const re = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(str)) !== null) out.push(m[1].trim());
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkStrings(value, fn) {
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
fn(value);
|
|
39
|
+
} else if (Array.isArray(value)) {
|
|
40
|
+
for (const v of value) walkStrings(v, fn);
|
|
41
|
+
} else if (value && typeof value === 'object') {
|
|
42
|
+
for (const v of Object.values(value)) walkStrings(v, fn);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate a workflow document against the known lanes/pools.
|
|
48
|
+
* @param {object} wf parsed workflow JSON
|
|
49
|
+
* @param {{lanes?: string[], poolNames?: string[]}} env live registry info
|
|
50
|
+
* @returns {{name: string, warnings: string[]}} normalized doc
|
|
51
|
+
* @throws WorkflowValidationError with .issues[] on any problem
|
|
52
|
+
*/
|
|
53
|
+
export function validateWorkflow(wf, { lanes = LANES, poolNames = [] } = {}) {
|
|
54
|
+
const issues = [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
const validLanes = new Set(lanes);
|
|
57
|
+
const validPools = new Set(poolNames);
|
|
58
|
+
|
|
59
|
+
if (!wf || typeof wf !== 'object' || Array.isArray(wf)) {
|
|
60
|
+
throw new WorkflowValidationError(['document is not a JSON object']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
collect(issues, typeof wf.name === 'string' && NAME_RE.test(wf.name),
|
|
64
|
+
`name "${wf.name}" must be kebab-case (letters/digits/dashes)`);
|
|
65
|
+
collect(issues, typeof wf.description === 'string' && wf.description.length > 0,
|
|
66
|
+
'description is required');
|
|
67
|
+
collect(issues, Array.isArray(wf.phases) && wf.phases.length > 0,
|
|
68
|
+
'phases must be a non-empty array');
|
|
69
|
+
|
|
70
|
+
// inputs
|
|
71
|
+
const inputs = wf.inputs ?? {};
|
|
72
|
+
if (!collect(issues, inputs && typeof inputs === 'object' && !Array.isArray(inputs),
|
|
73
|
+
'inputs must be an object')) {
|
|
74
|
+
throw new WorkflowValidationError(issues);
|
|
75
|
+
}
|
|
76
|
+
for (const [k, v] of Object.entries(inputs)) {
|
|
77
|
+
collect(issues, v && typeof v === 'object', `input "${k}" must be an object`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// phases
|
|
81
|
+
const phaseNames = new Set();
|
|
82
|
+
const stepIds = new Set();
|
|
83
|
+
const outputs = new Set(); // step ids that produce outputs
|
|
84
|
+
|
|
85
|
+
(wf.phases ?? []).forEach((phase, pi) => {
|
|
86
|
+
const at = `phases[${pi}]`;
|
|
87
|
+
collect(issues, phase && typeof phase === 'object', `${at} must be an object`);
|
|
88
|
+
if (!phase || typeof phase !== 'object') return;
|
|
89
|
+
collect(issues, typeof phase.name === 'string' && NAME_RE.test(phase.name),
|
|
90
|
+
`${at}.name must be kebab-case`);
|
|
91
|
+
if (phase.name) {
|
|
92
|
+
collect(issues, !phaseNames.has(phase.name), `duplicate phase name "${phase.name}"`);
|
|
93
|
+
phaseNames.add(phase.name);
|
|
94
|
+
}
|
|
95
|
+
collect(issues, Array.isArray(phase.steps), `${at}.steps must be an array`);
|
|
96
|
+
|
|
97
|
+
(phase.steps ?? []).forEach((step, si) => {
|
|
98
|
+
const sat = `${at}.steps[${si}]`;
|
|
99
|
+
if (!collect(issues, step && typeof step === 'object', `${sat} must be an object`)) return;
|
|
100
|
+
collect(issues, typeof step.id === 'string' && NAME_RE.test(step.id),
|
|
101
|
+
`${sat}.id must be kebab-case`);
|
|
102
|
+
if (step.id) {
|
|
103
|
+
collect(issues, !stepIds.has(step.id), `duplicate step id "${step.id}"`);
|
|
104
|
+
stepIds.add(step.id);
|
|
105
|
+
outputs.add(step.id);
|
|
106
|
+
}
|
|
107
|
+
collect(issues, STEP_TYPES.includes(step.type),
|
|
108
|
+
`${sat}.type must be one of ${STEP_TYPES.join('|')} (got "${step.type}")`);
|
|
109
|
+
collect(issues, ON_ERROR.includes(step.onError ?? 'continue'),
|
|
110
|
+
`${sat}.onError must be one of ${ON_ERROR.join('|')}`);
|
|
111
|
+
|
|
112
|
+
if (step.lane != null) {
|
|
113
|
+
collect(issues, validLanes.has(step.lane),
|
|
114
|
+
`${sat}.lane "${step.lane}" is not a lane (${[...validLanes].join(', ')})`);
|
|
115
|
+
}
|
|
116
|
+
if (step.pool != null) {
|
|
117
|
+
collect(issues, validPools.has(step.pool),
|
|
118
|
+
`${sat}.pool "${step.pool}" is not a known pool (${[...validPools].join(', ') || 'none discovered'})`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (step.type === 'fanout') {
|
|
122
|
+
collect(issues, typeof step.itemsFrom === 'string' && step.itemsFrom.length > 0,
|
|
123
|
+
`${sat}.itemsFrom is required for fanout steps`);
|
|
124
|
+
// itemsFrom must reference declared inputs or a prior step's output
|
|
125
|
+
if (typeof step.itemsFrom === 'string' && step.itemsFrom.includes('.')) {
|
|
126
|
+
const [root, target] = step.itemsFrom.split('.');
|
|
127
|
+
collect(issues, root === 'inputs' || (root === 'outputs' && outputs.has(target)),
|
|
128
|
+
`${sat}.itemsFrom "${step.itemsFrom}" cannot resolve (use inputs.<name> or outputs.<priorStepId>)`);
|
|
129
|
+
} else if (typeof step.itemsFrom === 'string') {
|
|
130
|
+
collect(issues, false,
|
|
131
|
+
`${sat}.itemsFrom "${step.itemsFrom}" must be a dotted path (inputs.<name> or outputs.<priorStepId>)`);
|
|
132
|
+
}
|
|
133
|
+
collect(issues, step.stepTemplate && typeof step.stepTemplate === 'object',
|
|
134
|
+
`${sat}.stepTemplate is required for fanout steps`);
|
|
135
|
+
if (step.concurrency != null) {
|
|
136
|
+
collect(issues, Number.isInteger(step.concurrency) && step.concurrency >= 1,
|
|
137
|
+
`${sat}.concurrency must be a positive integer`);
|
|
138
|
+
}
|
|
139
|
+
} else if (step.type === 'run') {
|
|
140
|
+
collect(issues,
|
|
141
|
+
typeof step.taskFile === 'string' || typeof step.prompt === 'string',
|
|
142
|
+
`${sat} needs taskFile or prompt`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (step.timeoutSec != null) {
|
|
146
|
+
collect(issues, Number.isFinite(step.timeoutSec) && step.timeoutSec > 0,
|
|
147
|
+
`${sat}.timeoutSec must be a positive number`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (issues.length) throw new WorkflowValidationError(issues);
|
|
153
|
+
|
|
154
|
+
// ---- template reference resolution (W2) --------------------------------
|
|
155
|
+
// Scope available at validation: inputs.*, outputs.<stepId> for PRIOR
|
|
156
|
+
// steps, runId/wfDir metadata. {{item}} allowed only inside fanout
|
|
157
|
+
// stepTemplate. We do a second pass now that all step ids are known.
|
|
158
|
+
|
|
159
|
+
const resolvable = (ref, inTemplate) => {
|
|
160
|
+
const root = ref.split('.')[0];
|
|
161
|
+
if (root === 'item') return inTemplate;
|
|
162
|
+
if (root === 'inputs') return true; // presence checked at runtime vs declared inputs? keep lenient, warn below
|
|
163
|
+
if (root === 'outputs') {
|
|
164
|
+
const target = ref.split('.')[1];
|
|
165
|
+
return target ? outputs.has(target) : false;
|
|
166
|
+
}
|
|
167
|
+
return root === 'runId' || root === 'wfDir';
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const checkRefs = (obj, inTemplate, label) => {
|
|
171
|
+
walkStrings(obj, (s) => {
|
|
172
|
+
for (const ref of templateRefs(s)) {
|
|
173
|
+
if (!resolvable(ref, inTemplate)) {
|
|
174
|
+
issues.push(`${label}: template ref "{{${ref}}}" cannot resolve` +
|
|
175
|
+
(inTemplate ? '' : ` (known roots: inputs, outputs.<stepId>, runId, wfDir)`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
(wf.phases ?? []).forEach((phase, pi) => {
|
|
182
|
+
(phase.steps ?? []).forEach((step, si) => {
|
|
183
|
+
const sat = `phases[${pi}].steps[${si}](${step.id ?? '?'})`;
|
|
184
|
+
const { stepTemplate, ...rest } = step;
|
|
185
|
+
checkRefs(rest, false, sat);
|
|
186
|
+
if (stepTemplate) checkRefs(stepTemplate, true, `${sat}.stepTemplate`);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// undeclared input usage → warning only (inputs may be passed at runtime)
|
|
191
|
+
const usedInputs = new Set();
|
|
192
|
+
walkStrings(wf, (s) => {
|
|
193
|
+
for (const ref of templateRefs(s)) {
|
|
194
|
+
if (ref.startsWith('inputs.')) usedInputs.add(ref.slice('inputs.'.length));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
for (const u of usedInputs) {
|
|
198
|
+
if (!(u in inputs)) {
|
|
199
|
+
warnings.push(`template uses inputs.${u} but it is not declared under "inputs" (pass --input ${u}=…)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (issues.length) throw new WorkflowValidationError(issues);
|
|
204
|
+
return { name: wf.name, warnings };
|
|
205
|
+
}
|