fix(workflows): support legacy resume key aliases cleanly (#4) (thanks @brownetw-ai)

This commit is contained in:
Vignesh Natarajan
2026-04-11 16:42:58 -07:00
committed by Vignesh
parent 14e46b388c
commit a95f133c2e
3 changed files with 87 additions and 7 deletions
+1
View File
@@ -4,6 +4,7 @@ All notable changes to Lobster will be documented in this file.
## Unreleased
- Improve workflow resume compatibility for `stateKey` naming by accepting both `workflow_resume_` and `workflow-resume_` prefixes, including cleanup against the resolved on-disk key. Thanks to [@brownetw-ai](https://github.com/brownetw-ai) (PR [#4](https://github.com/openclaw/lobster/pull/4)).
- Add per-step workflow `retry` policies (`max`, `backoff`, `delay_ms`, `max_delay_ms`, `jitter`) with retry-aware stderr logs and dry-run visibility. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#84](https://github.com/openclaw/lobster/pull/84)).
- Add optional approval identity constraints for workflow gates (`approval.initiated_by`, `approval.required_approver`, `approval.require_different_approver`) with resume-time enforcement via `LOBSTER_APPROVAL_APPROVED_BY` and envelope metadata for integrations. Thanks to [@coolmanns](https://github.com/coolmanns) (Issue [#44](https://github.com/openclaw/lobster/issues/44)).
- Clarify `pipeline:` vs `run:` usage for `llm.invoke` / `llm_task.invoke` in workflow files, and add regression coverage to ensure `stdin: $step.stdout` is forwarded as LLM artifacts for `llm_task.invoke` pipeline steps. Thanks to [@RatkoJ](https://github.com/RatkoJ) (Issue [#41](https://github.com/openclaw/lobster/issues/41)).
+32 -7
View File
@@ -566,10 +566,10 @@ export async function runWorkflowFile({
cancel?: boolean;
}): Promise<WorkflowRunResult> {
const consumedResumeStateKey = resume?.stateKey && typeof resume.stateKey === 'string'
? resume.stateKey
? await resolveWorkflowResumeStateKey(ctx.env, resume.stateKey)
: null;
const resumeState = resume?.stateKey
? await loadWorkflowResumeState(ctx.env, resume.stateKey)
? await loadWorkflowResumeState(ctx.env, consumedResumeStateKey ?? resume.stateKey)
: resume ?? null;
if (resumeState?.approvalStepId && resumeState?.inputStepId) {
throw new Error('Invalid workflow resume state');
@@ -1476,14 +1476,39 @@ async function saveWorkflowResumeState(env: Record<string, string | undefined>,
return stateKey;
}
function alternateWorkflowResumeStateKey(stateKey: string): string | null {
if (stateKey.includes('workflow-resume_')) {
return stateKey.replace('workflow-resume_', 'workflow_resume_');
}
if (stateKey.includes('workflow_resume_')) {
return stateKey.replace('workflow_resume_', 'workflow-resume_');
}
return null;
}
async function resolveWorkflowResumeStateKey(
env: Record<string, string | undefined>,
stateKey: string,
): Promise<string> {
const stored = await readStateJson({ env, key: stateKey });
if (stored && typeof stored === 'object') {
return stateKey;
}
const altKey = alternateWorkflowResumeStateKey(stateKey);
if (!altKey) {
return stateKey;
}
const altStored = await readStateJson({ env, key: altKey });
if (altStored && typeof altStored === 'object') {
return altKey;
}
return stateKey;
}
async function loadWorkflowResumeState(env: Record<string, string | undefined>, stateKey: string) {
let stored = await readStateJson({ env, key: stateKey });
if ((!stored || typeof stored !== 'object') && typeof stateKey === 'string') {
const altKey = stateKey.includes('workflow-resume_')
? stateKey.replace('workflow-resume_', 'workflow_resume_')
: stateKey.includes('workflow_resume_')
? stateKey.replace('workflow_resume_', 'workflow-resume_')
: null;
const altKey = alternateWorkflowResumeStateKey(stateKey);
if (altKey) {
stored = await readStateJson({ env, key: altKey });
}
+54
View File
@@ -143,6 +143,60 @@ test('workflow resume cancellation cleans up resume state', async () => {
assert.deepEqual(resumeStateFiles, []);
});
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: [