ftown-bridge 0.9.3 → 0.9.5
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/dist/claude-trust.d.ts +11 -0
- package/dist/claude-trust.js +47 -0
- package/dist/claude-trust.js.map +1 -0
- package/dist/create-ftown-session.d.ts +2 -0
- package/dist/create-ftown-session.js +37 -7
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-workflows-cli.d.ts +3 -0
- package/dist/install-ftown-workflows-cli.js +30 -0
- package/dist/install-ftown-workflows-cli.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/workflow-runner-cli.d.ts +2 -0
- package/dist/workflow-runner-cli.js +322 -0
- package/dist/workflow-runner-cli.js.map +1 -0
- package/dist/workflow-runner.d.ts +162 -0
- package/dist/workflow-runner.js +305 -0
- package/dist/workflow-runner.js.map +1 -0
- package/package.json +5 -3
- package/skills/ftown-workflows/SKILL.md +282 -0
- package/skills/ftown-workflows/scripts/example.flow.mjs +122 -0
- package/skills/ftown-workflows/scripts/ftown-workflows +4 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ftown-workflows engine.
|
|
3
|
+
*
|
|
4
|
+
* Brings Workflow-tool-style deterministic orchestration to REAL ftown sessions:
|
|
5
|
+
* each `agent()` call spawns a real ftown session via the bridge loopback API, the
|
|
6
|
+
* child writes its result to a file, the runner polls the filesystem for that file
|
|
7
|
+
* (race-free — no inbox / Stop-hook contention), removes the session and returns it.
|
|
8
|
+
*
|
|
9
|
+
* The engine is fully dependency-injected (BridgeClient / ResultStore / Clock /
|
|
10
|
+
* Logger) so it is unit-testable without a live bridge, real fs or real timers.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_SHELL = 'claude';
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 1_800_000;
|
|
14
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
15
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
16
|
+
// A new session reports running:false for a short startup window; only after this grace
|
|
17
|
+
// (with the child never observed running) do we treat it as failed-to-start.
|
|
18
|
+
const DEFAULT_STARTUP_GRACE_MS = 30_000;
|
|
19
|
+
// ---- Pure helpers (exported + individually unit-tested) ----
|
|
20
|
+
/**
|
|
21
|
+
* Build the full child prompt: the user's `task`, then a clearly delimited protocol
|
|
22
|
+
* block instructing the child to write its final result as JSON to `resultFilePath`
|
|
23
|
+
* and then stop. When `schema` is provided, the block embeds the schema and says
|
|
24
|
+
* `result` must conform. The block always literally contains `resultFilePath`.
|
|
25
|
+
*/
|
|
26
|
+
export function buildAgentPrompt(task, resultFilePath, schema) {
|
|
27
|
+
const lines = [
|
|
28
|
+
task.trim(),
|
|
29
|
+
'',
|
|
30
|
+
'--- ftown-workflows RESULT PROTOCOL (read carefully) ---',
|
|
31
|
+
'You are a worker session in a deterministic workflow. When you have finished the',
|
|
32
|
+
'task above, you MUST write your FINAL result as a single JSON object to this file:',
|
|
33
|
+
'',
|
|
34
|
+
` RESULT FILE: ${resultFilePath}`,
|
|
35
|
+
'',
|
|
36
|
+
'The JSON object must have this exact shape:',
|
|
37
|
+
' { "ok": true, "result": <your result> } on success',
|
|
38
|
+
' { "ok": false, "error": "<why it failed>" } on failure',
|
|
39
|
+
'',
|
|
40
|
+
];
|
|
41
|
+
if (schema) {
|
|
42
|
+
lines.push('On success, `result` MUST be valid JSON conforming to this JSON schema:', '', JSON.stringify(schema, null, 2), '');
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
lines.push('On success, `result` may be a plain string or any JSON value. (Without a schema, a', 'non-string `result` is returned to the calling script as a JSON string.)', '');
|
|
46
|
+
}
|
|
47
|
+
lines.push('Write the file atomically (write fully, then save). Do NOT print the result to the', 'terminal instead of the file.', 'The result FILE is the ONLY accepted output channel. Do NOT report via ftown-harness mail, the terminal, or any other channel.', 'After the file is written, STOP — do no further work.', `Reminder: the result file path is ${resultFilePath}`, '--- end protocol ---');
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse raw file text into RawResult. Returns null if text is empty or not valid
|
|
52
|
+
* JSON (partial write). Throws nothing. If JSON parses but lacks a boolean `ok`,
|
|
53
|
+
* treat as null (not yet complete).
|
|
54
|
+
*/
|
|
55
|
+
export function parseResultFile(text) {
|
|
56
|
+
if (!text || text.trim().length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
66
|
+
return null;
|
|
67
|
+
const obj = parsed;
|
|
68
|
+
if (typeof obj.ok !== 'boolean')
|
|
69
|
+
return null;
|
|
70
|
+
return obj;
|
|
71
|
+
}
|
|
72
|
+
// ---- Internal helpers ----
|
|
73
|
+
/** Async counting semaphore: caps concurrently-running spawn+poll cycles. */
|
|
74
|
+
class Semaphore {
|
|
75
|
+
permits;
|
|
76
|
+
waiters = [];
|
|
77
|
+
constructor(permits) {
|
|
78
|
+
this.permits = Number.isFinite(permits) ? Math.max(1, Math.floor(permits)) : 1;
|
|
79
|
+
}
|
|
80
|
+
async acquire() {
|
|
81
|
+
if (this.permits > 0) {
|
|
82
|
+
this.permits -= 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
86
|
+
}
|
|
87
|
+
release() {
|
|
88
|
+
const next = this.waiters.shift();
|
|
89
|
+
if (next) {
|
|
90
|
+
// Hand the permit directly to the next waiter (keeps the cap exact).
|
|
91
|
+
next();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
this.permits += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Make a label filesystem-safe for use as a result-file stem. */
|
|
99
|
+
function sanitizeStepKey(label) {
|
|
100
|
+
const cleaned = label.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
101
|
+
return cleaned.length > 0 ? cleaned : 'step';
|
|
102
|
+
}
|
|
103
|
+
/** Map a successful RawResult to the value agent() returns. */
|
|
104
|
+
function mapOk(raw, hasSchema) {
|
|
105
|
+
const value = raw.result;
|
|
106
|
+
if (hasSchema)
|
|
107
|
+
return value ?? null;
|
|
108
|
+
if (value === undefined || value === null)
|
|
109
|
+
return null;
|
|
110
|
+
// No schema: a string is returned as-is; a non-string structured value is
|
|
111
|
+
// JSON-stringified (NOT lossily String()-coerced to "[object Object]").
|
|
112
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
113
|
+
}
|
|
114
|
+
/** Build + run the context, execute the module, return its return value. */
|
|
115
|
+
export async function runWorkflow(deps, mod, opts) {
|
|
116
|
+
const body = mod.default ?? mod.run;
|
|
117
|
+
if (typeof body !== 'function') {
|
|
118
|
+
throw new Error('workflow module has no default or `run` export');
|
|
119
|
+
}
|
|
120
|
+
const { bridge, store, clock, logger } = deps;
|
|
121
|
+
const defaultShell = opts.defaultShell ?? DEFAULT_SHELL;
|
|
122
|
+
// Resolve numerics defensively: `??` does NOT catch NaN, and a NaN cap would
|
|
123
|
+
// deadlock the semaphore / make every timeout comparison false. Coerce non-finite
|
|
124
|
+
// values to the documented defaults.
|
|
125
|
+
const maxConcurrent = Number.isFinite(opts.maxConcurrent)
|
|
126
|
+
? Math.max(1, Math.floor(opts.maxConcurrent))
|
|
127
|
+
: DEFAULT_MAX_CONCURRENT;
|
|
128
|
+
const defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs)
|
|
129
|
+
? Math.max(0, opts.defaultTimeoutMs)
|
|
130
|
+
: DEFAULT_TIMEOUT_MS;
|
|
131
|
+
// Number.isFinite(null) === false, so both NaN and null collapse to null (unbounded).
|
|
132
|
+
const maxAgents = Number.isFinite(opts.maxAgents)
|
|
133
|
+
? Math.max(0, Math.floor(opts.maxAgents))
|
|
134
|
+
: null;
|
|
135
|
+
const sem = new Semaphore(maxConcurrent);
|
|
136
|
+
// Per-run step-key state (drives resumability + dedup).
|
|
137
|
+
const usedKeys = new Set();
|
|
138
|
+
let agentSeq = 0;
|
|
139
|
+
// Budget accounting — only NON-cached spawns count.
|
|
140
|
+
let spentCount = 0;
|
|
141
|
+
function deriveStepKey(label) {
|
|
142
|
+
agentSeq += 1;
|
|
143
|
+
const base = sanitizeStepKey(label ?? `step-${agentSeq}`);
|
|
144
|
+
let key = base;
|
|
145
|
+
let n = 2;
|
|
146
|
+
while (usedKeys.has(key)) {
|
|
147
|
+
key = `${base}-${n}`;
|
|
148
|
+
n += 1;
|
|
149
|
+
}
|
|
150
|
+
usedKeys.add(key);
|
|
151
|
+
return key;
|
|
152
|
+
}
|
|
153
|
+
const budget = {
|
|
154
|
+
maxAgents,
|
|
155
|
+
spent: () => spentCount,
|
|
156
|
+
remaining: () => (maxAgents === null ? Infinity : maxAgents - spentCount),
|
|
157
|
+
};
|
|
158
|
+
/** Poll the result file until it appears, the session dies, or we hit the cap. */
|
|
159
|
+
async function pollForResult(sessionId, path, pollIntervalMs, timeoutMs, startupGraceMs) {
|
|
160
|
+
const start = clock.now();
|
|
161
|
+
// A freshly-created session reports `running: false` for a brief startup window
|
|
162
|
+
// (the PTY exists but the child process is not registered as running yet). If we
|
|
163
|
+
// treated that first `!running` as terminal we would kill the child before it ever
|
|
164
|
+
// booted. So `!running` is only terminal once the child has been observed running
|
|
165
|
+
// at least once (it ran, then exited), or once the startup grace has elapsed
|
|
166
|
+
// without it ever coming up (it failed to start).
|
|
167
|
+
let everRunning = false;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const found = await store.readResult(path);
|
|
170
|
+
if (found)
|
|
171
|
+
return found;
|
|
172
|
+
const elapsed = clock.now() - start;
|
|
173
|
+
if (elapsed >= timeoutMs)
|
|
174
|
+
return null;
|
|
175
|
+
const running = await bridge.isRunning(sessionId);
|
|
176
|
+
if (running) {
|
|
177
|
+
everRunning = true;
|
|
178
|
+
}
|
|
179
|
+
else if (everRunning || elapsed >= startupGraceMs) {
|
|
180
|
+
// Ran-then-exited (do one final read for the wrote-then-exited race) or
|
|
181
|
+
// never started within the grace window.
|
|
182
|
+
return await store.readResult(path);
|
|
183
|
+
}
|
|
184
|
+
await clock.sleep(pollIntervalMs);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function agent(prompt, agentOpts = {}) {
|
|
188
|
+
if (typeof prompt !== 'string' || prompt.length === 0) {
|
|
189
|
+
throw new Error('agent(): prompt must be a non-empty string');
|
|
190
|
+
}
|
|
191
|
+
const stepKey = deriveStepKey(agentOpts.label);
|
|
192
|
+
const label = agentOpts.label ?? stepKey;
|
|
193
|
+
const phase = agentOpts.phase;
|
|
194
|
+
const hasSchema = agentOpts.schema !== undefined;
|
|
195
|
+
const path = store.resultPath(opts.runId, stepKey);
|
|
196
|
+
// 1. Resume: a valid result already on disk → return without spawning.
|
|
197
|
+
// A failing cache read is treated as a MISS (proceed to spawn) so agent()
|
|
198
|
+
// honors its never-rejects contract; the poll read inside the try block
|
|
199
|
+
// surfaces real store failures as null.
|
|
200
|
+
let cached = null;
|
|
201
|
+
try {
|
|
202
|
+
cached = await store.readResult(path);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
cached = null;
|
|
206
|
+
}
|
|
207
|
+
if (cached) {
|
|
208
|
+
logger.event({ kind: 'agent-done', label, phase, ok: cached.ok, cached: true });
|
|
209
|
+
return cached.ok ? mapOk(cached, hasSchema) : null;
|
|
210
|
+
}
|
|
211
|
+
// 7. Budget: a set cap blocks further spawns once exhausted.
|
|
212
|
+
if (maxAgents !== null && spentCount >= maxAgents) {
|
|
213
|
+
logger.event({ kind: 'agent-error', label, phase, error: 'budget exhausted' });
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
spentCount += 1;
|
|
217
|
+
const spec = {
|
|
218
|
+
prompt: buildAgentPrompt(prompt, path, agentOpts.schema),
|
|
219
|
+
shellType: agentOpts.shell ?? defaultShell,
|
|
220
|
+
parentSessionId: opts.selfSessionId,
|
|
221
|
+
};
|
|
222
|
+
if (agentOpts.workdir ?? opts.workdir)
|
|
223
|
+
spec.workingDir = agentOpts.workdir ?? opts.workdir;
|
|
224
|
+
spec.name = agentOpts.name ?? label;
|
|
225
|
+
if (agentOpts.model)
|
|
226
|
+
spec.model = agentOpts.model;
|
|
227
|
+
// 6. Concurrency: only `maxConcurrent` spawn+poll cycles run at once.
|
|
228
|
+
await sem.acquire();
|
|
229
|
+
let sessionId;
|
|
230
|
+
try {
|
|
231
|
+
const session = await bridge.createSession(spec);
|
|
232
|
+
sessionId = session.id;
|
|
233
|
+
logger.event({ kind: 'agent-start', label, phase, sessionId });
|
|
234
|
+
const pollInterval = Number.isFinite(agentOpts.pollIntervalMs)
|
|
235
|
+
? Math.max(50, agentOpts.pollIntervalMs)
|
|
236
|
+
: DEFAULT_POLL_INTERVAL_MS;
|
|
237
|
+
const startupGrace = Number.isFinite(agentOpts.startupGraceMs)
|
|
238
|
+
? Math.max(0, agentOpts.startupGraceMs)
|
|
239
|
+
: DEFAULT_STARTUP_GRACE_MS;
|
|
240
|
+
const raw = await pollForResult(sessionId, path, pollInterval, agentOpts.timeoutMs ?? defaultTimeoutMs, startupGrace);
|
|
241
|
+
if (raw && raw.ok) {
|
|
242
|
+
logger.event({ kind: 'agent-done', label, phase, ok: true, cached: false });
|
|
243
|
+
return mapOk(raw, hasSchema);
|
|
244
|
+
}
|
|
245
|
+
const error = raw
|
|
246
|
+
? (raw.error ?? 'agent reported failure')
|
|
247
|
+
: 'no result (session exited or timed out)';
|
|
248
|
+
logger.event({ kind: 'agent-error', label, phase, error });
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Real bridge/store errors must not reject agent() — map to a failure.
|
|
253
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
254
|
+
logger.event({ kind: 'agent-error', label, phase, error: message });
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
// 5. Cleanup: ALWAYS remove the session, swallowing 404s.
|
|
259
|
+
if (sessionId !== undefined) {
|
|
260
|
+
try {
|
|
261
|
+
await bridge.removeSession(sessionId);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* removeSession must not throw on 404 — ignore. */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
sem.release();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function parallel(thunks) {
|
|
271
|
+
return Promise.all(thunks.map(async (thunk) => {
|
|
272
|
+
try {
|
|
273
|
+
return await thunk();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
async function pipeline(items, ...stages) {
|
|
281
|
+
return Promise.all(items.map(async (item, index) => {
|
|
282
|
+
let prev = item;
|
|
283
|
+
try {
|
|
284
|
+
for (const stage of stages) {
|
|
285
|
+
prev = await stage(prev, item, index);
|
|
286
|
+
}
|
|
287
|
+
return prev;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
const ctx = {
|
|
295
|
+
agent,
|
|
296
|
+
parallel,
|
|
297
|
+
pipeline,
|
|
298
|
+
phase: (title) => logger.event({ kind: 'phase', title }),
|
|
299
|
+
log: (message) => logger.event({ kind: 'log', message }),
|
|
300
|
+
args: opts.args,
|
|
301
|
+
budget,
|
|
302
|
+
};
|
|
303
|
+
return await body(ctx);
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=workflow-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflow-runner.js","sourceRoot":"","sources":["../src/workflow-runner.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftown-bridge",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "CLI bridge for ftown
|
|
3
|
+
"version": "0.9.5",
|
|
4
|
+
"description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"ftown-bridge": "dist/index.js",
|
|
9
9
|
"ftown-harness": "dist/harness-cli.js",
|
|
10
|
-
"ftown-sessions": "bin/ftown-sessions"
|
|
10
|
+
"ftown-sessions": "bin/ftown-sessions",
|
|
11
|
+
"ftown-workflows": "dist/workflow-runner-cli.js"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"dist",
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
|
19
20
|
"build": "tsc",
|
|
21
|
+
"test": "node --import tsx --test \"src/**/*.test.ts\"",
|
|
20
22
|
"prepublishOnly": "npm run build",
|
|
21
23
|
"start": "tsx src/index.ts",
|
|
22
24
|
"dev": "tsx watch src/index.ts",
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ftown-workflows
|
|
3
|
+
description: >-
|
|
4
|
+
Run deterministic, scripted, resumable multi-session workflows across real
|
|
5
|
+
ftown agent sessions. Activates when the user wants to fan out ftown worker
|
|
6
|
+
sessions over a work list, run a pipeline or parallel batch across ftown
|
|
7
|
+
sessions, perform adversarial-verify (majority-vote) across sessions,
|
|
8
|
+
loop-until-dry over a dataset, or write a repeatable orchestration script
|
|
9
|
+
instead of doing it by hand.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# ftown-workflows
|
|
13
|
+
|
|
14
|
+
`ftown-workflows` is a **scripted orchestration engine** for real ftown sessions.
|
|
15
|
+
You write a `.mjs` script using a small API (`agent`, `parallel`, `pipeline`,
|
|
16
|
+
`phase`, `log`, `args`, `budget`) and the runner spawns real ftown sessions (claude /
|
|
17
|
+
cursor / codex), waits for each to write a result file, cleans up, and returns the
|
|
18
|
+
value — all deterministically and repeatably.
|
|
19
|
+
|
|
20
|
+
This complements the by-hand **ftown-orchestrator** skill (which is the ad-hoc,
|
|
21
|
+
human-in-the-loop playbook). Use ftown-workflows when the work is scripted and
|
|
22
|
+
repeatable; use ftown-orchestrator when you need to improvise or keep a human in
|
|
23
|
+
the loop.
|
|
24
|
+
|
|
25
|
+
## Running a workflow
|
|
26
|
+
|
|
27
|
+
You must be **inside an ftown session** — `FTOWN_SESSION_ID` must be set.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
~/.ftown/ftown-workflows run path/to/script.mjs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Caveat:** run ftown-workflows from a **top-level orchestrator session**. If the
|
|
34
|
+
> session running it is itself a child, the bridge flattens the tree so spawned
|
|
35
|
+
> workers become **siblings** of the orchestrator rather than its children. Results
|
|
36
|
+
> are file-based, so this does not affect correctness — only the dashboard topology.
|
|
37
|
+
|
|
38
|
+
Full options:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
~/.ftown/ftown-workflows run <script.mjs> \
|
|
42
|
+
[--args <json>] # parsed and available as ctx.args in the script
|
|
43
|
+
[--workdir <path>] # default working dir for spawned child sessions
|
|
44
|
+
[--shell claude|cursor|codex|opencode|shell]
|
|
45
|
+
[--concurrency <n>] # max simultaneous live sessions (default 4)
|
|
46
|
+
[--timeout <ms>] # per-agent timeout (default 1 800 000 = 30 min)
|
|
47
|
+
[--max-agents <n>] # hard budget cap on total spawns
|
|
48
|
+
[--run-id <id>] # resume a previous run (skip completed steps)
|
|
49
|
+
[--json] # print final result as raw JSON
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The runner prints the **run directory** (`~/.ftown/workflows/<run-id>/`) at start.
|
|
53
|
+
For long runs, launch in the background and tail the run dir:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
~/.ftown/ftown-workflows run script.mjs --args '{"pr":42}' &
|
|
57
|
+
tail -f ~/.ftown/workflows/<run-id>/*.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See the runnable template at `skills/ftown-workflows/scripts/example.flow.mjs`.
|
|
61
|
+
|
|
62
|
+
## Script API
|
|
63
|
+
|
|
64
|
+
A workflow script is an ES module. Its **default export** (or named `run` export)
|
|
65
|
+
is an async function that receives a `WorkflowContext`:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// my-workflow.mjs
|
|
69
|
+
export default async function (ctx) {
|
|
70
|
+
ctx.phase('Gather');
|
|
71
|
+
ctx.log(`args: ${JSON.stringify(ctx.args)}`);
|
|
72
|
+
|
|
73
|
+
const summary = await ctx.agent('Summarise the repo README', {
|
|
74
|
+
label: 'summarise',
|
|
75
|
+
workdir: '/path/to/repo',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
ctx.log(`summary: ${summary}`);
|
|
79
|
+
return summary;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `ctx.agent(prompt, opts?)`
|
|
84
|
+
|
|
85
|
+
Spawns one real ftown session. Blocks until the session writes its result file,
|
|
86
|
+
then removes the session and returns the result.
|
|
87
|
+
|
|
88
|
+
- Without `schema`: returns a **string** (or `null` on failure/timeout). A string
|
|
89
|
+
`result` is returned as-is; a non-string `result` is returned as a JSON string.
|
|
90
|
+
- With `schema`: returns the child's `result` JSON value **as-is** (parsed from the
|
|
91
|
+
result file), or `null` on failure. The engine does **not** validate `result`
|
|
92
|
+
against the schema — the schema is embedded in the child's prompt as guidance only.
|
|
93
|
+
Treat conformance as best-effort and validate it yourself if you depend on it.
|
|
94
|
+
|
|
95
|
+
Returns `null` — never throws — for: timeout, session exits without a result,
|
|
96
|
+
`ok: false` in the result, budget exhausted.
|
|
97
|
+
|
|
98
|
+
Key options:
|
|
99
|
+
|
|
100
|
+
| option | default | meaning |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `label` | `step-<n>` | step key used for the result file and resume |
|
|
103
|
+
| `phase` | — | progress grouping shown in logs |
|
|
104
|
+
| `schema` | — | JSON Schema; forces JSON result |
|
|
105
|
+
| `shell` | run-level default | `claude` / `cursor` / `codex` / `opencode` / `shell` |
|
|
106
|
+
| `model` | — | model override passed to the session |
|
|
107
|
+
| `workdir` | run-level default | working directory for the child session |
|
|
108
|
+
| `timeoutMs` | 1 800 000 | wall-clock cap for this step |
|
|
109
|
+
| `pollIntervalMs` | 2000 | how often to check for the result file |
|
|
110
|
+
|
|
111
|
+
### `ctx.parallel(thunks)`
|
|
112
|
+
|
|
113
|
+
Run an array of thunks concurrently (barrier: waits for all). Respects the
|
|
114
|
+
run-level `--concurrency` cap. A thunk that errors → `null` entry; the call
|
|
115
|
+
never rejects.
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
const reviews = await ctx.parallel(
|
|
119
|
+
files.map(f => () => ctx.agent(`Review ${f}`, { label: `review-${f}` }))
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `ctx.pipeline(items, ...stages)`
|
|
124
|
+
|
|
125
|
+
Thread each item through a sequence of stages independently (no barrier between
|
|
126
|
+
stages). A stage that throws drops that item to `null` and skips its remaining
|
|
127
|
+
stages.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const results = await ctx.pipeline(
|
|
131
|
+
files,
|
|
132
|
+
async (file) => ctx.agent(`lint ${file}`, { label: `lint-${file}` }),
|
|
133
|
+
async (lintResult, file) => ctx.agent(`fix issues in ${file}: ${lintResult}`, { label: `fix-${file}` }),
|
|
134
|
+
);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `ctx.phase(title)` / `ctx.log(message)`
|
|
138
|
+
|
|
139
|
+
Emit progress events to stderr. Use `phase` for major milestones, `log` for
|
|
140
|
+
detail lines.
|
|
141
|
+
|
|
142
|
+
### `ctx.args`
|
|
143
|
+
|
|
144
|
+
The value passed via `--args <json>` (parsed). `undefined` if not provided.
|
|
145
|
+
|
|
146
|
+
### `ctx.budget`
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
ctx.budget.maxAgents // null = unbounded
|
|
150
|
+
ctx.budget.spent() // spawns so far (cached don't count)
|
|
151
|
+
ctx.budget.remaining() // maxAgents - spent(), or Infinity
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Result-file contract
|
|
155
|
+
|
|
156
|
+
Each child session receives a prompt that ends with a protocol block instructing
|
|
157
|
+
it to write its final result as JSON to a specific file path and then stop:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{ "ok": true, "result": "...anything..." }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
or on failure:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{ "ok": false, "error": "reason" }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The engine polls the file every `pollIntervalMs` ms. A partial write (incomplete
|
|
170
|
+
JSON) is silently ignored until it is valid. The child session is removed (archived)
|
|
171
|
+
once the result is read, or on timeout/exit.
|
|
172
|
+
|
|
173
|
+
**You do not write this file yourself** — the child agent is instructed to do it.
|
|
174
|
+
The prompt injected by the engine tells the child agent exactly what to write.
|
|
175
|
+
|
|
176
|
+
## Patterns
|
|
177
|
+
|
|
178
|
+
### Parallel fan-out
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
export default async function (ctx) {
|
|
182
|
+
const items = ctx.args.items; // e.g. ["auth.ts", "api.ts", "db.ts"]
|
|
183
|
+
|
|
184
|
+
ctx.phase('Review');
|
|
185
|
+
const reviews = await ctx.parallel(
|
|
186
|
+
items.map(f => () => ctx.agent(`Review ${f} for security issues`, {
|
|
187
|
+
label: `review-${f}`,
|
|
188
|
+
}))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
ctx.phase('Synthesise');
|
|
192
|
+
const report = await ctx.agent(
|
|
193
|
+
`Synthesise these security reviews:\n${reviews.filter(Boolean).join('\n---\n')}`,
|
|
194
|
+
{ label: 'synthesis' },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return report;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Pipeline (multi-stage per item)
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
export default async function (ctx) {
|
|
205
|
+
return ctx.pipeline(
|
|
206
|
+
ctx.args.files,
|
|
207
|
+
(file) => ctx.agent(`Lint ${file}`, { label: `lint-${file}` }),
|
|
208
|
+
(lintOut, file) => ctx.agent(`Fix ${file} based on: ${lintOut}`, { label: `fix-${file}` }),
|
|
209
|
+
(fixOut, file) => ctx.agent(`Write tests for ${file}`, { label: `test-${file}` }),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Adversarial verify (majority vote)
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
export default async function (ctx) {
|
|
218
|
+
const claim = ctx.args.claim;
|
|
219
|
+
const REVIEWERS = 3;
|
|
220
|
+
|
|
221
|
+
ctx.phase('Verify');
|
|
222
|
+
const verdicts = await ctx.parallel(
|
|
223
|
+
Array.from({ length: REVIEWERS }, (_, i) =>
|
|
224
|
+
() => ctx.agent(
|
|
225
|
+
`You are a skeptical reviewer. Is this claim correct? "${claim}" Reply with just "yes" or "no".`,
|
|
226
|
+
{ label: `skeptic-${i}` },
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const yes = verdicts.filter(v => v?.toLowerCase().startsWith('yes')).length;
|
|
232
|
+
return { claim, verdict: yes > REVIEWERS / 2 ? 'accepted' : 'rejected', votes: verdicts };
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Loop-until-dry
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
export default async function (ctx) {
|
|
240
|
+
let queue = [...ctx.args.items];
|
|
241
|
+
const done = [];
|
|
242
|
+
|
|
243
|
+
while (queue.length > 0 && ctx.budget.remaining() > 0) {
|
|
244
|
+
ctx.phase(`Batch (${queue.length} remaining)`);
|
|
245
|
+
const batch = queue.splice(0, 4);
|
|
246
|
+
const results = await ctx.parallel(
|
|
247
|
+
batch.map(item => () => ctx.agent(`Process: ${item}`, { label: `proc-${item}` }))
|
|
248
|
+
);
|
|
249
|
+
done.push(...results.filter(Boolean));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return done;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Resume
|
|
257
|
+
|
|
258
|
+
Every step is keyed by its `label` (or `step-<n>`). If a result file already
|
|
259
|
+
exists for a step, the engine returns the cached result without spawning a new
|
|
260
|
+
session. To resume a partial run:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
~/.ftown/ftown-workflows run script.mjs --run-id <the-previous-run-id>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The run id and run directory are printed at startup.
|
|
267
|
+
|
|
268
|
+
## When to use this vs ftown-orchestrator
|
|
269
|
+
|
|
270
|
+
| | ftown-orchestrator | ftown-workflows |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| **style** | ad-hoc, by hand | scripted, deterministic |
|
|
273
|
+
| **human in loop** | yes — you direct workers via mail | no — script drives everything |
|
|
274
|
+
| **repeatability** | each run is improvised | same script, same steps |
|
|
275
|
+
| **resume** | manual | automatic via `--run-id` |
|
|
276
|
+
| **best for** | exploratory tasks, escalations, debugging | batch jobs, CI-style pipelines, fan-out reviews |
|
|
277
|
+
|
|
278
|
+
## If the CLI is missing
|
|
279
|
+
|
|
280
|
+
Start or restart **ftown-bridge** on this machine. It installs
|
|
281
|
+
`~/.ftown/ftown-workflows` and updates this skill under
|
|
282
|
+
`~/.ftown/skills/ftown-workflows/` (linked into ~/.agents/skills and ~/.claude/skills).
|