feat: add command-level input requests

Adds state-backed command-level requestInput resume across CLI/tool/SDK pipelines and workflow pipeline steps. Closes #101.
This commit is contained in:
Peter Steinberger
2026-06-10 17:50:51 -07:00
committed by GitHub
parent ff0791b78b
commit 77b0a55692
16 changed files with 3085 additions and 173 deletions
+1
View File
@@ -4,6 +4,7 @@ All notable changes to Lobster will be documented in this file.
## Unreleased
- Add command-level `ctx.requestInput(...)` for CLI/tool/SDK pipeline commands, with state-backed same-command resume, bounded command-input replay, and workflow `pipeline:` propagation (Issue [#101](https://github.com/openclaw/lobster/issues/101)).
- Warn when LLM usage records an unknown or missing model ID or invalid `LOBSTER_LLM_PRICING_JSON`, keeping zero-cost fallback behavior visible for `cost_limit` users. Thanks to [@KrasimirKralev](https://github.com/KrasimirKralev) (Issue [#107](https://github.com/openclaw/lobster/issues/107)).
- Require Node.js 22 or newer for the npm package, matching release CI.
- Write Lobster state files atomically while preserving restricted file modes, preventing truncated resume/session state after process termination. Thanks to [@KrasimirKralev](https://github.com/KrasimirKralev) (Issues [#108](https://github.com/openclaw/lobster/issues/108), [#109](https://github.com/openclaw/lobster/issues/109), PR [#110](https://github.com/openclaw/lobster/pull/110)).
+6
View File
@@ -218,6 +218,12 @@ Notes:
- `LOBSTER_APPROVAL_INITIATED_BY` can provide a default initiator id at run time.
- `LOBSTER_APPROVAL_APPROVED_BY` is used at resume/approval time for identity checks.
### Command-level input requests
Pipeline commands can call `ctx.requestInput({ prompt, responseSchema, defaults, subject, suspendedState })` to pause in tool mode, workflows, or the SDK and resume the same command after a structured response. CLI/tool resume tokens store only a state key; the persisted state validates the suspended request metadata before returning the submitted response to the command. SDK same-command resumes store the command frame in the configured SDK state directory.
Commands are re-run on resume, so they must be idempotent until `requestInput` returns. Array-backed command input is snapshotted with bounds for replay; lazy stream input is not buffered and requires a compact JSON `suspendedState` supplied by the command. On resume, call `ctx.requestInput.getSuspendedState()` before reading lazy input to restore that command-owned continuation state.
## Visualizing workflows
Use `lobster graph` to inspect workflow structure before execution.
+28 -9
View File
@@ -294,6 +294,10 @@ async function handleRun({ argv, registry }) {
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));
@@ -313,6 +317,12 @@ async function handleRun({ argv, registry }) {
}
}
function isPipelineInputRequest(items) {
return (
items.length === 1 && items[0]?.type === "input_request" && items[0]?.commandInput !== undefined
);
}
function parseRunArgs(argv) {
const rest = [];
let mode = "human";
@@ -697,10 +707,24 @@ async function handleResume({ argv, registry }) {
}
}
const isSameStageInput =
resumeState.haltType === "input_request" && resumeState.resumeMode === "same_stage";
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
const input = isSameStageInput
? resumeState.items
: resumeState.haltType === "input_request"
? [response]
: resumeState.items;
const requestInputResume = isSameStageInput
? {
state: resumeState.commandInput!,
response,
onConsumed: async () => {
await cleanupIndex();
await deleteStateJson({ env: process.env, key: previousStateKey });
},
}
: undefined;
try {
const output = await runPipeline({
@@ -712,6 +736,7 @@ async function handleResume({ argv, registry }) {
env: process.env,
mode,
input,
requestInputResume,
});
await cleanupIndex();
const finalized = await finalizePipelineToolRun({
@@ -737,12 +762,6 @@ async function handleResume({ argv, registry }) {
}
}
function streamFromItems(items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
async function readVersion() {
const { readFile } = await import("node:fs/promises");
const { fileURLToPath } = await import("node:url");
+31 -5
View File
@@ -78,9 +78,6 @@ export const askCommand = {
const subjectFromStdin = Boolean(args["subject-from-stdin"] ?? args.subjectFromStdin);
const schemaRaw = typeof args.schema === "string" ? args.schema : null;
const items = [];
for await (const item of input) items.push(item);
const defaultSchema = {
type: "object",
properties: {
@@ -105,7 +102,22 @@ export const askCommand = {
}
const responseValidator = compileAskValidator(responseSchema);
let subject;
const forceEmit = Boolean(args.emit);
const emit = forceEmit || ctx.mode === "tool" || !isInteractive(ctx.stdin);
const canRequestInput =
!forceEmit && ctx.mode === "tool" && typeof ctx.requestInput === "function";
const restoredState = canRequestInput ? ctx.requestInput.getSuspendedState?.() : undefined;
const restoredAskState =
restoredState && typeof restoredState === "object" && restoredState.type === "ask"
? restoredState
: null;
const items = [];
if (!restoredAskState) {
for await (const item of input) items.push(item);
}
let subject = restoredAskState?.subject;
if (subjectFromStdin && items.length > 0) {
const preview = items
.map((item) => (typeof item === "string" ? item : JSON.stringify(item)))
@@ -114,8 +126,18 @@ export const askCommand = {
subject = { text: preview };
}
const emit = Boolean(args.emit) || ctx.mode === "tool" || !isInteractive(ctx.stdin);
if (emit) {
if (canRequestInput) {
const response = await ctx.requestInput({
prompt,
responseSchema,
...(subject ? { subject } : null),
suspendedState: { type: "ask", ...(subject ? { subject } : null) },
});
return {
output: asStream([response]),
};
}
return {
halt: true,
output: (async function* () {
@@ -151,3 +173,7 @@ export const askCommand = {
throw lastError ?? new Error("ask response failed schema validation");
},
};
async function* asStream(items) {
for (const item of items) yield item;
}
+18 -11
View File
@@ -277,10 +277,24 @@ export async function resumeToolRequest({
}
}
const isSameStageInput =
resumeState.haltType === "input_request" && resumeState.resumeMode === "same_stage";
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
const input = isSameStageInput
? resumeState.items
: resumeState.haltType === "input_request"
? [response]
: resumeState.items;
const requestInputResume = isSameStageInput
? {
state: resumeState.commandInput!,
response,
onConsumed: async () => {
await cleanupIndex();
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
},
}
: undefined;
try {
const output = await runPipeline({
@@ -295,6 +309,7 @@ export async function resumeToolRequest({
llmAdapters: runtime.llmAdapters,
signal: runtime.signal,
input,
requestInputResume,
});
await cleanupIndex();
@@ -362,14 +377,6 @@ function errorEnvelope(type: string, message: string): ToolEnvelope {
};
}
function streamFromItems(items: unknown[]) {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
async function resolveWorkflowFile(candidate: string, cwd: string) {
const { stat } = await import("node:fs/promises");
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
+527
View File
@@ -0,0 +1,527 @@
import { stableStringify } from "./state/store.js";
import { compileCached } from "./validation.js";
export type RequestInputParams = {
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
suspendedState?: unknown;
};
export type RequestInputMetadata = {
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
signature: string;
};
export type CommandInputHistoryEntry = {
requestIndex: number;
metadata: RequestInputMetadata;
suspendedState?: unknown;
response: unknown;
};
export type CommandInputPendingRequest = {
requestIndex: number;
metadata: RequestInputMetadata;
suspendedState?: unknown;
};
export type CommandInputState = {
pending: CommandInputPendingRequest;
history: CommandInputHistoryEntry[];
};
export type CommandInputResume = {
state: CommandInputState;
response: unknown;
consumed?: boolean;
onConsumed?: () => void | Promise<void>;
};
export type PipelineCommandInputRequest = {
type: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
items: unknown[];
commandInput: CommandInputState;
};
const MAX_REPLAY_ITEMS = 1000;
const MAX_REPLAY_BYTES = 1024 * 1024;
const MAX_REQUEST_HISTORY = 100;
export class InputRequestSuspension extends Error {
stageIndex: number;
request: PipelineCommandInputRequest;
constructor(stageIndex: number, request: PipelineCommandInputRequest) {
super("Input request suspended");
this.name = "InputRequestSuspension";
this.stageIndex = stageIndex;
this.request = request;
}
}
export class RequestInputResumeError extends Error {
constructor(message: string) {
super(message);
this.name = "RequestInputResumeError";
}
}
export function createInputTracker(input: AsyncIterable<unknown> | Iterable<unknown>) {
const knownItems = Array.isArray(input) ? input : null;
let iterator: AsyncIterator<unknown> | null = null;
let completed = false;
let closed = false;
let replayEnabled = true;
let replaySnapshotItems: unknown[] = [];
let replaySnapshotIndex = 0;
let replaySnapshotError: Error | null = null;
let replaySnapshotBytes = 0;
const iterable = {
async *[Symbol.asyncIterator]() {
const iter = getIterator();
let hasPrimaryError = false;
let yieldedIndex = 0;
try {
while (true) {
const next = await iter.next();
if (next.done) {
completed = true;
return;
}
if (knownItems) {
snapshotKnownItemsThrough(yieldedIndex);
yieldedIndex += 1;
}
yield next.value;
}
} catch (err) {
hasPrimaryError = true;
throw err;
} finally {
await closeTrackedIterator({ suppressErrors: hasPrimaryError });
}
},
};
return {
iterable,
getReplayItems(hasSuspendedState: boolean) {
if (!replayEnabled) {
throw new Error("requestInput replay is no longer available after command output");
}
if (!knownItems) {
if (hasSuspendedState) return [];
throw new Error("requestInput requires suspendedState when command input is streaming");
}
snapshotKnownItemsThrough(knownItems.length - 1);
if (replaySnapshotError) throw replaySnapshotError;
return snapshotArray(replaySnapshotItems, "requestInput replay input");
},
disableReplay() {
replayEnabled = false;
},
async close(options: { suppressErrors?: boolean } = {}) {
await closeTrackedIterator({ suppressErrors: options.suppressErrors === true });
},
};
function getIterator() {
iterator ??= toAsyncIterator(input);
return iterator;
}
async function closeTrackedIterator({ suppressErrors }: { suppressErrors: boolean }) {
if (!iterator || completed || closed) return;
closed = true;
await closeIterator(iterator, { suppressErrors });
}
function snapshotKnownItemsThrough(index: number) {
if (!knownItems || replaySnapshotError) return;
while (replaySnapshotIndex <= index && replaySnapshotIndex < knownItems.length) {
try {
const snapshot = snapshotJson(knownItems[replaySnapshotIndex], "requestInput replay input");
replaySnapshotBytes += Buffer.byteLength(JSON.stringify(snapshot), "utf8");
if (
replaySnapshotItems.length + 1 > MAX_REPLAY_ITEMS ||
replaySnapshotBytes > MAX_REPLAY_BYTES
) {
throw new Error("requestInput replay limit exceeded");
}
replaySnapshotItems.push(snapshot);
replaySnapshotIndex += 1;
} catch (err) {
replaySnapshotError = err instanceof Error ? err : new Error(String(err));
return;
}
}
}
}
export function createStageRequestInput({
ctx,
stageIndex,
mode,
inputTracker,
isCommandActive,
getInactiveReason,
isOutputStarted,
resume,
}: {
ctx: any;
stageIndex: number;
mode: string;
inputTracker: ReturnType<typeof createInputTracker>;
isCommandActive: () => boolean;
getInactiveReason?: () => string | undefined;
isOutputStarted: () => boolean;
resume?: CommandInputResume;
}) {
let requestIndex = 0;
const history: CommandInputHistoryEntry[] = [...(resume?.state.history ?? [])];
const requestInput = async function requestInput(params: RequestInputParams) {
if (!isCommandActive()) {
throw new Error(
getInactiveReason?.() ?? "requestInput cannot run after the command has completed",
);
}
const metadata = snapshotRequestMetadata(params);
const requestedSuspendedState =
params.suspendedState === undefined
? undefined
: snapshotJson(params.suspendedState, "requestInput suspendedState");
const historical = history[requestIndex];
if (historical) {
assertMetadataMatches(historical.requestIndex, historical.metadata, requestIndex, metadata);
assertSuspendedStateMatches(historical.suspendedState, requestedSuspendedState);
const response = snapshotJson(historical.response, "requestInput response");
validateRequestInputResponse(metadata.responseSchema, response, "requestInput");
requestIndex += 1;
return response;
}
if (resume && !resume.consumed) {
assertMetadataMatches(
resume.state.pending.requestIndex,
resume.state.pending.metadata,
requestIndex,
metadata,
);
assertSuspendedStateMatches(resume.state.pending.suspendedState, requestedSuspendedState);
const response = snapshotJson(resume.response, "requestInput response");
validateRequestInputResponse(metadata.responseSchema, response, "requestInput");
const historyResponse = snapshotJson(response, "requestInput response");
await resume.onConsumed?.();
resume.consumed = true;
history.push({
requestIndex,
metadata,
...(requestedSuspendedState !== undefined
? { suspendedState: requestedSuspendedState }
: null),
response: historyResponse,
});
requestIndex += 1;
return response;
}
if (mode === "human" && isInteractive(ctx.stdin)) {
return requestInputInteractively(ctx, metadata);
}
if (isOutputStarted()) {
throw new Error("requestInput cannot suspend after this command has produced output");
}
if (history.length >= MAX_REQUEST_HISTORY) {
throw new Error("requestInput replay history limit exceeded");
}
const items = inputTracker.getReplayItems(requestedSuspendedState !== undefined);
const pending: CommandInputPendingRequest = {
requestIndex,
metadata,
...(requestedSuspendedState !== undefined
? { suspendedState: requestedSuspendedState }
: null),
};
throw new InputRequestSuspension(stageIndex, {
type: "input_request",
prompt: metadata.prompt,
responseSchema: metadata.responseSchema,
...(metadata.defaults !== undefined ? { defaults: metadata.defaults } : null),
...(metadata.subject !== undefined ? { subject: metadata.subject } : null),
items,
commandInput: {
pending,
history,
},
});
};
requestInput.getSuspendedState = function getSuspendedState() {
if (requestIndex < history.length) {
return snapshotOptionalState(history[requestIndex].suspendedState);
}
if (resume && !resume.consumed && resume.state.pending.requestIndex === requestIndex) {
return snapshotOptionalState(resume.state.pending.suspendedState);
}
return undefined;
};
return requestInput;
}
export function assertRequestInputResumeConsumed(resume?: CommandInputResume) {
if (resume && !resume.consumed) {
throw new RequestInputResumeError("resume input response was not consumed by requestInput");
}
}
export function snapshotRequestMetadata(params: RequestInputParams): RequestInputMetadata {
validateRequestInputParams(params);
const responseSchema = snapshotJson(params.responseSchema, "requestInput responseSchema");
const defaults =
params.defaults === undefined
? undefined
: snapshotJson(params.defaults, "requestInput defaults");
const subject =
params.subject === undefined ? undefined : snapshotJson(params.subject, "requestInput subject");
const unsigned = {
prompt: params.prompt,
responseSchema,
...(defaults !== undefined ? { defaults } : null),
...(subject !== undefined ? { subject } : null),
};
return {
...unsigned,
signature: stableStringify(unsigned),
};
}
export function validateCommandInputState(value: unknown): CommandInputState {
if (!value || typeof value !== "object") throw new Error("Invalid pipeline resume state");
const data = value as Partial<CommandInputState>;
validatePending(data.pending);
if (!Array.isArray(data.history)) throw new Error("Invalid pipeline resume state");
if (data.history.length !== data.pending.requestIndex) {
throw new Error("Invalid pipeline resume state");
}
data.history.forEach(validateHistoryEntry);
return data as CommandInputState;
}
export function validateRequestInputResponse(schema: unknown, response: unknown, label: string) {
let validator;
try {
validator = compileCached(schema as any);
} catch {
throw new Error(`${label} response schema is invalid`);
}
if (validator(response)) return;
const first = validator.errors?.[0];
const pathValue = first?.instancePath || "/";
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`${label} response failed schema validation at ${pathValue}:${reason}`);
}
function validateRequestInputParams(params: RequestInputParams) {
if (!params || typeof params !== "object") {
throw new Error("requestInput params must be an object");
}
if (typeof params.prompt !== "string" || params.prompt.length === 0) {
throw new Error("requestInput prompt is required");
}
if (params.responseSchema === undefined) {
throw new Error("requestInput responseSchema is required");
}
try {
compileCached(params.responseSchema as any);
} catch {
throw new Error("requestInput response schema is invalid");
}
}
function validatePending(value: unknown): asserts value is CommandInputPendingRequest {
if (!value || typeof value !== "object") throw new Error("Invalid pipeline resume state");
const data = value as Partial<CommandInputPendingRequest>;
if (
typeof data.requestIndex !== "number" ||
!Number.isInteger(data.requestIndex) ||
data.requestIndex < 0
) {
throw new Error("Invalid pipeline resume state");
}
validateStoredMetadata(data.metadata);
if (data.suspendedState !== undefined) {
snapshotJson(data.suspendedState, "requestInput suspendedState");
}
}
function validateHistoryEntry(value: unknown, index: number) {
if (!value || typeof value !== "object") throw new Error("Invalid pipeline resume state");
const data = value as Partial<CommandInputHistoryEntry>;
if (data.requestIndex !== index) throw new Error("Invalid pipeline resume state");
validateStoredMetadata(data.metadata);
if (data.suspendedState !== undefined) {
snapshotJson(data.suspendedState, "requestInput suspendedState");
}
if (data.response === undefined) throw new Error("Invalid pipeline resume state");
snapshotJson(data.response, "requestInput response");
}
function validateStoredMetadata(value: unknown): asserts value is RequestInputMetadata {
if (!value || typeof value !== "object") throw new Error("Invalid pipeline resume state");
const data = value as Partial<RequestInputMetadata>;
if (typeof data.prompt !== "string" || data.prompt.length === 0) {
throw new Error("Invalid pipeline resume state");
}
if (typeof data.signature !== "string" || data.signature.length === 0) {
throw new Error("Invalid pipeline resume state");
}
if (data.responseSchema === undefined) throw new Error("Invalid pipeline resume state");
const actual = snapshotRequestMetadata({
prompt: data.prompt,
responseSchema: data.responseSchema,
...(data.defaults !== undefined ? { defaults: data.defaults } : null),
...(data.subject !== undefined ? { subject: data.subject } : null),
});
if (actual.signature !== data.signature) throw new Error("Invalid pipeline resume state");
}
function assertMetadataMatches(
storedIndex: number,
stored: RequestInputMetadata,
actualIndex: number,
actual: RequestInputMetadata,
) {
if (
storedIndex !== actualIndex ||
stored.prompt !== actual.prompt ||
stored.signature !== actual.signature
) {
throw new RequestInputResumeError(
"requestInput resume request does not match suspended request",
);
}
}
function assertSuspendedStateMatches(stored: unknown, actual: unknown) {
if (stableStringify(stored) !== stableStringify(actual)) {
throw new RequestInputResumeError("requestInput resume state does not match suspended request");
}
}
function snapshotOptionalState(state: unknown) {
return state === undefined ? undefined : snapshotJson(state, "requestInput suspendedState");
}
function snapshotArray(items: readonly unknown[], label: string) {
if (items.length > MAX_REPLAY_ITEMS) throw new Error("requestInput replay item limit exceeded");
let totalBytes = 0;
return items.map((item) => {
const snapshot = snapshotJson(item, label);
totalBytes += Buffer.byteLength(JSON.stringify(snapshot), "utf8");
if (totalBytes > MAX_REPLAY_BYTES) {
throw new Error("requestInput replay byte limit exceeded");
}
return snapshot;
});
}
function snapshotJson(value: unknown, label: string): unknown {
assertJsonSerializable(value, label, new WeakSet());
const text = JSON.stringify(value);
if (text === undefined) throw new Error(`${label} must be JSON-serializable`);
return JSON.parse(text);
}
function assertJsonSerializable(value: unknown, label: string, seen: WeakSet<object>) {
if (value === null) return;
const type = typeof value;
if (type === "string" || type === "boolean") return;
if (type === "number") {
if (!Number.isFinite(value)) throw new Error(`${label} must be JSON-serializable`);
return;
}
if (type === "undefined" || type === "function" || type === "symbol" || type === "bigint") {
throw new Error(`${label} must be JSON-serializable`);
}
const object = value as object;
if (seen.has(object)) throw new Error(`${label} must be JSON-serializable`);
const prototype = Object.getPrototypeOf(object);
if (prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) {
throw new Error(`${label} must be JSON-serializable`);
}
seen.add(object);
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index++) {
if (!(index in value)) throw new Error(`${label} must be JSON-serializable`);
assertJsonSerializable(value[index], label, seen);
}
} else {
for (const key of Object.keys(value as Record<string, unknown>)) {
assertJsonSerializable((value as Record<string, unknown>)[key], label, seen);
}
}
seen.delete(object);
}
async function requestInputInteractively(ctx: any, metadata: RequestInputMetadata) {
ctx.stdout.write(`${metadata.prompt}\n> `);
const { readLineFromStream } = await import("./read_line.js");
const raw = await readLineFromStream(ctx.stdin, { timeoutMs: 0 });
let response;
try {
response = JSON.parse(String(raw ?? "").trim());
} catch {
throw new Error("requestInput response must be valid JSON");
}
validateRequestInputResponse(metadata.responseSchema, response, "requestInput");
return snapshotJson(response, "requestInput response");
}
function isInteractive(stdin: any) {
return Boolean(stdin?.isTTY);
}
async function closeIterator(
iterator: AsyncIterator<unknown>,
{ suppressErrors }: { suppressErrors: boolean },
) {
if (typeof iterator.return !== "function") return;
try {
await iterator.return();
} catch (err) {
if (!suppressErrors) throw err;
// Cleanup must not mask the original command error or suspension.
}
}
function toAsyncIterator(input: AsyncIterable<unknown> | Iterable<unknown>) {
if (typeof (input as any)[Symbol.asyncIterator] === "function") {
return (input as AsyncIterable<unknown>)[Symbol.asyncIterator]();
}
if (typeof (input as any)[Symbol.iterator] === "function") {
const iterator = (input as Iterable<unknown>)[Symbol.iterator]();
return {
async next() {
return iterator.next();
},
async return() {
if (typeof iterator.return === "function") iterator.return();
return { done: true, value: undefined };
},
};
}
throw new Error("input is not iterable");
}
+53 -3
View File
@@ -9,14 +9,17 @@ import {
writeStateJson,
} from "./state/store.js";
import { compileCached } from "./validation.js";
import { validateCommandInputState, type CommandInputState } from "./input_request.js";
export type PipelineResumeState = {
pipeline: Array<{ name: string; args: Record<string, unknown>; raw: string }>;
resumeAtIndex: number;
items: unknown[];
haltType?: "approval_request" | "input_request";
resumeMode?: "next_stage" | "same_stage";
inputSchema?: unknown;
prompt?: string;
commandInput?: CommandInputState;
createdAt: string;
};
@@ -34,6 +37,7 @@ export type PipelineInputRequest = {
defaults?: unknown;
subject?: unknown;
items?: unknown[];
commandInput?: CommandInputState;
};
export type PipelineRunOutput = {
@@ -134,13 +138,19 @@ export async function finalizePipelineToolRun(params: {
}
if (inputRequest) {
const resumeMode = inputRequest.commandInput ? "same_stage" : "next_stage";
const nextStateKey = await savePipelineResumeState(params.env, {
pipeline: params.pipeline,
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
items: [],
resumeAtIndex:
resumeMode === "same_stage"
? (params.output.haltedAt?.index ?? -1)
: (params.output.haltedAt?.index ?? -1) + 1,
items: resumeMode === "same_stage" ? (inputRequest.items ?? []) : [],
haltType: "input_request",
resumeMode,
inputSchema: inputRequest.responseSchema,
prompt: inputRequest.prompt,
...(inputRequest.commandInput ? { commandInput: inputRequest.commandInput } : null),
createdAt: new Date().toISOString(),
});
if (params.previousStateKey) {
@@ -199,7 +209,15 @@ export async function loadPipelineResumeState(
}
const data = stored as Partial<PipelineResumeState>;
if (!Array.isArray(data.pipeline)) throw new Error("Invalid pipeline resume state");
if (typeof data.resumeAtIndex !== "number") throw new Error("Invalid pipeline resume state");
validatePipelineShape(data.pipeline);
if (
typeof data.resumeAtIndex !== "number" ||
!Number.isInteger(data.resumeAtIndex) ||
data.resumeAtIndex < 0 ||
data.resumeAtIndex > data.pipeline.length
) {
throw new Error("Invalid pipeline resume state");
}
if (!Array.isArray(data.items)) throw new Error("Invalid pipeline resume state");
if (
data.haltType !== undefined &&
@@ -207,6 +225,24 @@ export async function loadPipelineResumeState(
) {
throw new Error("Invalid pipeline resume state");
}
if (data.resumeMode !== undefined && !["next_stage", "same_stage"].includes(data.resumeMode)) {
throw new Error("Invalid pipeline resume state");
}
if (data.haltType === "input_request") {
if (data.inputSchema === undefined || typeof data.prompt !== "string") {
throw new Error("Invalid pipeline resume state");
}
if (data.resumeMode === "same_stage") {
if (data.resumeAtIndex >= data.pipeline.length) {
throw new Error("Invalid pipeline resume state");
}
data.commandInput = validateCommandInputState(data.commandInput);
} else if (data.commandInput !== undefined) {
throw new Error("Invalid pipeline resume state");
}
} else if (data.resumeMode === "same_stage" || data.commandInput !== undefined) {
throw new Error("Invalid pipeline resume state");
}
return data as PipelineResumeState;
}
@@ -227,3 +263,17 @@ export function validatePipelineInputResponse(schema: unknown, response: unknown
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`pipeline input response failed schema validation at ${pathValue}:${reason}`);
}
function validatePipelineShape(pipeline: unknown[]) {
for (const stage of pipeline) {
if (!stage || typeof stage !== "object") throw new Error("Invalid pipeline resume state");
const data = stage as Record<string, unknown>;
if (typeof data.name !== "string" || data.name.length === 0) {
throw new Error("Invalid pipeline resume state");
}
if (!data.args || typeof data.args !== "object" || Array.isArray(data.args)) {
throw new Error("Invalid pipeline resume state");
}
if (typeof data.raw !== "string") throw new Error("Invalid pipeline resume state");
}
}
+182 -9
View File
@@ -1,4 +1,12 @@
import { createJsonRenderer } from "./renderers/json.js";
import {
InputRequestSuspension,
RequestInputResumeError,
assertRequestInputResumeConsumed,
createInputTracker,
createStageRequestInput,
type CommandInputResume,
} from "./input_request.js";
export async function runPipeline({
pipeline,
@@ -13,6 +21,8 @@ export async function runPipeline({
llmAdapters = undefined,
signal = undefined,
dryRun = false,
requestInputResume = undefined,
requestInputEnabled = true,
}: {
pipeline: any[];
registry: any;
@@ -26,17 +36,20 @@ export async function runPipeline({
llmAdapters?: Record<string, any> | undefined;
signal?: AbortSignal | undefined;
dryRun?: boolean;
requestInputResume?: CommandInputResume | undefined;
requestInputEnabled?: boolean;
}) {
if (dryRun) {
return dryRunPipeline({ pipeline, registry, stderr });
}
let stream = input ?? emptyStream();
let stream = input ?? [];
let rendered = false;
let halted = false;
let haltedAt = null;
let pipelineOutputStarted = false;
const ctx = {
const baseCtx = {
stdin,
stdout,
stderr,
@@ -46,7 +59,6 @@ export async function runPipeline({
cwd,
llmAdapters,
signal,
render: createJsonRenderer(stdout),
};
for (let idx = 0; idx < pipeline.length; idx++) {
@@ -56,26 +68,124 @@ export async function runPipeline({
throw new Error(`Unknown command: ${stage.name}`);
}
const result = await command.run({ input: stream, args: stage.args, ctx });
const inputTracker = createInputTracker(stream);
const stageResume = idx === 0 ? requestInputResume : undefined;
let commandActive = true;
let inactiveReason: string | undefined;
let commandOutputStarted = false;
let stageFinished = false;
async function finishStage({ assertResume = true, suppressCloseErrors = false } = {}) {
if (stageFinished) return;
stageFinished = true;
commandActive = false;
inputTracker.disableReplay();
await inputTracker.close({ suppressErrors: suppressCloseErrors });
if (assertResume) assertRequestInputResumeConsumed(stageResume);
}
const stageStdout = trackWritableOutput(stdout, () => {
pipelineOutputStarted = true;
});
const ctx = {
...baseCtx,
stdout: stageStdout,
render: createJsonRenderer(stageStdout),
};
const stageCtx = {
...ctx,
requestInput: requestInputEnabled
? createStageRequestInput({
ctx,
stageIndex: idx,
mode,
inputTracker,
isCommandActive: () => commandActive,
getInactiveReason: () => inactiveReason,
isOutputStarted: () => pipelineOutputStarted || commandOutputStarted,
resume: stageResume,
})
: createUnsupportedRequestInput(),
};
let result;
try {
result = await command.run({ input: inputTracker.iterable, args: stage.args, ctx: stageCtx });
} catch (err) {
await finishStage({ assertResume: false, suppressCloseErrors: true });
if (haltForInputRequest(err)) break;
assertNoUnconsumedResumeAfterError(stageResume, err);
throw err;
}
if (result?.rendered) {
rendered = true;
}
const output = result?.output;
if (Array.isArray(output)) {
stream = output;
await finishStage();
} else if (output && !result?.halt && idx < pipeline.length - 1) {
commandActive = false;
inactiveReason = "requestInput cannot suspend from lazy output before downstream stages";
assertRequestInputResumeConsumed(stageResume);
stream = trackCommandOutput(
output,
() => {
commandOutputStarted = true;
},
() => assertRequestInputResumeConsumed(stageResume),
(err) => assertNoUnconsumedResumeAfterError(stageResume, err),
finishStage,
);
} else {
stream = output
? trackCommandOutput(
output,
() => {
commandOutputStarted = true;
},
() => assertRequestInputResumeConsumed(stageResume),
(err) => assertNoUnconsumedResumeAfterError(stageResume, err),
finishStage,
)
: [];
if (!output) await finishStage();
}
if (result?.halt) {
halted = true;
haltedAt = { index: idx, stage };
stream = result.output ?? emptyStream();
break;
}
stream = result?.output ?? emptyStream();
}
const items = [];
for await (const item of stream) items.push(item);
try {
for await (const item of stream) items.push(item);
} catch (err) {
if (haltForInputRequest(err)) {
items.length = 0;
for await (const item of stream) items.push(item);
} else {
throw err;
}
}
assertRequestInputResumeConsumed(requestInputResume);
return { items, rendered, halted, haltedAt };
function haltForInputRequest(err: unknown) {
if (!(err instanceof InputRequestSuspension)) return false;
const stageIndex = err.stageIndex;
halted = true;
haltedAt = {
index: stageIndex,
stage: pipeline[stageIndex],
inPlace: true,
};
stream = streamFromItems([err.request]);
return true;
}
}
function dryRunPipeline({
@@ -122,4 +232,67 @@ function formatStageArgs(args: Record<string, unknown>) {
return parts.join(", ");
}
async function* emptyStream() {}
function streamFromItems(items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
})();
}
function trackCommandOutput(
output: AsyncIterable<unknown> | Iterable<unknown>,
markOutput: () => void,
assertResumeConsumed: () => void,
assertNoUnconsumedResumeAfterError: (err: unknown) => void,
finishStage: (options?: {
assertResume?: boolean;
suppressCloseErrors?: boolean;
}) => Promise<void>,
) {
return (async function* () {
let completed = false;
try {
for await (const item of output) {
assertResumeConsumed();
markOutput();
yield item;
}
completed = true;
} catch (err) {
await finishStage({ assertResume: false, suppressCloseErrors: true });
assertNoUnconsumedResumeAfterError(err);
throw err;
} finally {
await finishStage({ assertResume: completed });
}
})();
}
function assertNoUnconsumedResumeAfterError(resume: CommandInputResume | undefined, err: unknown) {
if (err instanceof RequestInputResumeError) return;
assertRequestInputResumeConsumed(resume);
}
function trackWritableOutput(stdout: any, markOutput: () => void) {
return new Proxy(stdout, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (prop === "write" || prop === "end") {
return (...args: unknown[]) => {
markOutput();
return value.apply(target, args);
};
}
return typeof value === "function" ? value.bind(target) : value;
},
});
}
function createUnsupportedRequestInput() {
const requestInput = async function requestInput() {
throw new Error("requestInput is not supported in this pipeline context");
};
requestInput.getSuspendedState = function getSuspendedState() {
return undefined;
};
return requestInput;
}
+123 -12
View File
@@ -1,6 +1,9 @@
import { randomUUID } from "node:crypto";
import { runPipelineInternal } from "./runtime.js";
import { encodeToken, decodeToken } from "./token.js";
import { compileCached } from "../validation.js";
import { validateCommandInputState, type CommandInputState } from "../input_request.js";
import { deleteStateJson, readStateJson, writeStateJson } from "../state/store.js";
type SdkResumePayload = {
protocolVersion: 1;
@@ -11,6 +14,16 @@ type SdkResumePayload = {
prompt?: string;
inputSchema?: unknown;
inputSubject?: unknown;
resumeMode?: "next_stage" | "same_stage";
stateKey?: string;
};
type SdkCommandInputResumeState = {
resumeAtIndex: number;
items: unknown[];
inputSchema: unknown;
inputSubject?: unknown;
commandInput: CommandInputState;
};
/**
@@ -109,13 +122,30 @@ export class Lobster {
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const resumeMode = input.commandInput ? "same_stage" : "next_stage";
const resumeAtIndex =
resumeMode === "same_stage"
? (result.haltedAt?.index ?? -1)
: (result.haltedAt?.index ?? -1) + 1;
const stateKey =
resumeMode === "same_stage"
? await saveSdkCommandInputResumeState(this.#options, {
resumeAtIndex,
items: input.items ?? [],
inputSchema: input.responseSchema,
...(input.subject !== undefined ? { inputSubject: input.subject } : null),
commandInput: input.commandInput,
})
: undefined;
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
stageIndex: result.haltedAt?.index ?? -1,
resumeAtIndex: (result.haltedAt?.index ?? -1) + 1,
items: [],
inputSchema: input.responseSchema,
resumeAtIndex,
resumeMode,
...(resumeMode === "same_stage"
? { stateKey }
: { items: [], inputSchema: input.responseSchema }),
inputSubject: input.subject,
});
@@ -174,7 +204,15 @@ export class Lobster {
const payload = decodeSdkResumePayload(token);
let sdkCommandInputState: SdkCommandInputResumeState | undefined;
if (payload.resumeMode === "same_stage") {
sdkCommandInputState = await loadSdkCommandInputResumeState(this.#options, payload.stateKey!);
}
if (cancel === true) {
if (payload.resumeMode === "same_stage") {
await deleteStateJson({ env: sdkStateEnv(this.#options), key: payload.stateKey! });
}
return {
ok: true,
status: "cancelled",
@@ -184,7 +222,7 @@ export class Lobster {
};
}
const expectsInput = payload.inputSchema !== undefined;
const expectsInput = payload.inputSchema !== undefined || payload.resumeMode === "same_stage";
if (expectsInput) {
if (approved !== undefined) {
throw new Error("resume token expects an input response, not approved");
@@ -210,10 +248,11 @@ export class Lobster {
}
}
const resumeIndex = payload.resumeAtIndex ?? 0;
let resumeItems = payload.items ?? [];
const resumeIndex = sdkCommandInputState?.resumeAtIndex ?? payload.resumeAtIndex ?? 0;
let resumeItems = sdkCommandInputState?.items ?? payload.items ?? [];
let requestInputResume;
if (response !== undefined) {
const schema = payload.inputSchema;
const schema = sdkCommandInputState?.inputSchema ?? payload.inputSchema;
if (schema === undefined) {
throw new Error("resume token does not support input responses");
}
@@ -230,7 +269,18 @@ export class Lobster {
`response does not match schema at ${first?.instancePath || "/"}: ${first?.message || "invalid"}`,
);
}
resumeItems = [response];
if (payload.resumeMode === "same_stage") {
resumeItems = sdkCommandInputState!.items;
requestInputResume = {
state: sdkCommandInputState!.commandInput,
response,
onConsumed: async () => {
await deleteStateJson({ env: sdkStateEnv(this.#options), key: payload.stateKey! });
},
};
} else {
resumeItems = [response];
}
}
const remainingStages = this.#stages.slice(resumeIndex);
@@ -245,6 +295,7 @@ export class Lobster {
stages: remainingStages,
ctx,
input: resumeItems,
requestInputResume,
});
if (
@@ -277,13 +328,27 @@ export class Lobster {
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const inputStageIndex = resumeIndex + (result.haltedAt?.index ?? 0);
const resumeMode = input.commandInput ? "same_stage" : "next_stage";
const stateKey =
resumeMode === "same_stage"
? await saveSdkCommandInputResumeState(this.#options, {
resumeAtIndex: inputStageIndex,
items: input.items ?? [],
inputSchema: input.responseSchema,
...(input.subject !== undefined ? { inputSubject: input.subject } : null),
commandInput: input.commandInput,
})
: undefined;
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
stageIndex: resumeIndex + (result.haltedAt?.index ?? 0),
resumeAtIndex: resumeIndex + (result.haltedAt?.index ?? 0) + 1,
items: [],
inputSchema: input.responseSchema,
stageIndex: inputStageIndex,
resumeAtIndex: resumeMode === "same_stage" ? inputStageIndex : inputStageIndex + 1,
resumeMode,
...(resumeMode === "same_stage"
? { stateKey }
: { items: [], inputSchema: input.responseSchema }),
inputSubject: input.subject,
});
@@ -351,5 +416,51 @@ function decodeSdkResumePayload(token: string): SdkResumePayload {
if (data.items !== undefined && !Array.isArray(data.items)) {
throw new Error("Invalid token");
}
if (
data.resumeMode !== undefined &&
(typeof data.resumeMode !== "string" || !["next_stage", "same_stage"].includes(data.resumeMode))
) {
throw new Error("Invalid token");
}
if (data.resumeMode === "same_stage") {
if (typeof data.stateKey !== "string" || data.stateKey.length === 0) {
throw new Error("Invalid token");
}
} else if (data.stateKey !== undefined) {
throw new Error("Invalid token");
}
if (data.commandInput !== undefined) throw new Error("Invalid token");
return data as unknown as SdkResumePayload;
}
function sdkStateEnv(options: any) {
return options.stateDir
? { ...(options.env ?? process.env), LOBSTER_STATE_DIR: options.stateDir }
: (options.env ?? process.env);
}
async function saveSdkCommandInputResumeState(options: any, state: SdkCommandInputResumeState) {
const stateKey = `sdk_resume_${randomUUID()}`;
await writeStateJson({ env: sdkStateEnv(options), key: stateKey, value: state });
return stateKey;
}
async function loadSdkCommandInputResumeState(
options: any,
stateKey: string,
): Promise<SdkCommandInputResumeState> {
const stored = await readStateJson({ env: sdkStateEnv(options), key: stateKey });
if (!stored || typeof stored !== "object") throw new Error("SDK resume state not found");
const data = stored as Partial<SdkCommandInputResumeState>;
if (
typeof data.resumeAtIndex !== "number" ||
!Number.isInteger(data.resumeAtIndex) ||
data.resumeAtIndex < 0
) {
throw new Error("Invalid SDK resume state");
}
if (!Array.isArray(data.items)) throw new Error("Invalid SDK resume state");
if (data.inputSchema === undefined) throw new Error("Invalid SDK resume state");
data.commandInput = validateCommandInputState(data.commandInput);
return data as SdkCommandInputResumeState;
}
+96 -99
View File
@@ -1,9 +1,12 @@
/**
* SDK Runtime - Executes Lobster pipelines
*
* This is adapted from the CLI runtime but designed for SDK use.
* This adapts SDK stages to the core runtime so command-level suspension rules
* stay identical across CLI, tool mode, and SDK entry points.
*/
import { runPipeline as runCorePipeline } from "../runtime.js";
/**
* @typedef {Object} StageResult
* @property {AsyncIterable|any[]} [output] - Output items
@@ -18,39 +21,6 @@
* @property {Object|null} haltedAt - Stage where halt occurred
*/
/**
* Convert various inputs to an async iterable
* @param {any} input
* @returns {AsyncIterable}
*/
async function* toAsyncIterable(input) {
if (input === null || input === undefined) {
return;
}
if (Array.isArray(input)) {
for (const item of input) {
yield item;
}
return;
}
if (typeof input[Symbol.asyncIterator] === "function") {
yield* input;
return;
}
if (typeof input[Symbol.iterator] === "function") {
for (const item of input) {
yield item;
}
return;
}
// Single item
yield input;
}
/**
* Collect async iterable to array
* @param {AsyncIterable} iterable
@@ -64,6 +34,29 @@ async function collectItems(iterable) {
return items;
}
function normalizeSdkOutput(output) {
if (output === null || output === undefined) return [];
if (Array.isArray(output)) return output;
if (
typeof output?.[Symbol.asyncIterator] === "function" ||
typeof output?.[Symbol.iterator] === "function"
) {
return output;
}
return [output];
}
function createNullWritable() {
return {
write() {
return true;
},
end() {
return undefined;
},
};
}
/**
* Run a pipeline of stages
*
@@ -73,53 +66,72 @@ async function collectItems(iterable) {
* @param {any[]} [options.input] - Initial input items
* @returns {Promise<PipelineResult>}
*/
export async function runPipelineInternal({ stages, ctx, input = [] }) {
let stream = toAsyncIterable(input);
let halted = false;
let haltedAt = null;
export async function runPipelineInternal({
stages,
ctx,
input = [],
requestInputResume = undefined,
}) {
const runtimeCtx = ctx ?? {};
const pipeline = stages.map((_stage, index) => ({
name: `sdk.stage.${index}`,
args: {},
raw: `sdk.stage.${index}`,
}));
const commands = new Map(
stages.map((stage, index) => [
`sdk.stage.${index}`,
{
async run({ input, ctx }) {
const stageCtx = { ...runtimeCtx, ...ctx };
if (typeof stage === "function") {
const isGenerator =
stage.constructor?.name === "AsyncGeneratorFunction" ||
stage.constructor?.name === "GeneratorFunction";
for (let idx = 0; idx < stages.length; idx++) {
const stage = stages[idx];
if (isGenerator) {
return { output: normalizeSdkOutput(stage(input, stageCtx)) };
}
let result;
const items = await collectItems(input);
return { output: normalizeSdkOutput(await stage(items, stageCtx)) };
}
if (typeof stage === "function") {
// Check if it's a generator function
const isGenerator =
stage.constructor?.name === "AsyncGeneratorFunction" ||
stage.constructor?.name === "GeneratorFunction";
if (typeof stage?.run === "function") {
const result = await stage.run({ input, ctx: stageCtx });
return result && "output" in result
? { ...result, output: normalizeSdkOutput(result.output) }
: result;
}
if (isGenerator) {
// Generator function - pass the stream directly
result = { output: stage(stream, ctx) };
} else {
// Regular function - collect items first, then call
const items = await collectItems(stream);
const output = await stage(items, ctx);
result = { output: toAsyncIterable(output) };
}
} else if (typeof stage?.run === "function") {
// Stage object with run method (primitives)
result = await stage.run({ input: stream, ctx });
} else {
throw new Error(`Invalid stage at index ${idx}: must be a function or have run() method`);
}
throw new Error(
`Invalid stage at index ${index}: must be a function or have run() method`,
);
},
},
]),
);
const stdout = runtimeCtx.stdout ?? createNullWritable();
const stderr = runtimeCtx.stderr ?? createNullWritable();
// Handle halt
if (result?.halt) {
halted = true;
haltedAt = { index: idx, stage };
stream = result.output ?? toAsyncIterable([]);
break;
}
stream = result?.output ?? toAsyncIterable([]);
}
// Collect final output
const items = await collectItems(stream);
return { items, halted, haltedAt };
return runCorePipeline({
pipeline,
registry: {
get(name) {
return commands.get(name);
},
},
stdin: runtimeCtx.stdin ?? { isTTY: false },
stdout,
stderr,
env: runtimeCtx.env ?? process.env,
mode: runtimeCtx.mode ?? "sdk",
cwd: runtimeCtx.cwd,
llmAdapters: runtimeCtx.llmAdapters,
signal: runtimeCtx.signal,
input: normalizeSdkOutput(input),
requestInputResume,
});
}
/**
@@ -134,34 +146,19 @@ export async function runPipeline({
env,
mode = "human",
input,
requestInputResume = undefined,
requestInputEnabled = true,
}) {
// This wraps the CLI-style pipeline execution
// Convert pipeline stages to functions using registry
const stages = pipeline.map((stage) => {
const command = registry.get(stage.name);
if (!command) {
throw new Error(`Unknown command: ${stage.name}`);
}
return {
run: async ({ input, ctx }) => {
return command.run({ input, args: stage.args, ctx });
},
};
});
const ctx = {
return runCorePipeline({
pipeline,
registry,
stdin,
stdout,
stderr,
env,
mode,
};
return runPipelineInternal({
stages,
ctx,
input: input ? await collectItems(input) : [],
input,
requestInputResume,
requestInputEnabled,
});
}
+264 -25
View File
@@ -22,6 +22,11 @@ import { CostTracker } from "../core/cost_tracker.js";
import type { CostLimit, CostSummary } from "../core/cost_tracker.js";
import { withRetry, resolveRetryConfig } from "../core/retry.js";
import type { RetryConfig } from "../core/retry.js";
import {
RequestInputResumeError,
validateCommandInputState,
type CommandInputState,
} from "../input_request.js";
export type WorkflowFile = {
name?: string;
@@ -178,8 +183,10 @@ export type WorkflowResumePayload = {
approvalStepId?: string;
approvalIdentity?: WorkflowApprovalIdentity;
inputStepId?: string;
inputKind?: "workflow_step" | "pipeline_command";
inputSchema?: unknown;
inputSubject?: unknown;
pipelineInput?: WorkflowPipelineInputResumeState;
};
type WorkflowResumeState = {
@@ -190,11 +197,20 @@ type WorkflowResumeState = {
approvalStepId?: string;
approvalIdentity?: WorkflowApprovalIdentity;
inputStepId?: string;
inputKind?: "workflow_step" | "pipeline_command";
inputSchema?: unknown;
inputSubject?: unknown;
pipelineInput?: WorkflowPipelineInputResumeState;
createdAt: string;
};
type WorkflowPipelineInputResumeState = {
pipeline: Array<{ name: string; args: Record<string, unknown>; raw: string }>;
resumeAtIndex: number;
items: unknown[];
commandInput: CommandInputState;
};
export class WorkflowResumeArgumentError extends Error {
constructor(message: string) {
super(message);
@@ -202,6 +218,33 @@ export class WorkflowResumeArgumentError extends Error {
}
}
class WorkflowPipelineInputSuspension extends Error {
stepId: string;
request: {
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
};
pipelineInput: WorkflowPipelineInputResumeState;
constructor({
stepId,
request,
pipelineInput,
}: {
stepId: string;
request: WorkflowPipelineInputSuspension["request"];
pipelineInput: WorkflowPipelineInputResumeState;
}) {
super(`Workflow step ${stepId} pipeline requested input`);
this.name = "WorkflowPipelineInputSuspension";
this.stepId = stepId;
this.request = request;
this.pipelineInput = pipelineInput;
}
}
export async function loadWorkflowFile(filePath: string): Promise<WorkflowFile> {
const text = await fsp.readFile(filePath, "utf8");
const ext = path.extname(filePath).toLowerCase();
@@ -726,6 +769,13 @@ export async function runWorkflowFile({
results[resumeState.approvalStepId] = previous;
}
let resumedPipelineInput: {
stepId: string;
response: unknown;
pipelineInput: WorkflowPipelineInputResumeState;
onConsumed?: () => Promise<void>;
} | null = null;
if (resumeState?.inputStepId) {
if (approved !== undefined) {
throw new WorkflowResumeArgumentError(
@@ -737,24 +787,61 @@ export async function runWorkflowFile({
"Workflow resume requires --response-json for input requests",
);
}
const inputStep = steps[stepIndexById.get(resumeState.inputStepId) ?? -1];
if (!inputStep || !isInputStep(inputStep.input)) {
throw new Error(`Invalid input step in resume state: ${resumeState.inputStepId}`);
}
try {
validateInputResponse({
schema: resumeState.inputSchema ?? inputStep.input.responseSchema,
if (resumeState.inputKind === "pipeline_command") {
const resumedStepIndex = stepIndexById.get(resumeState.inputStepId);
if (resumedStepIndex !== startIndex) {
throw new RequestInputResumeError("workflow input step changed since input request");
}
const pipelineStep = steps[resumedStepIndex];
if (!pipelineStep || typeof pipelineStep.pipeline !== "string") {
throw new Error(
`Invalid pipeline input step in resume state: ${resumeState.inputStepId}`,
);
}
if (!evaluateCondition(pipelineStep.when ?? pipelineStep.condition, results)) {
throw new RequestInputResumeError(
"workflow input step condition changed since input request",
);
}
try {
validateInputResponse({
schema: resumeState.inputSchema,
response,
stepId: pipelineStep.id,
});
} catch (err: any) {
throw new WorkflowResumeArgumentError(err?.message ?? String(err));
}
resumedPipelineInput = {
stepId: resumeState.inputStepId,
response,
stepId: inputStep.id,
});
} catch (err: any) {
throw new WorkflowResumeArgumentError(err?.message ?? String(err));
pipelineInput: resumeState.pipelineInput!,
onConsumed: consumedResumeStateKey
? async () => {
await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
}
: undefined,
};
} else {
const inputStep = steps[stepIndexById.get(resumeState.inputStepId) ?? -1];
if (!inputStep || !isInputStep(inputStep.input)) {
throw new Error(`Invalid input step in resume state: ${resumeState.inputStepId}`);
}
try {
validateInputResponse({
schema: resumeState.inputSchema ?? inputStep.input.responseSchema,
response,
stepId: inputStep.id,
});
} catch (err: any) {
throw new WorkflowResumeArgumentError(err?.message ?? String(err));
}
const previous = results[resumeState.inputStepId] ?? { id: resumeState.inputStepId };
previous.subject = resumeState.inputSubject ?? null;
previous.response = response;
delete previous.skipped;
results[resumeState.inputStepId] = previous;
}
const previous = results[resumeState.inputStepId] ?? { id: resumeState.inputStepId };
previous.subject = resumeState.inputSubject ?? null;
previous.response = response;
delete previous.skipped;
results[resumeState.inputStepId] = previous;
}
if (ctx.dryRun) {
@@ -925,6 +1012,7 @@ export async function runWorkflowFile({
ctx,
env: subEnv,
cwd: subCwd,
requestInputEnabled: false,
});
} else {
const inputValue = resolveInputValue(subStep.stdin, resolvedArgs, scopedResults);
@@ -1053,6 +1141,7 @@ export async function runWorkflowFile({
ctx: { ...ctx, signal: branchSignal },
env: branchEnv,
cwd: branchCwd,
requestInputEnabled: false,
});
return { branchId: branch.id, result: branchResult };
}
@@ -1196,6 +1285,14 @@ export async function runWorkflowFile({
ctx: { ...ctx, signal: stepSignal },
env,
cwd,
resume:
resumedPipelineInput?.stepId === step.id
? {
pipelineInput: resumedPipelineInput.pipelineInput,
response: resumedPipelineInput.response,
onConsumed: resumedPipelineInput.onConsumed,
}
: undefined,
});
} else {
const inputValue = resolveInputValue(step.stdin, resolvedArgs, results);
@@ -1216,6 +1313,12 @@ export async function runWorkflowFile({
? await withRetry(executeStepAttempt, retryConfig, {
signal: ctx.signal,
shouldRetry: (error) => {
if (
error instanceof WorkflowPipelineInputSuspension ||
error instanceof RequestInputResumeError
) {
return false;
}
const message = error?.message ?? String(error);
return !/halted (for approval inside|before completion at) pipeline/.test(
message,
@@ -1231,6 +1334,51 @@ export async function runWorkflowFile({
result = attemptResult.result;
parallelBranchResults = attemptResult.parallelBranchResults;
} catch (err: any) {
if (err instanceof WorkflowPipelineInputSuspension) {
const inputRequest = buildNeedsInputRequest({
stepId: err.stepId,
prompt: err.request.prompt,
responseSchema: err.request.responseSchema,
defaults: err.request.defaults,
subject: err.request.subject,
maxEnvelopeBytes: resolveToolEnvelopeMaxBytes(ctx.env),
});
const stateKey = await saveWorkflowResumeState(ctx.env, {
filePath: resolvedFilePath,
resumeAtIndex: idx,
steps: results,
args: resolvedArgs,
inputStepId: err.stepId,
inputKind: "pipeline_command",
inputSchema: err.request.responseSchema,
inputSubject: err.request.subject,
pipelineInput: err.pipelineInput,
createdAt: new Date().toISOString(),
});
if (consumedResumeStateKey && consumedResumeStateKey !== stateKey) {
await deleteStateJson({ env: ctx.env, key: consumedResumeStateKey });
}
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
kind: "workflow-file",
stateKey,
} satisfies WorkflowResumePayload);
return {
status: "needs_input",
output: [],
requiresInput: {
...inputRequest,
resumeToken,
},
};
}
if (err instanceof RequestInputResumeError) {
throw err;
}
if (ctx.signal?.aborted && (err?.name === "AbortError" || err?.code === "ABORT_ERR")) {
throw err;
}
@@ -1716,9 +1864,56 @@ async function loadWorkflowResumeState(env: Record<string, string | undefined>,
if (!data.steps || typeof data.steps !== "object")
throw new Error("Invalid workflow resume state");
if (!data.args || typeof data.args !== "object") throw new Error("Invalid workflow resume state");
if (
data.inputKind !== undefined &&
!["workflow_step", "pipeline_command"].includes(data.inputKind)
) {
throw new Error("Invalid workflow resume state");
}
if (data.inputKind === "pipeline_command") {
if (typeof data.inputStepId !== "string") throw new Error("Invalid workflow resume state");
if (data.inputSchema === undefined) throw new Error("Invalid workflow resume state");
data.pipelineInput = validateWorkflowPipelineInputResumeState(data.pipelineInput);
} else if (data.pipelineInput !== undefined) {
throw new Error("Invalid workflow resume state");
}
return data as WorkflowResumeState;
}
function validateWorkflowPipelineInputResumeState(
value: unknown,
): WorkflowPipelineInputResumeState {
if (!value || typeof value !== "object") throw new Error("Invalid workflow resume state");
const data = value as Partial<WorkflowPipelineInputResumeState>;
if (!Array.isArray(data.pipeline)) throw new Error("Invalid workflow resume state");
validateWorkflowPipelineShape(data.pipeline);
if (
typeof data.resumeAtIndex !== "number" ||
!Number.isInteger(data.resumeAtIndex) ||
data.resumeAtIndex < 0 ||
data.resumeAtIndex >= data.pipeline.length
) {
throw new Error("Invalid workflow resume state");
}
if (!Array.isArray(data.items)) throw new Error("Invalid workflow resume state");
data.commandInput = validateCommandInputState(data.commandInput);
return data as WorkflowPipelineInputResumeState;
}
function validateWorkflowPipelineShape(pipeline: unknown[]) {
for (const stage of pipeline) {
if (!stage || typeof stage !== "object") throw new Error("Invalid workflow resume state");
const data = stage as Record<string, unknown>;
if (typeof data.name !== "string" || data.name.length === 0) {
throw new Error("Invalid workflow resume state");
}
if (!data.args || typeof data.args !== "object" || Array.isArray(data.args)) {
throw new Error("Invalid workflow resume state");
}
if (typeof data.raw !== "string") throw new Error("Invalid workflow resume state");
}
}
function mergeEnv(
base: Record<string, string | undefined>,
workflowEnv: WorkflowFile["env"],
@@ -2652,6 +2847,8 @@ async function runPipelineStep({
ctx,
env,
cwd,
resume,
requestInputEnabled = true,
}: {
stepId: string;
pipelineText: string;
@@ -2659,11 +2856,26 @@ async function runPipelineStep({
ctx: RunContext;
env: Record<string, string | undefined>;
cwd?: string;
resume?: {
pipelineInput: WorkflowPipelineInputResumeState;
response: unknown;
onConsumed?: () => Promise<void>;
};
requestInputEnabled?: boolean;
}) {
let pipeline;
try {
pipeline = parsePipeline(pipelineText);
const currentPipeline = parsePipeline(pipelineText);
if (resume) {
if (!isDeepStrictEqual(currentPipeline, resume.pipelineInput.pipeline)) {
throw new RequestInputResumeError("workflow pipeline changed since input request");
}
pipeline = resume.pipelineInput.pipeline;
} else {
pipeline = currentPipeline;
}
} catch (err: any) {
if (err instanceof RequestInputResumeError) throw err;
throw new Error(
`Workflow step ${stepId} pipeline parse failed: ${err?.message ?? String(err)}`,
);
@@ -2676,8 +2888,10 @@ async function runPipelineStep({
renderedStdout += String(chunk);
});
const pipelineStartIndex = resume ? resume.pipelineInput.resumeAtIndex : 0;
const remainingPipeline = pipeline.slice(pipelineStartIndex);
const result = await runPipeline({
pipeline,
pipeline: remainingPipeline,
registry: ctx.registry,
stdin: ctx.stdin,
stdout,
@@ -2687,7 +2901,15 @@ async function runPipelineStep({
cwd,
signal: ctx.signal,
llmAdapters: ctx.llmAdapters,
input: inputValueToStream(inputValue),
input: resume ? resume.pipelineInput.items : inputValueToPipelineItems(inputValue),
requestInputEnabled,
requestInputResume: resume
? {
state: resume.pipelineInput.commandInput,
response: resume.response,
onConsumed: resume.onConsumed,
}
: undefined,
});
stdout.end();
@@ -2698,6 +2920,27 @@ async function runPipelineStep({
`Workflow step ${stepId} halted for approval inside pipeline stage ${haltedName}. Use a separate approval step in the workflow file.`,
);
}
const request =
result.items.length === 1 && result.items[0]?.type === "input_request"
? (result.items[0] as Record<string, any>)
: null;
if (request?.commandInput) {
throw new WorkflowPipelineInputSuspension({
stepId,
request: {
prompt: String(request.prompt),
responseSchema: request.responseSchema,
...(request.defaults !== undefined ? { defaults: request.defaults } : null),
...(request.subject !== undefined ? { subject: request.subject } : null),
},
pipelineInput: {
pipeline,
resumeAtIndex: pipelineStartIndex + (result.haltedAt?.index ?? 0),
items: Array.isArray(request.items) ? request.items : [],
commandInput: request.commandInput,
},
});
}
throw new Error(
`Workflow step ${stepId} halted before completion at pipeline stage ${haltedName}`,
);
@@ -2772,12 +3015,8 @@ function* inputValueToItems(value: unknown) {
yield value;
}
function inputValueToStream(value: unknown) {
return (async function* () {
for (const item of inputValueToItems(value)) {
yield item;
}
})();
function inputValueToPipelineItems(value: unknown) {
return [...inputValueToItems(value)];
}
function serializePipelineItemsToStdout(items: unknown[]) {
+22
View File
@@ -83,6 +83,28 @@ test("for_each supports custom item_var and index_var", async () => {
]);
});
test("for_each pipeline sub-steps reject command-level requestInput", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([1]))"' },
{
id: "loop",
for_each: "$vals.json",
steps: [
{
id: "review",
pipeline: "ask --prompt 'Review?'",
},
],
},
],
}),
/requestInput is not supported in this pipeline context/,
);
});
test("for_each throws when source is not an array", async () => {
await assert.rejects(
() =>
+18
View File
@@ -122,6 +122,24 @@ test("parallel wait=all propagates branch failure", async () => {
);
});
test("parallel pipeline branches reject command-level requestInput", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "p",
parallel: {
wait: "all",
branches: [{ id: "review", pipeline: "ask --prompt 'Review?'" }],
},
},
],
}),
/requestInput is not supported in this pipeline context/,
);
});
test("parallel validation rejects empty branches", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
File diff suppressed because it is too large Load Diff
+139
View File
@@ -1,7 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Lobster } from "../src/sdk/Lobster.js";
import { stateSet } from "../src/sdk/primitives/state.js";
import { decodeToken, encodeToken } from "../src/token.js";
test("sdk Lobster.resume accepts structured input responses", async () => {
const workflow = new Lobster().pipe({
@@ -65,3 +70,137 @@ test("sdk Lobster.resume rejects invalid structured input responses", async () =
/does not match schema/i,
);
});
test("sdk Lobster.resume replays command-level requestInput with a fresh instance", async () => {
const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-sdk-input-"));
let runs = 0;
const schema = {
type: "object",
properties: { choice: { type: "string", enum: ["red", "blue"] } },
required: ["choice"],
};
const choose = {
async run({ input, ctx }: any) {
runs += 1;
const items = [];
for await (const item of input) items.push(item);
const response = await ctx.requestInput({
prompt: "Pick a color",
responseSchema: schema,
suspendedState: { count: items.length },
});
return { output: [{ runs, items, choice: response.choice }] };
},
};
const createWorkflow = () => new Lobster({ stateDir }).pipe(choose);
const first = await createWorkflow().run([{ id: 1 }]);
assert.equal(first.ok, true);
assert.equal(first.status, "needs_input");
assert.ok(first.requiresInput?.resumeToken);
const payload = decodeToken(first.requiresInput.resumeToken) as any;
assert.equal(payload.resumeMode, "same_stage");
assert.equal(payload.resumeAtIndex, 0);
assert.match(payload.stateKey, /^sdk_resume_/);
assert.equal(payload.commandInput, undefined);
const resumed = await createWorkflow().resume(first.requiresInput.resumeToken, {
response: { choice: "red" },
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ runs: 2, items: [{ id: 1 }], choice: "red" }]);
});
test("sdk Lobster.resume rejects command-level requestInput tampering and metadata drift", async () => {
const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-sdk-input-tamper-"));
const schema = {
type: "object",
properties: { choice: { type: "string" } },
required: ["choice"],
};
let prompt = "Original";
const workflow = new Lobster({ stateDir }).pipe({
async run({ ctx }: any) {
const response = await ctx.requestInput({ prompt, responseSchema: schema });
return { output: [response] };
},
});
const first = await workflow.run();
assert.equal(first.status, "needs_input");
const payload = decodeToken(first.requiresInput!.resumeToken) as any;
payload.stateKey = "sdk_resume_forged";
const forged = encodeToken(payload);
await assert.rejects(
() => workflow.resume(forged, { response: { choice: "red" } }),
/SDK resume state not found/,
);
prompt = "Changed";
const resumed = await workflow.resume(first.requiresInput!.resumeToken, {
response: { choice: "red" },
});
assert.equal(resumed.ok, false);
assert.equal(resumed.status, "error");
assert.match(resumed.error?.message ?? "", /does not match suspended request/);
});
test("sdk Lobster stores chained requestInput history outside the resume token", async () => {
const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-sdk-input-chain-"));
const schema = {
type: "object",
properties: { choice: { type: "string", enum: ["red", "blue"] } },
required: ["choice"],
};
const createWorkflow = () =>
new Lobster({ stateDir }).pipe({
async run({ ctx }: any) {
const first = await ctx.requestInput({ prompt: "First", responseSchema: schema });
const second = await ctx.requestInput({
prompt: `Second after ${first.choice}`,
responseSchema: schema,
});
return { output: [{ first: first.choice, second: second.choice }] };
},
});
const first = await createWorkflow().run();
assert.equal(first.status, "needs_input");
const second = await createWorkflow().resume(first.requiresInput!.resumeToken, {
response: { choice: "red" },
});
assert.equal(second.status, "needs_input");
const payload = decodeToken(second.requiresInput!.resumeToken) as any;
assert.equal(payload.resumeMode, "same_stage");
assert.match(payload.stateKey, /^sdk_resume_/);
assert.equal(payload.commandInput, undefined);
assert.equal(payload.items, undefined);
const final = await createWorkflow().resume(second.requiresInput!.resumeToken, {
response: { choice: "blue" },
});
assert.equal(final.ok, true);
assert.equal(final.status, "ok");
assert.deepEqual(final.output, [{ first: "red", second: "blue" }]);
});
test("sdk Lobster preserves custom stateDir while using core runtime", async () => {
const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-sdk-state-dir-"));
const workflow = new Lobster({ stateDir })
.pipe(() => ({ saved: true }))
.pipe(stateSet("custom-key"));
const result = await workflow.run();
assert.equal(result.ok, true);
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ saved: true }]);
assert.equal(
await fsp.readFile(path.join(stateDir, "custom-key.json"), "utf8"),
'{\n "saved": true\n}\n',
);
});
+550
View File
@@ -8,6 +8,13 @@ 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 { 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 = {
@@ -288,6 +295,549 @@ test("workflow file input steps pause and resume with structured responses", asy
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",
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 = {