Files
lobster/test/workflow_file.test.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

2269 lines
60 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import http from "node:http";
import path from "node:path";
import os from "node:os";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runWorkflowFile } from "../src/workflows/file.js";
import { decodeResumeToken } from "../src/resume.js";
import { keyToPath, readStateJsonWithLock as readStateJson } from "../src/state/store.js";
function streamOf(items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
test("workflow file runs with approval and resume", async () => {
const workflow = {
name: "sample",
steps: [
{
id: "collect",
command: 'node -e "process.stdout.write(JSON.stringify([{value:1}]))"',
},
{
id: "mutate",
command:
"node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const items=JSON.parse(d);items[0].value=2;process.stdout.write(JSON.stringify(items));});\"",
stdin: "$collect.stdout",
},
{
id: "approve_step",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "finish",
command:
"node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const items=JSON.parse(d);process.stdout.write(JSON.stringify({done:true,value:items[0].value}));});\"",
stdin: "$mutate.stdout",
condition: "$approve_step.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval?.prompt, "Proceed?");
assert.ok(first.requiresApproval?.resumeToken);
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ done: true, value: 2 }]);
const stateFiles = await fsp.readdir(stateDir);
const resumeStateFiles = stateFiles.filter((name) => name.startsWith("workflow_resume_"));
assert.deepEqual(resumeStateFiles, []);
});
test("sequential workflow approvals reclaim superseded resume markers", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-sequential-approval-"));
try {
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{ id: "first", approval: "First?" },
{ id: "second", approval: "Second?" },
{ id: "finish", command: "node -e \"process.stdout.write('{}')\"" },
],
}),
"utf8",
);
const ctx = {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool" as const,
};
const first = await runWorkflowFile({ filePath, ctx });
assert.equal(first.status, "needs_approval");
const firstPayload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(firstPayload.kind, "workflow-file");
const second = await runWorkflowFile({
filePath,
ctx,
resume: firstPayload,
approved: true,
});
assert.equal(second.status, "needs_approval");
const secondPayload = decodeResumeToken(second.requiresApproval?.resumeToken ?? "");
assert.equal(secondPayload.kind, "workflow-file");
const completed = await runWorkflowFile({
filePath,
ctx,
resume: secondPayload,
approved: true,
});
assert.equal(completed.status, "ok");
const stateFiles = await fsp.readdir(stateDir);
assert.deepEqual(
stateFiles.filter((name) => name.startsWith("workflow_resume_")),
[],
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
test("workflow resume cancellation cleans up resume state", async () => {
const workflow = {
steps: [
{
id: "approve_step",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "finish",
command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
condition: "$approve_step.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-cancel-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
const cancelled = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: false,
});
assert.equal(cancelled.status, "cancelled");
assert.deepEqual(cancelled.output, []);
const files = await fsp.readdir(stateDir);
const resumeStateFiles = files.filter((name) => name.startsWith("workflow_resume_"));
assert.deepEqual(resumeStateFiles, []);
});
test("direct workflow resume consumes its capability after cancellation starts an effect", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-direct-cancel-"));
const stateDir = path.join(tmpDir, "state");
const effectPath = path.join(tmpDir, "effects.log");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "approve",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "effect",
run: `node -e "require('fs').appendFileSync(process.argv[1], 'run\\n'); setInterval(() => {}, 1000)" ${JSON.stringify(effectPath)}`,
condition: "$approve.approved",
},
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
const controller = new AbortController();
const resumed = runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
signal: controller.signal,
},
resume: payload,
approved: true,
});
for (let attempt = 0; attempt < 100; attempt++) {
try {
await fsp.access(effectPath);
break;
} catch {
if (attempt === 99) throw new Error("workflow effect did not start");
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
controller.abort(new Error("cancel after dispatch"));
await assert.rejects(() => resumed, /cancel after dispatch/);
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
const stateFiles = await fsp.readdir(stateDir);
assert.equal(
stateFiles.some((file) => file.startsWith("approval_")),
false,
);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/Workflow resume state not found/,
);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
});
test("workflow template setup failure preserves an unstarted approval capability", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-template-resume-"));
try {
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const broken = {
steps: [
{ id: "approve", approval: "Continue?" },
{
id: "effect",
run: "node -e \"process.stdout.write('should not run')\"",
stdin: "$missing.stdout",
},
],
};
await fsp.writeFile(filePath, JSON.stringify(broken), "utf8");
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/Unknown step reference: missing\.stdout/,
);
assert.notEqual(await readStateJson({ env, key: payload.stateKey! }), null);
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{ id: "approve", approval: "Continue?" },
{ id: "effect", run: "node -e \"process.stdout.write('ok')\"" },
],
}),
"utf8",
);
const retried = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(retried.status, "ok");
assert.deepEqual(retried.output, ["ok"]);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
test("workflow resume consumes its capability after a step timeout starts an effect", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-step-timeout-"));
const stateDir = path.join(tmpDir, "state");
const effectPath = path.join(tmpDir, "effects.log");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "approve",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "effect",
run: `node -e "require('fs').appendFileSync(process.argv[1], 'run\\n'); setInterval(() => {}, 1000)" ${JSON.stringify(effectPath)}`,
condition: "$approve.approved",
timeout_ms: 1500,
},
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/timed out|timeout|abort|cancel/i,
);
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/Workflow resume state not found/,
);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
});
test("workflow resume applies on_error after a timed-out effect", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-timeout-on-error-"));
const stateDir = path.join(tmpDir, "state");
const effectPath = path.join(tmpDir, "effects.log");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "approve",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "effect",
run: `node -e "require('fs').appendFileSync(process.argv[1], 'run\\n'); setInterval(() => {}, 1000)" ${JSON.stringify(effectPath)}`,
condition: "$approve.approved",
timeout_ms: 1500,
on_error: "continue",
},
{ id: "after", run: "echo continued" },
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, ["continued\n"]);
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
});
test("workflow resume retries a timed-out effect before consuming its capability", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-timeout-retry-"));
const stateDir = path.join(tmpDir, "state");
const attemptsPath = path.join(tmpDir, "attempts");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "approve",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "effect",
run: `node -e "const fs=require('fs'); const file=process.argv[1]; const attempt=fs.existsSync(file) ? Number(fs.readFileSync(file, 'utf8')) : 0; fs.writeFileSync(file, String(attempt + 1)); if (attempt === 0) setInterval(() => {}, 1000); else process.stdout.write('retried');" ${JSON.stringify(attemptsPath)}`,
condition: "$approve.approved",
timeout_ms: 1500,
retry: { max: 2, delay_ms: 10 },
},
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, ["retried"]);
assert.equal(await fsp.readFile(attemptsPath, "utf8"), "2");
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
});
test("workflow resume retries a claim blocked by a state lock before dispatch", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-claim-retry-"));
try {
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{ id: "approve", approval: "Proceed?" },
{
id: "effect",
run: "printf ran",
condition: "$approve.approved",
timeout_ms: 1000,
retry: { max: 2, delay_ms: 250 },
},
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
const lockPath = `${keyToPath(stateDir, payload.stateKey!)}.lock`;
const originalRename = fsp.rename;
const originalRm = fsp.rm;
let releasedInitialReadLocks = 0;
let lockReplaced!: () => void;
const replacedInitialReadLocks = new Promise<void>((resolve) => {
lockReplaced = resolve;
});
Object.defineProperty(fsp, "rename", {
configurable: true,
writable: true,
async value(
oldPath: Parameters<typeof fsp.rename>[0],
newPath: Parameters<typeof fsp.rename>[1],
) {
const result = await originalRename(oldPath, newPath);
if (String(oldPath) === lockPath && ++releasedInitialReadLocks === 2) {
await fsp.mkdir(lockPath);
await fsp.writeFile(
path.join(lockPath, "owner"),
`${process.pid}::live-writer\n`,
"utf8",
);
lockReplaced();
}
return result;
},
});
try {
const resumedRun = runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
});
await replacedInitialReadLocks;
const release = setTimeout(
() => void originalRm(lockPath, { recursive: true, force: true }),
1200,
);
const resumed = await resumedRun;
clearTimeout(release);
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, ["ran"]);
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
} finally {
Object.defineProperty(fsp, "rename", {
configurable: true,
writable: true,
value: originalRename,
});
await fsp.rm(lockPath, { recursive: true, force: true });
}
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
test("workflow resume consumes its capability after a parallel timeout starts an effect", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-parallel-timeout-"));
const stateDir = path.join(tmpDir, "state");
const effectPath = path.join(tmpDir, "effects.log");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "approve",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "effect",
condition: "$approve.approved",
parallel: {
timeout_ms: 1500,
branches: [
{
id: "side-effect",
run: `node -e "require('fs').appendFileSync(process.argv[1], 'run\\n'); setInterval(() => {}, 1000)" ${JSON.stringify(effectPath)}`,
},
],
},
},
],
}),
"utf8",
);
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/timed out|timeout|abort|cancel/i,
);
assert.equal(await readStateJson({ env, key: payload.stateKey! }), null);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
approved: true,
}),
/Workflow resume state not found/,
);
assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
});
test("workflow resume accepts workflow-resume_ state key aliases and cleans up state", async () => {
const workflow = {
steps: [
{
id: "approve_step",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "finish",
command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
condition: "$approve_step.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-alias-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
assert.ok(payload.stateKey?.startsWith("workflow_resume_"));
const aliasedPayload = {
...payload,
stateKey: (payload.stateKey ?? "").replace("workflow_resume_", "workflow-resume_"),
};
assert.ok(aliasedPayload.stateKey.startsWith("workflow-resume_"));
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: aliasedPayload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ done: true }]);
const files = await fsp.readdir(stateDir);
const resumeStateFiles = files.filter(
(name) => name.startsWith("workflow_resume_") || name.startsWith("workflow-resume_"),
);
assert.deepEqual(resumeStateFiles, []);
});
test("workflow file input steps pause and resume with structured responses", async () => {
const workflow = {
steps: [
{
id: "draft",
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
},
{
id: "review",
input: {
prompt: "Review draft?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION,subject:process.env.SUBJECT}))"',
env: {
DECISION: "$review.response.decision",
SUBJECT: "$review.subject.text",
},
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_input");
assert.deepEqual(first.requiresInput?.subject, { text: "hello" });
assert.ok(first.requiresInput?.resumeToken);
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const resumeEnv: Record<string, string | undefined> = { ...env };
delete resumeEnv.LONG_TEXT;
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: resumeEnv,
mode: "tool",
},
resume: payload,
response: { decision: "approve" },
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
});
test("workflow pipeline command input pauses and resumes the same pipeline step", async () => {
const schema = JSON.stringify({
type: "object",
properties: { decision: { type: "string", enum: ["approve", "reject"] } },
required: ["decision"],
});
const workflow = {
steps: [
{
id: "draft",
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
},
{
id: "review",
pipeline: `ask --subject-from-stdin --prompt 'Review draft?' --schema ${JSON.stringify(schema)} | pick decision`,
stdin: "$draft.json",
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION}))"',
env: {
DECISION: "$review.json.decision",
},
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-input-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry: createDefaultRegistry(),
},
});
assert.equal(first.status, "needs_input");
assert.deepEqual(first.requiresInput?.subject, { text: '{"text":"hello"}' });
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const state = (await readStateJson({ env, key: payload.stateKey! })) as any;
assert.equal(state.resumeAtIndex, 1);
assert.equal(state.inputKind, "pipeline_command");
assert.equal(state.inputStepId, "review");
assert.equal(state.pipelineInput.resumeAtIndex, 0);
assert.deepEqual(state.pipelineInput.items, [{ text: "hello" }]);
assert.deepEqual(state.pipelineInput.commandInput.pending.suspendedState, {
type: "ask",
subject: { text: '{"text":"hello"}' },
});
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry: createDefaultRegistry(),
},
resume: payload,
response: { decision: "approve" },
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ decision: "approve" }]);
});
test("workflow pipeline requestInput resume invariant bypasses on_error", async () => {
const schema = {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
};
let calls = 0;
let sideEffects = 0;
const choose = {
name: "choose",
meta: { resumeSafeBeforeInput: true },
async run({ ctx }: any) {
calls += 1;
if (calls > 1) return { output: streamOf([{ skipped: true }]) };
await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
return { output: streamOf([]) };
},
};
const side = {
name: "side",
async run() {
sideEffects += 1;
return { output: streamOf([{ sideEffects }]) };
},
};
const registry = {
get(name: string) {
return name === "choose" ? choose : name === "side" ? side : undefined;
},
list() {
return ["choose", "side"];
},
};
const workflow = {
name: "sample",
steps: [
{
id: "review",
pipeline: "choose",
on_error: "continue",
},
{
id: "side",
pipeline: "side",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-invariant-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_input");
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
await assert.rejects(
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: payload,
response: { decision: "approve" },
}),
/not consumed/,
);
assert.equal(sideEffects, 0);
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
});
test("workflow pipeline requestInput resume rejects changed pipeline", async () => {
const schema = {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
};
let sideEffects = 0;
const choose = {
name: "choose",
async run({ ctx }: any) {
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
return { output: streamOf([{ decision: response.decision }]) };
},
};
const side = {
name: "side",
async run({ input }: any) {
sideEffects += 1;
return { output: input };
},
};
const registry = {
get(name: string) {
return name === "choose" ? choose : name === "side" ? side : undefined;
},
list() {
return ["choose", "side"];
},
};
const workflow = {
name: "sample",
steps: [
{
id: "review",
pipeline: "choose | side",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-change-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_input");
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
workflow.steps[0].pipeline = "choose";
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
await assert.rejects(
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: payload,
response: { decision: "approve" },
}),
/pipeline changed/,
);
assert.equal(sideEffects, 0);
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
});
test("workflow pipeline requestInput keeps full pipeline across repeated suspensions", async () => {
const schema = {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
};
const produce = {
name: "produce",
async run() {
return { output: streamOf([{ id: 1 }]) };
},
};
const choose = {
name: "choose",
async run({ ctx }: any) {
const first = await ctx.requestInput({
prompt: "First?",
responseSchema: schema,
suspendedState: { phase: "first" },
});
const second = await ctx.requestInput({
prompt: `Second after ${first.decision}`,
responseSchema: schema,
suspendedState: { phase: "second" },
});
return { output: streamOf([{ first: first.decision, second: second.decision }]) };
},
};
const registry = {
get(name: string) {
return name === "produce" ? produce : name === "choose" ? choose : undefined;
},
list() {
return ["produce", "choose"];
},
};
const workflow = {
name: "sample",
steps: [
{
id: "review",
pipeline: "produce | choose",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-repeat-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_input");
const firstPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(firstPayload.kind, "workflow-file");
const second = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: firstPayload,
response: { decision: "approve" },
});
assert.equal(second.status, "needs_input");
const secondPayload = decodeResumeToken(second.requiresInput?.resumeToken ?? "");
assert.equal(secondPayload.kind, "workflow-file");
const state = (await readStateJson({ env, key: secondPayload.stateKey! })) as any;
assert.equal(state.pipelineInput.resumeAtIndex, 1);
assert.equal(state.pipelineInput.pipeline.length, 2);
const done = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: secondPayload,
response: { decision: "ship" },
});
assert.equal(done.status, "ok");
assert.deepEqual(done.output, [{ first: "approve", second: "ship" }]);
});
test("workflow pipeline requestInput resume rejects condition bypass", async () => {
const schema = {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
};
let sideEffects = 0;
const choose = {
name: "choose",
async run({ ctx }: any) {
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
return { output: streamOf([{ decision: response.decision }]) };
},
};
const side = {
name: "side",
async run() {
sideEffects += 1;
return { output: streamOf([{ sideEffects }]) };
},
};
const registry = {
get(name: string) {
return name === "choose" ? choose : name === "side" ? side : undefined;
},
list() {
return ["choose", "side"];
},
};
const workflow = {
name: "sample",
steps: [
{
id: "gate",
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
},
{
id: "review",
pipeline: "choose",
condition: "$gate.json.ok",
},
{
id: "side",
pipeline: "side",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-condition-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_input");
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
workflow.steps[1].condition = "$gate.json.missing";
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
await assert.rejects(
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: payload,
response: { decision: "approve" },
}),
/condition changed/,
);
assert.equal(sideEffects, 0);
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
});
test("workflow pipeline command input preserves replayable stdin without suspended state", async () => {
const schema = {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
};
const reviewCommand = {
name: "review_input",
async run({ input, ctx }: any) {
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
const items = [];
for await (const item of input) items.push(item);
return { output: streamOf([{ items, decision: response.decision }]) };
},
};
const registry = {
get(name: string) {
return name === reviewCommand.name ? reviewCommand : undefined;
},
list() {
return [reviewCommand.name];
},
};
async function runCase({
sourceStep,
stdin,
prefix,
}: {
sourceStep?: Record<string, unknown>;
stdin?: string;
prefix: string;
}) {
const steps = [
...(sourceStep ? [sourceStep] : []),
{
id: "review",
pipeline: "review_input",
...(stdin ? { stdin } : null),
},
];
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify({ name: "sample", steps }, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_input");
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const state = (await readStateJson({ env, key: payload.stateKey! })) as any;
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: payload,
response: { decision: "approve" },
});
return { state, resumed };
}
const noStdin = await runCase({ prefix: "lobster-workflow-pipeline-no-stdin-" });
assert.deepEqual(noStdin.state.pipelineInput.items, []);
assert.equal(noStdin.resumed.status, "ok");
assert.deepEqual(noStdin.resumed.output, [{ items: [], decision: "approve" }]);
const withArrayStdin = await runCase({
prefix: "lobster-workflow-pipeline-array-stdin-",
sourceStep: {
id: "draft",
run: 'node -e "process.stdout.write(JSON.stringify([{id:1}]))"',
},
stdin: "$draft.json",
});
assert.deepEqual(withArrayStdin.state.pipelineInput.items, [{ id: 1 }]);
assert.equal(withArrayStdin.resumed.status, "ok");
assert.deepEqual(withArrayStdin.resumed.output, [{ items: [{ id: 1 }], decision: "approve" }]);
});
test("workflow input resumes preserve the full subject even when the tool envelope preview is truncated", async () => {
const longText = "x".repeat(250_000);
const workflow = {
steps: [
{
id: "draft",
run: "node -e \"let data=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => data += chunk); process.stdin.on('end', () => process.stdout.write(JSON.stringify({text:data})))\"",
stdin: longText,
},
{
id: "review",
input: {
prompt: "Review draft?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "finish",
run: "node -e \"let data=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => data += chunk); process.stdin.on('end', () => process.stdout.write(JSON.stringify({subjectLength:data.length})))\"",
stdin: "$review.subject.text",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-truncate-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = {
...process.env,
LOBSTER_STATE_DIR: stateDir,
LOBSTER_MAX_TOOL_ENVELOPE_BYTES: "8192",
};
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_input");
assert.deepEqual(first.requiresInput?.subject, {
truncated: true,
bytes: Buffer.byteLength(JSON.stringify({ text: longText }), "utf8"),
preview: JSON.stringify({ text: longText }).slice(0, 2000),
});
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
response: { decision: "approve" },
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ subjectLength: longText.length }]);
});
test("workflow approval resumes require an explicit decision", async () => {
const workflow = {
steps: [
{
id: "approve_step",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
condition: "$approve_step.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-required-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: payload,
}),
/requires --approve yes\|no/i,
);
});
test("workflow approval can require a different approver than initiator", async () => {
const workflow = {
steps: [
{
id: "gate",
approval: {
prompt: "Proceed?",
require_different_approver: true,
},
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
when: "$gate.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-identity-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const baseEnv = {
...process.env,
LOBSTER_STATE_DIR: stateDir,
LOBSTER_APPROVAL_INITIATED_BY: "agent-1",
};
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: baseEnv,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval?.initiatedBy, "agent-1");
assert.equal(first.requiresApproval?.requireDifferentApprover, true);
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "agent-1" },
mode: "tool",
},
resume: payload,
approved: true,
}),
/must be granted by someone other than 'agent-1'/i,
);
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "human-1" },
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ done: true }]);
});
test("workflow approval can require a specific approver identity", async () => {
const workflow = {
steps: [
{
id: "gate",
approval: {
prompt: "Proceed?",
required_approver: "alice",
},
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
when: "$gate.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-required-approver-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval?.requiredApprover, "alice");
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "bob" },
mode: "tool",
},
resume: payload,
approved: true,
}),
/requires approver 'alice', got 'bob'/i,
);
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "alice" },
mode: "tool",
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ done: true }]);
});
test("workflow conditions support comparisons, boolean operators, and parentheses", async () => {
const workflow = {
steps: [
{
id: "collect",
run: "node -e \"process.stdout.write(JSON.stringify({kind:'deploy',count:2}))\"",
},
{
id: "review",
input: {
prompt: "Review draft?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "approve_step",
command:
"node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
approval: "required",
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
condition:
"($approve_step.approved && $review.response.decision == approve) && !($collect.json.kind != deploy || $collect.json.count != 2)",
},
{
id: "fallback",
run: 'node -e "process.stdout.write(JSON.stringify({ok:false}))"',
condition: "$review.response.decision == reject || $collect.json.kind == skip",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-conditions-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
});
assert.equal(first.status, "needs_input");
const inputPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
assert.equal(inputPayload.kind, "workflow-file");
const second = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: inputPayload,
response: { decision: "approve" },
});
assert.equal(second.status, "needs_approval");
const approvalPayload = decodeResumeToken(second.requiresApproval?.resumeToken ?? "");
assert.equal(approvalPayload.kind, "workflow-file");
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
},
resume: approvalPayload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ ok: true }]);
});
test("workflow conditions reject standalone bare identifiers", async () => {
const workflow = {
steps: [
{ id: "collect", run: "echo hello" },
{ id: "finish", run: "echo done", condition: "approve" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-invalid-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env },
mode: "tool",
},
}),
/Unsupported condition: approve/,
);
});
test("parallel wait:any fails promptly when a registered loser ignores cancellation", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-unsettled-"));
const filePath = path.join(tmpDir, "workflow.lobster");
const registry = {
get(name: string) {
if (name === "winner") {
return {
name,
help: () => "winner",
async run() {
return { output: [{ winner: true }] };
},
};
}
if (name === "never") {
return {
name,
help: () => "never",
async run() {
await new Promise<void>(() => {});
},
};
}
return undefined;
},
};
try {
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "parallel",
parallel: {
wait: "any",
branches: [
{ id: "winner", pipeline: "winner" },
{ id: "never", pipeline: "never" },
],
},
},
],
}),
"utf8",
);
await assert.rejects(
Promise.race([
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env },
mode: "tool",
registry,
},
}),
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error("workflow did not finish")), 1_000),
),
]),
/Parallel branches did not settle after cancellation/,
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
test("workflow conditions reject unknown step refs even under negation", async () => {
const workflow = {
steps: [
{ id: "collect", run: "echo hello" },
{ id: "finish", run: "echo done", condition: "!$aprove.approved" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-typo-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env },
mode: "tool",
},
}),
/Unknown step reference: aprove\.approved/,
);
});
test("workflow conditions compare object refs without key-order sensitivity", async () => {
const workflow = {
steps: [
{
id: "left",
run: 'node -e "process.stdout.write(JSON.stringify({a:1,b:2}))"',
},
{
id: "right",
run: 'node -e "process.stdout.write(JSON.stringify({b:2,a:1}))"',
},
{
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
condition: "$left.json == $right.json",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-object-eq-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env },
mode: "tool",
},
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ ok: true }]);
});
test("workflow files can mix shell steps, approval-only steps, and pipeline llm steps", async () => {
const registry = createDefaultRegistry();
const requests: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/invoke") {
res.writeHead(404);
res.end("nope");
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
requests.push(parsed);
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
runId: "http_1",
model: parsed.model || "test-model",
prompt: parsed.prompt,
output: {
format: "json",
text: '{"recommendation":"no","reason":"warm"}',
data: { recommendation: "no", reason: "warm" },
},
},
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const workflow = {
name: "mixed-workflow",
steps: [
{
id: "fetch",
run: "node -e \"process.stdout.write(JSON.stringify({location:'Phoenix',temp_f:73.8,humidity_pct:13,wind_mph:3.4}))\"",
},
{
id: "confirm",
approval: "Want jacket advice from the LLM?",
stdin: "$fetch.json",
},
{
id: "advice",
pipeline:
'llm.invoke --provider http --prompt "Given this weather data, should I wear a jacket? Return JSON." --disable-cache',
stdin: "$fetch.json",
when: "$confirm.approved",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-mixed-"));
const stateDir = path.join(tmpDir, "state");
const cacheDir = path.join(tmpDir, "cache");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = {
...process.env,
LOBSTER_STATE_DIR: stateDir,
LOBSTER_CACHE_DIR: cacheDir,
LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
};
try {
const first = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval?.prompt, "Want jacket advice from the LLM?");
assert.match(first.requiresApproval?.preview ?? "", /Phoenix/);
assert.ok(first.requiresApproval?.resumeToken);
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
assert.equal(payload.kind, "workflow-file");
const resumed = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
resume: payload,
approved: true,
});
assert.equal(resumed.status, "ok");
assert.equal(resumed.output.length, 1);
assert.equal((resumed.output[0] as any).kind, "llm.invoke");
assert.equal((resumed.output[0] as any).output.data.recommendation, "no");
assert.equal(requests.length, 1);
assert.equal(requests[0].artifacts[0].location, "Phoenix");
} finally {
await closeServer(server);
}
});
test("workflow pipeline llm_task.invoke consumes stdin artifacts from previous step", async () => {
const registry = createDefaultRegistry();
const requests: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end("nope");
return;
}
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const parsed = JSON.parse(body || "{}");
requests.push(parsed);
const text = String(parsed?.args?.artifacts?.[0]?.text ?? "");
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: "task_1",
model: parsed?.args?.model ?? "test-model",
prompt: parsed?.args?.prompt,
output: {
text: JSON.stringify({ word_count: wordCount }),
data: { word_count: wordCount },
format: "json",
},
},
},
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const workflow = {
name: "word-counter",
steps: [
{
id: "make_words",
run: 'echo "One two three four five six"',
},
{
id: "count_words",
pipeline:
'llm_task.invoke --prompt "How many words have been pasted below?" --disable-cache',
stdin: "$make_words.stdout",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-llm-task-stdin-"));
const stateDir = path.join(tmpDir, "state");
const cacheDir = path.join(tmpDir, "cache");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = {
...process.env,
LOBSTER_STATE_DIR: stateDir,
LOBSTER_CACHE_DIR: cacheDir,
OPENCLAW_URL: `http://127.0.0.1:${port}`,
};
try {
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: "tool",
registry,
},
});
assert.equal(result.status, "ok");
assert.equal(result.output.length, 1);
assert.equal((result.output[0] as any).kind, "llm_task.invoke");
assert.equal((result.output[0] as any).output.data.word_count, 6);
assert.equal(requests.length, 1);
assert.equal(requests[0].tool, "llm-task");
assert.equal(requests[0].action, "invoke");
assert.equal(requests[0].args.prompt, "How many words have been pasted below?");
assert.match(
String(requests[0].args.artifacts?.[0]?.text ?? ""),
/One two three four five six/,
);
} finally {
await closeServer(server);
}
});
test("workflow pipeline steps respect cwd and feed later shell steps via stdout refs", async () => {
const registry = createDefaultRegistry();
const workflow = {
cwd: "${TARGET_DIR}",
steps: [
{
id: "pwd",
pipeline: "exec pwd",
},
{
id: "capture",
run: "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{process.stdout.write(JSON.stringify({pwd:d.trim()}));});\"",
stdin: "$pwd.stdout",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-cwd-"));
const targetDir = path.join(tmpDir, "nested");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.mkdir(targetDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const result = await runWorkflowFile({
filePath,
args: { TARGET_DIR: targetDir },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: path.join(tmpDir, "state") },
mode: "tool",
registry,
},
});
assert.equal(result.status, "ok");
const resolvedTargetDir = await fsp.realpath(targetDir);
assert.deepEqual(result.output, [{ pwd: resolvedTargetDir }]);
});
async function closeServer(server: http.Server) {
if (!server.listening) return;
await new Promise<void>((resolve) => server.close(() => resolve()));
}