mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 08:52:48 +00:00
fix(workflows): support legacy resume key aliases cleanly (#4) (thanks @brownetw-ai)
This commit is contained in:
committed by
Vignesh
parent
14e46b388c
commit
a95f133c2e
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user