lilflow 0.1.1 → 0.2.1
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/.claude-plugin/plugin.json +7 -0
- package/README.md +193 -51
- package/package.json +6 -2
- package/skills/lilflow-workflow-driver/SKILL.md +110 -0
- package/src/agents/index.js +41 -1
- package/src/agents/output-file.js +204 -0
- package/src/cli.js +17 -9
- package/src/run-workflow.js +202 -1
- package/src/session-bridge.js +644 -0
- package/src/session-prompt.js +59 -0
- package/src/session-runner.js +152 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import {
|
|
5
|
+
WorkflowError,
|
|
6
|
+
appendEventLogEntryWithLock,
|
|
7
|
+
evaluateSessionGate,
|
|
8
|
+
loadWorkflow,
|
|
9
|
+
readWorkflowRunEvents
|
|
10
|
+
} from "./run-workflow.js";
|
|
11
|
+
|
|
12
|
+
const SUBCOMMANDS = new Set(["next", "update", "gate", "ask", "wait", "status", "log", "compact", "help"]);
|
|
13
|
+
const VALID_STATUSES = new Set(["completed", "failed", "deferred", "skipped"]);
|
|
14
|
+
const DEFAULT_POLL_MS = 1000;
|
|
15
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return the help text for `flow session-bridge`.
|
|
19
|
+
*
|
|
20
|
+
* @returns {string} Command help text.
|
|
21
|
+
*/
|
|
22
|
+
export function getSessionBridgeHelpText() {
|
|
23
|
+
return `flow session-bridge
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
flow session-bridge <subcommand> [args...]
|
|
27
|
+
|
|
28
|
+
Subcommands:
|
|
29
|
+
next Return the next eligible step(s) as JSON
|
|
30
|
+
update <step> <status> Mark a step as completed/failed/deferred/skipped
|
|
31
|
+
Options: --output <text>, --exit-code <n>
|
|
32
|
+
gate <step> Evaluate a gate step, return pass/fail result
|
|
33
|
+
ask <prompt> Read one line from stdin, return as response
|
|
34
|
+
wait <step> Block until wait step trigger fires
|
|
35
|
+
status Return full workflow state as JSON
|
|
36
|
+
log <message> Append a bridge_log event to the run log
|
|
37
|
+
compact Return a condensed summary of completed steps
|
|
38
|
+
|
|
39
|
+
Environment:
|
|
40
|
+
FLOW_RUN_ID Active workflow run ID (required for state-changing subcommands)
|
|
41
|
+
|
|
42
|
+
Notes:
|
|
43
|
+
- Designed to be invoked from inside an agent session by the lilflow skill
|
|
44
|
+
- Outputs single-line JSON on stdout for the agent to consume
|
|
45
|
+
- State is read from / written to .flow/runs/<run-id>.jsonl`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute `flow session-bridge <subcommand>`.
|
|
50
|
+
*
|
|
51
|
+
* @param {string[]} args - Command args after `session-bridge`.
|
|
52
|
+
* @param {(message: string) => void} stdout - Standard output writer.
|
|
53
|
+
* @param {(message: string) => void} stderr - Standard error writer.
|
|
54
|
+
* @param {object} options - Runtime overrides.
|
|
55
|
+
* @param {string} options.cwd - Working directory.
|
|
56
|
+
* @param {object} options.env - Process environment.
|
|
57
|
+
* @param {NodeJS.ReadableStream} [options.stdin] - Input stream used by `ask`.
|
|
58
|
+
* @returns {Promise<number>} Process exit code.
|
|
59
|
+
*/
|
|
60
|
+
export async function runSessionBridgeCommand(args, stdout, stderr, options) {
|
|
61
|
+
const [subcommand, ...subArgs] = args;
|
|
62
|
+
|
|
63
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h" || subcommand === "help") {
|
|
64
|
+
stdout(getSessionBridgeHelpText());
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!SUBCOMMANDS.has(subcommand)) {
|
|
69
|
+
stderr(`Unknown session-bridge subcommand: ${subcommand}`);
|
|
70
|
+
stderr("Run 'flow session-bridge --help' to see available subcommands.");
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
switch (subcommand) {
|
|
76
|
+
case "next":
|
|
77
|
+
return await subcommandNext(subArgs, stdout, stderr, options);
|
|
78
|
+
case "update":
|
|
79
|
+
return await subcommandUpdate(subArgs, stdout, stderr, options);
|
|
80
|
+
case "gate":
|
|
81
|
+
return await subcommandGate(subArgs, stdout, stderr, options);
|
|
82
|
+
case "ask":
|
|
83
|
+
return await subcommandAsk(subArgs, stdout, stderr, options);
|
|
84
|
+
case "wait":
|
|
85
|
+
return await subcommandWait(subArgs, stdout, stderr, options);
|
|
86
|
+
case "status":
|
|
87
|
+
return await subcommandStatus(subArgs, stdout, stderr, options);
|
|
88
|
+
case "log":
|
|
89
|
+
return await subcommandLog(subArgs, stdout, stderr, options);
|
|
90
|
+
case "compact":
|
|
91
|
+
return await subcommandCompact(subArgs, stdout, stderr, options);
|
|
92
|
+
default:
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof WorkflowError) {
|
|
97
|
+
stderr(`Error: ${error.message}`);
|
|
98
|
+
if (Array.isArray(error.details)) {
|
|
99
|
+
for (const detail of error.details) {
|
|
100
|
+
stderr(detail);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the run ID from env or explicit flag.
|
|
112
|
+
*
|
|
113
|
+
* @param {string[]} args - Remaining CLI args.
|
|
114
|
+
* @param {object} env - Process environment.
|
|
115
|
+
* @returns {{runId: string, remaining: string[]}} Resolved run ID and remaining args.
|
|
116
|
+
*/
|
|
117
|
+
function resolveRunId(args, env) {
|
|
118
|
+
const remaining = [];
|
|
119
|
+
let flagged;
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
122
|
+
if (args[i] === "--run-id") {
|
|
123
|
+
flagged = args[i + 1];
|
|
124
|
+
i += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
remaining.push(args[i]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const runId = flagged ?? env.FLOW_RUN_ID;
|
|
131
|
+
|
|
132
|
+
if (!runId || typeof runId !== "string" || runId.trim() === "") {
|
|
133
|
+
throw new WorkflowError("FLOW_RUN_ID env var or --run-id flag is required.");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { runId: runId.trim(), remaining };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load the workflow associated with a run by reading workflow_path from run_started.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} cwd - Working directory.
|
|
143
|
+
* @param {string} runId - Run identifier.
|
|
144
|
+
* @returns {Promise<{workflow: object, runStarted: object, events: object[], eventLogPath: string}>} Loaded context.
|
|
145
|
+
*/
|
|
146
|
+
async function loadRunContext(cwd, runId) {
|
|
147
|
+
const events = await readWorkflowRunEvents(cwd, runId);
|
|
148
|
+
const runStarted = events.find((event) => event.type === "run_started");
|
|
149
|
+
|
|
150
|
+
if (!runStarted) {
|
|
151
|
+
throw new WorkflowError(`Run ${runId} is missing its run_started event.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const workflowPath = path.isAbsolute(runStarted.workflow_path)
|
|
155
|
+
? runStarted.workflow_path
|
|
156
|
+
: path.join(cwd, runStarted.workflow_path);
|
|
157
|
+
|
|
158
|
+
const workflow = await loadWorkflow(workflowPath);
|
|
159
|
+
const eventLogPath = path.join(cwd, ".flow", "runs", `${runId}.jsonl`);
|
|
160
|
+
|
|
161
|
+
return { workflow, runStarted, events, eventLogPath };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compute which steps have been completed/failed/skipped from events.
|
|
166
|
+
*
|
|
167
|
+
* @param {object[]} events - All run events.
|
|
168
|
+
* @returns {Map<string, {status: string, exitCode: number, stdout: string, stderr: string}>} Step state by name.
|
|
169
|
+
*/
|
|
170
|
+
function computeStepStates(events) {
|
|
171
|
+
const states = new Map();
|
|
172
|
+
|
|
173
|
+
for (const event of events) {
|
|
174
|
+
if (event.type === "step_completed") {
|
|
175
|
+
states.set(event.step_name, {
|
|
176
|
+
status: "completed",
|
|
177
|
+
exitCode: event.exit_code ?? 0,
|
|
178
|
+
stdout: event.stdout ?? "",
|
|
179
|
+
stderr: event.stderr ?? ""
|
|
180
|
+
});
|
|
181
|
+
} else if (event.type === "step_failed") {
|
|
182
|
+
states.set(event.step_name, {
|
|
183
|
+
status: "failed",
|
|
184
|
+
exitCode: event.exit_code ?? 1,
|
|
185
|
+
stdout: event.stdout ?? "",
|
|
186
|
+
stderr: event.stderr ?? ""
|
|
187
|
+
});
|
|
188
|
+
} else if (event.type === "step_skipped") {
|
|
189
|
+
states.set(event.step_name, {
|
|
190
|
+
status: "skipped",
|
|
191
|
+
exitCode: 0,
|
|
192
|
+
stdout: "",
|
|
193
|
+
stderr: ""
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return states;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Minimal serialization of a step suitable for the agent.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} step - Runtime step.
|
|
205
|
+
* @param {number} index - Step index in the workflow.
|
|
206
|
+
* @returns {object} Plain object for JSON serialization.
|
|
207
|
+
*/
|
|
208
|
+
function serializeStep(step, index) {
|
|
209
|
+
const base = {
|
|
210
|
+
name: step.name,
|
|
211
|
+
type: step.stepType,
|
|
212
|
+
index
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (step.stepType === "run") {
|
|
216
|
+
base.command = step.run;
|
|
217
|
+
} else if (step.stepType === "gate") {
|
|
218
|
+
base.gate = step.gate;
|
|
219
|
+
base.gateAction = step.gateAction ?? "fail";
|
|
220
|
+
if (step.message) base.message = step.message;
|
|
221
|
+
} else if (step.stepType === "subflow") {
|
|
222
|
+
base.subflow = step.subflow;
|
|
223
|
+
if (step.with) base.with = step.with;
|
|
224
|
+
} else if (step.stepType === "interactive") {
|
|
225
|
+
base.command = step.interactive;
|
|
226
|
+
} else if (step.stepType === "wait") {
|
|
227
|
+
base.wait = step.wait;
|
|
228
|
+
} else if (step.stepType === "agent") {
|
|
229
|
+
base.agent = {
|
|
230
|
+
provider: step.agent.provider,
|
|
231
|
+
prompt: step.agent.prompt,
|
|
232
|
+
mode: step.agent.mode ?? "autonomous",
|
|
233
|
+
...(step.agent.model ? { model: step.agent.model } : {}),
|
|
234
|
+
...(step.agent.session ? { session: step.agent.session } : {})
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return base;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check whether a step is ready to execute given current state.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} step - Runtime step.
|
|
245
|
+
* @param {Map<string, object>} stepStates - Completed/failed/skipped step states.
|
|
246
|
+
* @returns {boolean} True when depends_on is satisfied.
|
|
247
|
+
*/
|
|
248
|
+
function isStepReady(step, stepStates) {
|
|
249
|
+
if (!step.dependsOn || step.dependsOn.length === 0) return true;
|
|
250
|
+
return step.dependsOn.every((dep) => stepStates.get(dep)?.status === "completed");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Return the first-unresolved step plus any siblings that belong in the same
|
|
255
|
+
* parallel batch.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} workflow - Loaded workflow.
|
|
258
|
+
* @param {Map<string, object>} stepStates - Current step states.
|
|
259
|
+
* @returns {object[]} Eligible step list (empty when workflow is done).
|
|
260
|
+
*/
|
|
261
|
+
function findEligibleSteps(workflow, stepStates) {
|
|
262
|
+
const firstPending = workflow.steps.findIndex((step) => !stepStates.has(step.name));
|
|
263
|
+
|
|
264
|
+
if (firstPending === -1) return [];
|
|
265
|
+
|
|
266
|
+
const head = workflow.steps[firstPending];
|
|
267
|
+
|
|
268
|
+
if (!isStepReady(head, stepStates)) return [];
|
|
269
|
+
|
|
270
|
+
if (head.parallel !== true && head.parallelGroup === undefined) {
|
|
271
|
+
return [{ step: head, index: firstPending }];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const group = head.parallelGroup;
|
|
275
|
+
const batch = [];
|
|
276
|
+
|
|
277
|
+
for (let i = firstPending; i < workflow.steps.length; i += 1) {
|
|
278
|
+
const step = workflow.steps[i];
|
|
279
|
+
|
|
280
|
+
if (stepStates.has(step.name)) continue;
|
|
281
|
+
|
|
282
|
+
const inGroup = group === undefined
|
|
283
|
+
? step.parallel === true && step.parallelGroup === undefined
|
|
284
|
+
: step.parallelGroup === group;
|
|
285
|
+
|
|
286
|
+
if (!inGroup) break;
|
|
287
|
+
if (!isStepReady(step, stepStates)) continue;
|
|
288
|
+
|
|
289
|
+
batch.push({ step, index: i });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return batch;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* `flow session-bridge next` — return the next eligible step(s).
|
|
297
|
+
*/
|
|
298
|
+
async function subcommandNext(args, stdout, stderr, options) {
|
|
299
|
+
const { runId } = resolveRunId(args, options.env);
|
|
300
|
+
const { workflow, events } = await loadRunContext(options.cwd, runId);
|
|
301
|
+
const stepStates = computeStepStates(events);
|
|
302
|
+
const eligible = findEligibleSteps(workflow, stepStates);
|
|
303
|
+
const remaining = workflow.steps.length - stepStates.size;
|
|
304
|
+
|
|
305
|
+
if (eligible.length === 0) {
|
|
306
|
+
stdout(JSON.stringify({
|
|
307
|
+
step: null,
|
|
308
|
+
steps: [],
|
|
309
|
+
remaining,
|
|
310
|
+
workflow_status: remaining === 0 ? "completed" : "blocked"
|
|
311
|
+
}));
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
stdout(JSON.stringify({
|
|
316
|
+
step: eligible.length === 1 ? serializeStep(eligible[0].step, eligible[0].index) : null,
|
|
317
|
+
steps: eligible.map((entry) => serializeStep(entry.step, entry.index)),
|
|
318
|
+
remaining,
|
|
319
|
+
workflow_status: "running"
|
|
320
|
+
}));
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `flow session-bridge update <step> <status>` — persist a step result.
|
|
326
|
+
*/
|
|
327
|
+
async function subcommandUpdate(args, stdout, stderr, options) {
|
|
328
|
+
const { runId, remaining } = resolveRunId(args, options.env);
|
|
329
|
+
const [stepName, status, ...rest] = remaining;
|
|
330
|
+
|
|
331
|
+
if (!stepName || !status) {
|
|
332
|
+
throw new WorkflowError("flow session-bridge update requires <step-name> <status>.");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!VALID_STATUSES.has(status)) {
|
|
336
|
+
throw new WorkflowError(
|
|
337
|
+
`flow session-bridge update status must be one of: ${[...VALID_STATUSES].join(", ")}.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let output = "";
|
|
342
|
+
let exitCode;
|
|
343
|
+
let reason;
|
|
344
|
+
|
|
345
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
346
|
+
if (rest[i] === "--output") {
|
|
347
|
+
output = rest[i + 1] ?? "";
|
|
348
|
+
i += 1;
|
|
349
|
+
} else if (rest[i] === "--exit-code") {
|
|
350
|
+
exitCode = Number.parseInt(rest[i + 1], 10);
|
|
351
|
+
if (!Number.isFinite(exitCode)) {
|
|
352
|
+
throw new WorkflowError("--exit-code must be an integer.");
|
|
353
|
+
}
|
|
354
|
+
i += 1;
|
|
355
|
+
} else if (rest[i] === "--reason") {
|
|
356
|
+
reason = rest[i + 1] ?? "";
|
|
357
|
+
i += 1;
|
|
358
|
+
} else {
|
|
359
|
+
throw new WorkflowError(`Unknown update flag: ${rest[i]}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const { workflow, eventLogPath } = await loadRunContext(options.cwd, runId);
|
|
364
|
+
const workflowStep = workflow.steps.find((step) => step.name === stepName);
|
|
365
|
+
|
|
366
|
+
if (!workflowStep) {
|
|
367
|
+
throw new WorkflowError(`Step '${stepName}' is not defined in workflow.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const stepIndex = workflow.steps.indexOf(workflowStep);
|
|
371
|
+
const resolvedExit = exitCode ?? (status === "completed" ? 0 : 1);
|
|
372
|
+
const eventType = status === "completed"
|
|
373
|
+
? "step_completed"
|
|
374
|
+
: status === "skipped"
|
|
375
|
+
? "step_skipped"
|
|
376
|
+
: status === "deferred"
|
|
377
|
+
? "step_deferred"
|
|
378
|
+
: "step_failed";
|
|
379
|
+
|
|
380
|
+
await appendEventLogEntryWithLock(eventLogPath, {
|
|
381
|
+
type: eventType,
|
|
382
|
+
timestamp: new Date().toISOString(),
|
|
383
|
+
step_name: stepName,
|
|
384
|
+
step_index: stepIndex,
|
|
385
|
+
exit_code: resolvedExit,
|
|
386
|
+
stdout: output,
|
|
387
|
+
stderr: "",
|
|
388
|
+
via: "session-bridge",
|
|
389
|
+
...(reason === undefined ? {} : { reason })
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
stdout(JSON.stringify({ ok: true, step: stepName, status, exit_code: resolvedExit }));
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `flow session-bridge gate <step>` — evaluate a gate step synchronously.
|
|
398
|
+
*/
|
|
399
|
+
async function subcommandGate(args, stdout, stderr, options) {
|
|
400
|
+
const { runId, remaining } = resolveRunId(args, options.env);
|
|
401
|
+
const [stepName] = remaining;
|
|
402
|
+
|
|
403
|
+
if (!stepName) {
|
|
404
|
+
throw new WorkflowError("flow session-bridge gate requires <step-name>.");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { workflow, events, eventLogPath } = await loadRunContext(options.cwd, runId);
|
|
408
|
+
const step = workflow.steps.find((entry) => entry.name === stepName);
|
|
409
|
+
|
|
410
|
+
if (!step) {
|
|
411
|
+
throw new WorkflowError(`Step '${stepName}' not found.`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (step.stepType !== "gate") {
|
|
415
|
+
throw new WorkflowError(`Step '${stepName}' is not a gate step (type: ${step.stepType}).`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const result = evaluateSessionGate(step, events, options.env);
|
|
419
|
+
|
|
420
|
+
await appendEventLogEntryWithLock(eventLogPath, {
|
|
421
|
+
type: result.passed ? "step_completed" : (step.gateAction === "warn" ? "step_completed" : "step_failed"),
|
|
422
|
+
timestamp: new Date().toISOString(),
|
|
423
|
+
step_name: stepName,
|
|
424
|
+
step_index: workflow.steps.indexOf(step),
|
|
425
|
+
exit_code: result.passed ? 0 : 1,
|
|
426
|
+
stdout: result.message ?? "",
|
|
427
|
+
stderr: "",
|
|
428
|
+
via: "session-bridge",
|
|
429
|
+
gate_passed: result.passed,
|
|
430
|
+
gate_action: step.gateAction ?? "fail"
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
stdout(JSON.stringify({
|
|
434
|
+
passed: result.passed,
|
|
435
|
+
action: step.gateAction ?? "fail",
|
|
436
|
+
message: result.message ?? null,
|
|
437
|
+
workflow_should_stop: !result.passed && (step.gateAction ?? "fail") === "fail"
|
|
438
|
+
}));
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* `flow session-bridge ask <prompt>` — read one line from stdin.
|
|
444
|
+
*/
|
|
445
|
+
async function subcommandAsk(args, stdout, stderr, options) {
|
|
446
|
+
const { remaining } = resolveRunId(args, options.env);
|
|
447
|
+
const prompt = remaining.join(" ").trim();
|
|
448
|
+
|
|
449
|
+
if (prompt === "") {
|
|
450
|
+
throw new WorkflowError("flow session-bridge ask requires a prompt message.");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const rl = readline.createInterface({
|
|
454
|
+
input: options.stdin ?? process.stdin,
|
|
455
|
+
output: process.stderr,
|
|
456
|
+
terminal: false
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
stderr(`${prompt}\n> `);
|
|
461
|
+
const response = await rl.question("");
|
|
462
|
+
stdout(JSON.stringify({ response: response.trim() }));
|
|
463
|
+
return 0;
|
|
464
|
+
} finally {
|
|
465
|
+
rl.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* `flow session-bridge wait <step>` — block until the wait step's trigger fires.
|
|
471
|
+
*/
|
|
472
|
+
async function subcommandWait(args, stdout, stderr, options) {
|
|
473
|
+
const { runId, remaining } = resolveRunId(args, options.env);
|
|
474
|
+
const [stepName] = remaining;
|
|
475
|
+
|
|
476
|
+
if (!stepName) {
|
|
477
|
+
throw new WorkflowError("flow session-bridge wait requires <step-name>.");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const { workflow, eventLogPath } = await loadRunContext(options.cwd, runId);
|
|
481
|
+
const step = workflow.steps.find((entry) => entry.name === stepName);
|
|
482
|
+
|
|
483
|
+
if (!step || step.stepType !== "wait") {
|
|
484
|
+
throw new WorkflowError(`Step '${stepName}' is not a wait step.`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const timeoutMs = step.wait.timeout
|
|
488
|
+
? parseDurationMs(step.wait.timeout)
|
|
489
|
+
: DEFAULT_WAIT_TIMEOUT_MS;
|
|
490
|
+
const pollMs = step.wait.pollInterval ? parseDurationMs(step.wait.pollInterval) : DEFAULT_POLL_MS;
|
|
491
|
+
const deadline = Date.now() + timeoutMs;
|
|
492
|
+
|
|
493
|
+
while (Date.now() < deadline) {
|
|
494
|
+
if (step.wait.trigger === "file") {
|
|
495
|
+
const triggerPath = path.isAbsolute(step.wait.path)
|
|
496
|
+
? step.wait.path
|
|
497
|
+
: path.join(options.cwd, step.wait.path);
|
|
498
|
+
try {
|
|
499
|
+
await readFile(triggerPath);
|
|
500
|
+
stdout(JSON.stringify({ fired: true, trigger: "file", path: step.wait.path }));
|
|
501
|
+
return await markWaitFired(eventLogPath, workflow, step);
|
|
502
|
+
} catch {
|
|
503
|
+
// not yet
|
|
504
|
+
}
|
|
505
|
+
} else if (step.wait.trigger === "signal") {
|
|
506
|
+
const events = await readWorkflowRunEvents(options.cwd, runId);
|
|
507
|
+
const signal = events.findLast((event) =>
|
|
508
|
+
event.type === "step_signaled" && event.step_name === stepName
|
|
509
|
+
);
|
|
510
|
+
if (signal) {
|
|
511
|
+
stdout(JSON.stringify({ fired: true, trigger: "signal", data: signal.data ?? null }));
|
|
512
|
+
return await markWaitFired(eventLogPath, workflow, step);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
await new Promise((resolve) => { setTimeout(resolve, pollMs); });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
stderr(`Wait step '${stepName}' timed out after ${step.wait.timeout ?? "1h"}.`);
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Mark a wait step as completed after its trigger fires.
|
|
525
|
+
*/
|
|
526
|
+
async function markWaitFired(eventLogPath, workflow, step) {
|
|
527
|
+
await appendEventLogEntryWithLock(eventLogPath, {
|
|
528
|
+
type: "step_completed",
|
|
529
|
+
timestamp: new Date().toISOString(),
|
|
530
|
+
step_name: step.name,
|
|
531
|
+
step_index: workflow.steps.indexOf(step),
|
|
532
|
+
exit_code: 0,
|
|
533
|
+
stdout: "",
|
|
534
|
+
stderr: "",
|
|
535
|
+
via: "session-bridge"
|
|
536
|
+
});
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* `flow session-bridge status` — full state snapshot.
|
|
542
|
+
*/
|
|
543
|
+
async function subcommandStatus(args, stdout, stderr, options) {
|
|
544
|
+
const { runId } = resolveRunId(args, options.env);
|
|
545
|
+
const { workflow, events } = await loadRunContext(options.cwd, runId);
|
|
546
|
+
const stepStates = computeStepStates(events);
|
|
547
|
+
|
|
548
|
+
const stepSummary = workflow.steps.map((step, index) => {
|
|
549
|
+
const state = stepStates.get(step.name);
|
|
550
|
+
return {
|
|
551
|
+
name: step.name,
|
|
552
|
+
type: step.stepType,
|
|
553
|
+
index,
|
|
554
|
+
status: state?.status ?? "pending",
|
|
555
|
+
...(state ? { exit_code: state.exitCode, stdout: state.stdout } : {})
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
stdout(JSON.stringify({
|
|
560
|
+
run_id: runId,
|
|
561
|
+
workflow_name: workflow.name,
|
|
562
|
+
mode: workflow.mode,
|
|
563
|
+
total_steps: workflow.steps.length,
|
|
564
|
+
completed: stepSummary.filter((entry) => entry.status === "completed").length,
|
|
565
|
+
failed: stepSummary.filter((entry) => entry.status === "failed").length,
|
|
566
|
+
pending: stepSummary.filter((entry) => entry.status === "pending").length,
|
|
567
|
+
steps: stepSummary
|
|
568
|
+
}));
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* `flow session-bridge log <message>` — append a bridge_log event.
|
|
574
|
+
*/
|
|
575
|
+
async function subcommandLog(args, stdout, stderr, options) {
|
|
576
|
+
const { runId, remaining } = resolveRunId(args, options.env);
|
|
577
|
+
const message = remaining.join(" ");
|
|
578
|
+
|
|
579
|
+
if (!message.trim()) {
|
|
580
|
+
throw new WorkflowError("flow session-bridge log requires a non-empty message.");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const { eventLogPath } = await loadRunContext(options.cwd, runId);
|
|
584
|
+
|
|
585
|
+
await appendEventLogEntryWithLock(eventLogPath, {
|
|
586
|
+
type: "bridge_log",
|
|
587
|
+
timestamp: new Date().toISOString(),
|
|
588
|
+
message
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
stdout(JSON.stringify({ ok: true }));
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `flow session-bridge compact` — return a condensed summary of completed steps.
|
|
597
|
+
*/
|
|
598
|
+
async function subcommandCompact(args, stdout, stderr, options) {
|
|
599
|
+
const { runId } = resolveRunId(args, options.env);
|
|
600
|
+
const { workflow, events } = await loadRunContext(options.cwd, runId);
|
|
601
|
+
const stepStates = computeStepStates(events);
|
|
602
|
+
|
|
603
|
+
const summary = workflow.steps
|
|
604
|
+
.filter((step) => stepStates.get(step.name)?.status === "completed")
|
|
605
|
+
.map((step) => {
|
|
606
|
+
const state = stepStates.get(step.name);
|
|
607
|
+
return {
|
|
608
|
+
name: step.name,
|
|
609
|
+
type: step.stepType,
|
|
610
|
+
exit_code: state.exitCode,
|
|
611
|
+
output_preview: (state.stdout ?? "").slice(0, 200)
|
|
612
|
+
};
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
stdout(JSON.stringify({
|
|
616
|
+
workflow_name: workflow.name,
|
|
617
|
+
completed_steps: summary.length,
|
|
618
|
+
summary
|
|
619
|
+
}));
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Parse a duration string like "500ms", "30s", "5m", "1h" to milliseconds.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} value - Duration string.
|
|
627
|
+
* @returns {number} Milliseconds.
|
|
628
|
+
*/
|
|
629
|
+
function parseDurationMs(value) {
|
|
630
|
+
const match = /^(\d+)(ms|s|m|h)$/.exec(value.trim());
|
|
631
|
+
|
|
632
|
+
if (!match) {
|
|
633
|
+
throw new WorkflowError(`Invalid duration: ${value}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const num = Number.parseInt(match[1], 10);
|
|
637
|
+
switch (match[2]) {
|
|
638
|
+
case "ms": return num;
|
|
639
|
+
case "s": return num * 1000;
|
|
640
|
+
case "m": return num * 60 * 1000;
|
|
641
|
+
case "h": return num * 60 * 60 * 1000;
|
|
642
|
+
default: throw new WorkflowError(`Invalid duration unit: ${match[2]}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the system prompt injected into a session-mode claude spawn.
|
|
3
|
+
*
|
|
4
|
+
* The prompt teaches the agent the session protocol: always call
|
|
5
|
+
* `flow session-bridge next` to get the next step, execute it, and report
|
|
6
|
+
* back via `flow session-bridge update`. Covers step types, conversational
|
|
7
|
+
* mode, and failure handling.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} workflow - Loaded workflow (must include `session` block).
|
|
10
|
+
* @param {string} runId - Run ID for this session.
|
|
11
|
+
* @returns {string} System prompt text.
|
|
12
|
+
*/
|
|
13
|
+
export function buildSessionSystemPrompt(workflow, runId) {
|
|
14
|
+
const custom = workflow.session?.systemPrompt?.trim();
|
|
15
|
+
const stepCount = workflow.steps.length;
|
|
16
|
+
const stepList = workflow.steps
|
|
17
|
+
.map((step, index) => {
|
|
18
|
+
const summary = step.stepType === "agent"
|
|
19
|
+
? `agent(${step.agent.provider}, mode=${step.agent.mode ?? "autonomous"})`
|
|
20
|
+
: step.stepType;
|
|
21
|
+
return ` ${index + 1}. ${step.name} — ${summary}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
24
|
+
|
|
25
|
+
return `You are driving a lilflow workflow in session mode. Every step must be
|
|
26
|
+
fetched, executed, and reported via the \`flow session-bridge\` CLI. The
|
|
27
|
+
active run ID is already set in \`FLOW_RUN_ID\` — you do not need to pass it.
|
|
28
|
+
|
|
29
|
+
Workflow: ${workflow.name}${workflow.version ? ` (v${workflow.version})` : ""}
|
|
30
|
+
Steps (${stepCount}):
|
|
31
|
+
${stepList}
|
|
32
|
+
|
|
33
|
+
## Execution protocol
|
|
34
|
+
|
|
35
|
+
1. Call \`flow session-bridge next\` to get the next step.
|
|
36
|
+
2. Execute the step according to its \`type\`:
|
|
37
|
+
- \`run\`: execute \`command\` via the Bash tool.
|
|
38
|
+
- \`agent\` with \`mode: autonomous\`: execute the prompt yourself (you are the agent).
|
|
39
|
+
- \`agent\` with \`mode: conversational\`: work with the user iteratively; only
|
|
40
|
+
mark complete after they confirm satisfaction.
|
|
41
|
+
- \`agent\` with a foreign provider: mark the step \`deferred\` and continue.
|
|
42
|
+
- \`gate\`: call \`flow session-bridge gate <name>\`.
|
|
43
|
+
- \`interactive\`: call \`flow session-bridge ask <prompt>\`.
|
|
44
|
+
- \`wait\`: call \`flow session-bridge wait <name>\`.
|
|
45
|
+
3. Report the result: \`flow session-bridge update <name> completed --output "..." --exit-code 0\`
|
|
46
|
+
or \`flow session-bridge update <name> failed --reason "..."\`.
|
|
47
|
+
4. If a gate returns \`workflow_should_stop: true\`, stop and report to the user.
|
|
48
|
+
5. Repeat until \`flow session-bridge next\` reports \`workflow_status: "completed"\`.
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- Never infer the workflow. Always ask the bridge.
|
|
53
|
+
- Never fabricate step names or results. The event log is authoritative.
|
|
54
|
+
- For \`mode: conversational\` steps, do not call \`update\` without user interaction.
|
|
55
|
+
- If unsure about a step, call \`flow session-bridge status\` or \`flow session-bridge log "<note>"\`.
|
|
56
|
+
${custom ? `\n## Workflow-specific instructions\n\n${custom}\n` : ""}
|
|
57
|
+
The run ID for this session is: ${runId}
|
|
58
|
+
Start by calling \`flow session-bridge next\`.`;
|
|
59
|
+
}
|