Files
lobster/src/cli.ts
T
xingzhouandPeter Steinberger c440ca57d1 fix(runtime): cancelled workflows no longer continue external commands (#119)
* fix(runtime): stop process-backed work on cancellation

* fix(runtime): invalidate cancelled resume state

* Revert "fix(runtime): invalidate cancelled resume state"

This reverts commit 0f7d291f98.

* fix(runtime): narrow cancellation to safe child processes

* fix(runtime): stop after completed search cancellation

* fix(runtime): halt direct pipelines after cancellation

Preserve completed in-flight stage results while preventing later direct pipeline stages from starting after parent cancellation.

* fix(runtime): consume aborted approval resumes

* fix(runtime): cancelled workflows no longer continue external commands

* fix(workflow): propagate custom parent cancellation

* fix(runtime): preserve pre-aborted resume state

* fix(runtime): stop lazy handoff after cancellation

* fix(workflow): close remaining cancellation boundaries

* fix(runtime): preserve workflow resumes during setup cancellation

* fix(runtime): close final cancellation persistence gaps

* fix(runtime): stop lazy handoff after cancellation

* fix(runtime): interrupt blocked lazy handoff reads

* fix(runtime): terminate cancellation process trees

* fix(runtime): await process tree termination

* fix(runtime): terminate workflow process trees

* fix(runtime): bridge CLI cancellation

* fix(cli): preserve cancellation lifecycle

* fix(cli): abort stalled signal-aware commands

* fix(cli): release aborted interactive prompts

* fix(cli): preserve sequential prompt input

* fix(cli): preserve buffered prompt input

* fix(cli): handle prompt EOF after buffered input

* fix(runtime): preserve UTF-8 subprocess output

* fix(workflow): preserve retryable resume before execution

* fix(workflow): roll back cancelled resume replacement

* fix(state): roll back cancelled monitor snapshot

* fix(resume): preserve cancelled gate capabilities

* fix(resume): close cancellation rollback windows

* fix(resume): harden cancellation state cleanup

* fix(runtime): close resumed cancellation gaps

* fix(runtime): preserve cancellation cleanup

* fix(runtime): close cancellation lifecycle gaps

* fix(runtime): harden resumed cancellation boundaries

* fix(workflow): consume timed-out resume capabilities

* fix(workflow): preserve resume policy boundaries

* fix(runtime): preserve cancellation cleanup liveness

* fix(runtime): harden cancellation cleanup

* fix(runtime): stop lazy output after cancellation

* fix(runtime): settle cancellation cleanup

* fix(runtime): preserve safe input resumes

* fix(runtime): prevent consumed resume replays

* fix(runtime): serialize approval resume consumption

* fix(runtime): prevent concurrent safe gate forks

* fix(runtime): close cancellation review gaps

* fix(llm): restore cache after cancelled refresh

* fix(runtime): close remaining cancellation windows

* fix(runtime): preserve cancellation recovery invariants

* fix(runtime): prevent stale resume recovery

* fix(runtime): preserve resume claim recovery

* fix(runtime): retry pre-dispatch claims safely

* fix(state): synchronize rollback-safe reads

* fix(resume): discard cancelled pipeline successors

* fix(runtime): harden cancellation and state locking

* fix(runtime): recover durable cancellation failures

* fix(runtime): preserve legacy workflow cancellation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-13 10:54:20 -07:00

748 lines
18 KiB
TypeScript

import { parsePipeline } from "./parser.js";
import { createDefaultRegistry } from "./commands/registry.js";
import { runPipeline } from "./runtime.js";
import { parseResumeArgs } from "./resume.js";
import { resumeToolRequest } from "./core/tool_runtime.js";
import { loadWorkflowFile, resolveWorkflowArgs, runWorkflowFile } from "./workflows/file.js";
import { renderWorkflowGraph } from "./workflows/graph.js";
import type { WorkflowGraphFormat } from "./workflows/graph.js";
import { finalizePipelineToolRun } from "./pipeline_resume_state.js";
import { forceTerminateAbortableProcesses } from "./abortable_process.js";
export async function runCli(argv) {
const cancellation = createCliCancellation();
try {
await runCliWithSignal(argv, cancellation.signal, cancellation.signal);
} finally {
if (cancellation.exitCode !== undefined) {
process.exitCode = cancellation.exitCode;
}
cancellation.dispose();
}
}
async function runCliWithSignal(
argv,
signal: AbortSignal,
forceTerminationSignal: AbortSignal = signal,
) {
const registry = createDefaultRegistry();
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
process.stdout.write(helpText());
return;
}
if (argv[0] === "help") {
const topic = argv[1];
if (!topic) {
process.stdout.write(helpText());
return;
}
const cmd = registry.get(topic);
if (!cmd) {
process.stderr.write(`Unknown command: ${topic}\n`);
process.exitCode = 2;
return;
}
process.stdout.write(cmd.help());
return;
}
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
process.stdout.write(`${await readVersion()}\n`);
return;
}
if (argv[0] === "doctor") {
await handleDoctor({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
return;
}
if (argv[0] === "graph") {
await handleGraph({ argv: argv.slice(1) });
return;
}
if (argv[0] === "run") {
await handleRun({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
return;
}
if (argv[0] === "resume") {
await handleResume({ argv: argv.slice(1), registry, signal, forceTerminationSignal });
return;
}
// Default: treat argv as a pipeline string.
await handleRun({ argv, registry, signal, forceTerminationSignal });
}
function createCliCancellation() {
const controller = new AbortController();
let received: NodeJS.Signals | undefined;
const terminatingSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
const abort = (receivedSignal: NodeJS.Signals) => {
if (received) {
forceTerminateAbortableProcesses(controller.signal);
return;
}
received = receivedSignal;
controller.abort(new Error(`Received ${receivedSignal}`));
};
const listeners = new Map<NodeJS.Signals, () => void>();
for (const terminatingSignal of terminatingSignals) {
const listener = () => abort(terminatingSignal);
listeners.set(terminatingSignal, listener);
process.on(terminatingSignal, listener);
}
return {
signal: controller.signal,
get exitCode() {
if (!received) return undefined;
return (
{
SIGINT: 130,
SIGHUP: 129,
SIGQUIT: 131,
SIGTERM: 143,
} as const
)[received];
},
dispose() {
for (const [terminatingSignal, listener] of listeners) {
process.removeListener(terminatingSignal, listener);
}
},
};
}
async function handleGraph({ argv }) {
const parsed = parseGraphArgs(argv);
if (parsed.help) {
process.stdout.write(graphHelpText());
return;
}
if (!parsed.filePath) {
process.stderr.write("graph requires a workflow file path (use --file <path>)\n");
process.exitCode = 2;
return;
}
if (!isWorkflowGraphFormat(parsed.format)) {
process.stderr.write("graph --format must be one of: mermaid, dot, ascii\n");
process.exitCode = 2;
return;
}
let argsJson: Record<string, unknown> = {};
if (parsed.argsJson) {
try {
const value = JSON.parse(parsed.argsJson);
if (!value || typeof value !== "object" || Array.isArray(value)) {
process.stderr.write("graph --args-json must be a JSON object\n");
process.exitCode = 2;
return;
}
argsJson = value as Record<string, unknown>;
} catch {
process.stderr.write("graph --args-json must be valid JSON\n");
process.exitCode = 2;
return;
}
}
let filePath: string;
try {
filePath = await resolveWorkflowFile(parsed.filePath);
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
return;
}
try {
const workflow = await loadWorkflowFile(filePath);
const args = resolveWorkflowArgs(workflow.args, argsJson);
const graph = renderWorkflowGraph({ workflow, format: parsed.format, args });
process.stdout.write(graph);
process.stdout.write("\n");
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
}
}
function isWorkflowGraphFormat(value: string): value is WorkflowGraphFormat {
return value === "mermaid" || value === "dot" || value === "ascii";
}
async function handleRun({
argv,
registry,
signal,
forceTerminationSignal,
}: {
argv;
registry;
signal: AbortSignal;
forceTerminationSignal: AbortSignal;
}) {
const parsed = parseRunArgs(argv);
const { mode, argsJson } = parsed;
const normalizedMode = normalizeMode(mode);
const { rest, filePath, dryRun } = await resolveRunTarget(parsed);
const workflowFile = filePath
? await resolveWorkflowFile(filePath)
: await detectWorkflowFile(rest);
if (workflowFile) {
let parsedArgs = {};
if (argsJson) {
try {
parsedArgs = JSON.parse(argsJson);
} catch {
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: "run --args-json must be valid JSON" },
});
process.exitCode = 2;
return;
}
process.stderr.write("run --args-json must be valid JSON\n");
process.exitCode = 2;
return;
}
}
try {
const output = await runWorkflowFile({
filePath: workflowFile,
args: parsedArgs,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: normalizedMode,
registry,
dryRun,
signal,
forceTerminationSignal,
},
});
if (normalizedMode === "tool") {
if (output.status === "needs_approval") {
writeToolEnvelope({
ok: true,
status: "needs_approval",
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: null,
});
return;
}
if (output.status === "needs_input") {
writeToolEnvelope({
ok: true,
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: output.requiresInput ?? null,
});
return;
}
writeToolEnvelope({
ok: true,
status: "ok",
output: output.output,
requiresApproval: null,
requiresInput: null,
});
return;
}
if (output.status === "needs_approval" || output.status === "needs_input") {
process.stdout.write(
JSON.stringify(
{
status: output.status,
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: output.requiresInput ?? null,
},
null,
2,
),
);
process.stdout.write("\n");
return;
}
if (output.status === "ok" && output.output.length) {
process.stdout.write(JSON.stringify(output.output, null, 2));
process.stdout.write("\n");
}
return;
} catch (err) {
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
return;
}
}
const pipelineString = rest.join(" ");
let pipeline;
try {
pipeline = parsePipeline(pipelineString);
} catch (err) {
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
process.stderr.write(`Parse error: ${err?.message ?? String(err)}\n`);
process.exitCode = 2;
return;
}
try {
const output = await runPipeline({
pipeline,
registry,
input: [],
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: normalizedMode,
dryRun,
signal,
forceTerminationSignal,
haltAfterStageOnAbort: true,
});
if (normalizedMode === "tool") {
const finalized = await finalizePipelineToolRun({
env: process.env,
pipeline,
output,
signal,
});
writeToolEnvelope({
ok: true,
status: finalized.status,
output: finalized.output,
requiresApproval: finalized.requiresApproval,
requiresInput: finalized.requiresInput,
});
return;
}
if (output.halted && isPipelineInputRequest(output.items)) {
throw new Error("requestInput requires --mode tool when stdin is not interactive");
}
// Human mode: if the last command didn't render, print JSON.
if (!output.rendered) {
process.stdout.write(JSON.stringify(output.items, null, 2));
process.stdout.write("\n");
}
} catch (err) {
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
}
}
function isPipelineInputRequest(items) {
return (
items.length === 1 && items[0]?.type === "input_request" && items[0]?.commandInput !== undefined
);
}
function parseRunArgs(argv) {
const rest = [];
let mode = "human";
let filePath = null;
let argsJson = null;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
// Treat --dry-run as a Lobster flag only before positional command/pipeline
// args begin. Once rest has started, the token may belong to the command.
// Trailing workflow-file --dry-run is handled later after we can prove the
// first positional token is actually a workflow file.
if (tok === "--dry-run" && rest.length === 0) {
dryRun = true;
continue;
}
if (tok === "--mode") {
const value = argv[i + 1];
if (value) {
mode = value;
i++;
}
continue;
}
if (tok.startsWith("--mode=")) {
mode = tok.slice("--mode=".length) || "human";
continue;
}
if (tok === "--file") {
const value = argv[i + 1];
if (value) {
filePath = value;
i++;
}
continue;
}
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
continue;
}
if (tok === "--args-json") {
const value = argv[i + 1];
if (value) {
argsJson = value;
i++;
}
continue;
}
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
continue;
}
rest.push(tok);
}
return { mode, rest, filePath, argsJson, dryRun };
}
function parseGraphArgs(argv: string[]) {
const rest: string[] = [];
let filePath: string | null = null;
let format = "mermaid";
let argsJson: string | null = null;
let help = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === "-h" || tok === "--help") {
help = true;
continue;
}
if (tok === "--file") {
const value = argv[i + 1];
if (value) {
filePath = value;
i++;
}
continue;
}
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
continue;
}
if (tok === "--format") {
const value = argv[i + 1];
if (value) {
format = value;
i++;
}
continue;
}
if (tok.startsWith("--format=")) {
format = tok.slice("--format=".length) || "mermaid";
continue;
}
if (tok === "--args-json") {
const value = argv[i + 1];
if (value) {
argsJson = value;
i++;
}
continue;
}
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
continue;
}
rest.push(tok);
}
if (!filePath && rest.length > 0) {
filePath = rest[0];
}
return { filePath, format, argsJson, help };
}
async function resolveRunTarget(parsed: {
rest: string[];
filePath: string | null;
dryRun: boolean;
}) {
if (parsed.filePath) return parsed;
const restWithoutDryRun = parsed.rest.filter((token) => token !== "--dry-run");
if (restWithoutDryRun.length === 1 && restWithoutDryRun.length !== parsed.rest.length) {
try {
const workflowFile = await resolveWorkflowFile(restWithoutDryRun[0]);
return { ...parsed, filePath: workflowFile, rest: [], dryRun: true };
} catch {
return parsed;
}
}
return parsed;
}
function normalizeMode(mode) {
return mode === "tool" ? "tool" : "human";
}
async function detectWorkflowFile(rest) {
if (rest.length !== 1) return null;
const candidate = rest[0];
if (!candidate || candidate.includes("|")) return null;
try {
return await resolveWorkflowFile(candidate);
} catch {
return null;
}
}
async function resolveWorkflowFile(candidate) {
const { promises: fsp } = await import("node:fs");
const { resolve, extname, isAbsolute } = await import("node:path");
const resolved = isAbsolute(candidate) ? candidate : resolve(process.cwd(), candidate);
const stat = await fsp.stat(resolved);
if (!stat.isFile()) throw new Error("Workflow path is not a file");
const ext = extname(resolved).toLowerCase();
if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
}
return resolved;
}
async function handleResume({
argv,
registry,
signal,
forceTerminationSignal,
}: {
argv;
registry;
signal: AbortSignal;
forceTerminationSignal: AbortSignal;
}) {
let parsed;
try {
parsed = parseResumeArgs(argv);
} catch (err) {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
let envelope;
try {
envelope = await resumeToolRequest({
token: parsed.token ?? undefined,
approvalId: parsed.approvalId ?? undefined,
approved: parsed.approved,
response: parsed.response,
cancel: parsed.cancel,
ctx: {
cwd: process.cwd(),
env: process.env,
mode: "tool",
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
registry,
signal,
forceTerminationSignal,
},
});
} catch (err: any) {
envelope = {
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
};
}
writeToolEnvelope(envelope);
if (!envelope.ok) {
process.exitCode = envelope.error?.type === "parse_error" ? 2 : 1;
}
}
async function readVersion() {
const { readFile } = await import("node:fs/promises");
const { fileURLToPath } = await import("node:url");
const { dirname, join } = await import("node:path");
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(here, "..", "..", "package.json");
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
return pkg.version ?? "0.0.0";
}
async function handleDoctor({
argv,
registry,
signal,
forceTerminationSignal,
}: {
argv;
registry;
signal: AbortSignal;
forceTerminationSignal: AbortSignal;
}) {
const mode = "tool";
const pipeline = "exec --json --shell 'echo [1]'";
const output: any = await (async () => {
try {
const parsed = parsePipeline(pipeline);
return await runPipeline({
pipeline: parsed,
registry,
input: [],
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode,
signal,
forceTerminationSignal,
haltAfterStageOnAbort: true,
});
} catch (err: any) {
return { error: err };
}
})();
if (output?.error) {
writeToolEnvelope({
ok: false,
error: { type: "doctor_error", message: output.error?.message ?? String(output.error) },
});
process.exitCode = 1;
return;
}
writeToolEnvelope({
ok: true,
status: "ok",
output: [
{
toolMode: true,
protocolVersion: 1,
version: await readVersion(),
notes: argv.length ? argv : undefined,
},
],
requiresApproval: null,
requiresInput: null,
});
}
function writeToolEnvelope(payload) {
const envelope = {
protocolVersion: 1,
...payload,
};
process.stdout.write(JSON.stringify(envelope, null, 2));
process.stdout.write("\n");
}
function helpText() {
return (
`lobster — OpenClaw-native typed shell\n\n` +
`Usage:\n` +
` lobster '<pipeline>'\n` +
` lobster run --mode tool '<pipeline>'\n` +
` lobster run path/to/workflow.lobster\n` +
` lobster run --file path/to/workflow.lobster --args-json '{...}'\n` +
` lobster run --dry-run --file path/to/workflow.lobster\n` +
` lobster run --dry-run '<pipeline>'\n` +
` lobster graph --file path/to/workflow.lobster --format mermaid\n` +
` lobster graph --file path/to/workflow.lobster --format dot\n` +
` lobster graph --file path/to/workflow.lobster --format ascii\n` +
` lobster resume --token <token> --approve yes|no\n` +
` lobster resume --token <token> --response-json '{...}'\n` +
` lobster resume --token <token> --cancel\n` +
` lobster doctor\n` +
` lobster version\n` +
` lobster help <command>\n\n` +
`Flags:\n` +
` --dry-run Validate and print the execution plan without running anything\n\n` +
`Modes:\n` +
` - human (default): renderers can write to stdout\n` +
` - tool: prints a single JSON envelope for easy integration\n\n` +
`Examples:\n` +
` lobster 'exec --json "echo [1,2,3]" | json'\n` +
` lobster run --mode tool 'exec --json "echo [1]" | approve --prompt "ok?"'\n\n` +
`Commands:\n` +
` exec, head, json, pick, table, where, approve, ask, openclaw.agent, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run, graph\n`
);
}
function graphHelpText() {
return (
`lobster graph — render workflow step graphs\n\n` +
`Usage:\n` +
` lobster graph --file path/to/workflow.lobster [--format mermaid|dot|ascii] [--args-json '{...}']\n` +
` lobster graph path/to/workflow.lobster [--format mermaid|dot|ascii]\n\n` +
`Flags:\n` +
` --file Workflow file path (.lobster, .yaml, .yml, .json)\n` +
` --format Output format: mermaid (default), dot, ascii\n` +
` --args-json JSON object used to resolve workflow args for labels\n`
);
}