@plateforme-ai/lobster 2026.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/VISION.md +249 -0
- package/bin/clawd.invoke.js +18 -0
- package/bin/lobster.js +24 -0
- package/bin/openclaw.invoke.js +21 -0
- package/dist/src/cli.js +793 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/commands/commands_list.js +49 -0
- package/dist/src/commands/commands_list.js.map +1 -0
- package/dist/src/commands/registry.js +66 -0
- package/dist/src/commands/registry.js.map +1 -0
- package/dist/src/commands/stdlib/approve.js +77 -0
- package/dist/src/commands/stdlib/approve.js.map +1 -0
- package/dist/src/commands/stdlib/ask.js +171 -0
- package/dist/src/commands/stdlib/ask.js.map +1 -0
- package/dist/src/commands/stdlib/dedupe.js +55 -0
- package/dist/src/commands/stdlib/dedupe.js.map +1 -0
- package/dist/src/commands/stdlib/diff_last.js +35 -0
- package/dist/src/commands/stdlib/diff_last.js.map +1 -0
- package/dist/src/commands/stdlib/email_triage.js +279 -0
- package/dist/src/commands/stdlib/email_triage.js.map +1 -0
- package/dist/src/commands/stdlib/exec.js +130 -0
- package/dist/src/commands/stdlib/exec.js.map +1 -0
- package/dist/src/commands/stdlib/gog_gmail_search.js +94 -0
- package/dist/src/commands/stdlib/gog_gmail_search.js.map +1 -0
- package/dist/src/commands/stdlib/gog_gmail_send.js +104 -0
- package/dist/src/commands/stdlib/gog_gmail_send.js.map +1 -0
- package/dist/src/commands/stdlib/group_by.js +59 -0
- package/dist/src/commands/stdlib/group_by.js.map +1 -0
- package/dist/src/commands/stdlib/head.js +34 -0
- package/dist/src/commands/stdlib/head.js.map +1 -0
- package/dist/src/commands/stdlib/json.js +20 -0
- package/dist/src/commands/stdlib/json.js.map +1 -0
- package/dist/src/commands/stdlib/llm_invoke.js +758 -0
- package/dist/src/commands/stdlib/llm_invoke.js.map +1 -0
- package/dist/src/commands/stdlib/llm_task_invoke.js +2 -0
- package/dist/src/commands/stdlib/llm_task_invoke.js.map +1 -0
- package/dist/src/commands/stdlib/map.js +104 -0
- package/dist/src/commands/stdlib/map.js.map +1 -0
- package/dist/src/commands/stdlib/openclaw_invoke.js +136 -0
- package/dist/src/commands/stdlib/openclaw_invoke.js.map +1 -0
- package/dist/src/commands/stdlib/pick.js +45 -0
- package/dist/src/commands/stdlib/pick.js.map +1 -0
- package/dist/src/commands/stdlib/sort.js +86 -0
- package/dist/src/commands/stdlib/sort.js.map +1 -0
- package/dist/src/commands/stdlib/state.js +76 -0
- package/dist/src/commands/stdlib/state.js.map +1 -0
- package/dist/src/commands/stdlib/table.js +57 -0
- package/dist/src/commands/stdlib/table.js.map +1 -0
- package/dist/src/commands/stdlib/template.js +126 -0
- package/dist/src/commands/stdlib/template.js.map +1 -0
- package/dist/src/commands/stdlib/where.js +81 -0
- package/dist/src/commands/stdlib/where.js.map +1 -0
- package/dist/src/commands/types.js +2 -0
- package/dist/src/commands/types.js.map +1 -0
- package/dist/src/commands/workflows/workflows_list.js +24 -0
- package/dist/src/commands/workflows/workflows_list.js.map +1 -0
- package/dist/src/commands/workflows/workflows_run.js +74 -0
- package/dist/src/commands/workflows/workflows_run.js.map +1 -0
- package/dist/src/core/cost_tracker.js +119 -0
- package/dist/src/core/cost_tracker.js.map +1 -0
- package/dist/src/core/filters.js +102 -0
- package/dist/src/core/filters.js.map +1 -0
- package/dist/src/core/index.js +7 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/core/retry.js +89 -0
- package/dist/src/core/retry.js.map +1 -0
- package/dist/src/core/tool_runtime.js +289 -0
- package/dist/src/core/tool_runtime.js.map +1 -0
- package/dist/src/input_request.js +430 -0
- package/dist/src/input_request.js.map +1 -0
- package/dist/src/parser.js +145 -0
- package/dist/src/parser.js.map +1 -0
- package/dist/src/pipeline_resume_state.js +186 -0
- package/dist/src/pipeline_resume_state.js.map +1 -0
- package/dist/src/read_line.js +50 -0
- package/dist/src/read_line.js.map +1 -0
- package/dist/src/recipes/github/index.js +16 -0
- package/dist/src/recipes/github/index.js.map +1 -0
- package/dist/src/recipes/github/pr-monitor.js +248 -0
- package/dist/src/recipes/github/pr-monitor.js.map +1 -0
- package/dist/src/recipes/github/stages/pr-view.js +107 -0
- package/dist/src/recipes/github/stages/pr-view.js.map +1 -0
- package/dist/src/recipes/index.js +7 -0
- package/dist/src/recipes/index.js.map +1 -0
- package/dist/src/recipes/registry.js +30 -0
- package/dist/src/recipes/registry.js.map +1 -0
- package/dist/src/renderers/json.js +13 -0
- package/dist/src/renderers/json.js.map +1 -0
- package/dist/src/resume.js +179 -0
- package/dist/src/resume.js.map +1 -0
- package/dist/src/runtime.js +230 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/sdk/Lobster.js +402 -0
- package/dist/src/sdk/Lobster.js.map +1 -0
- package/dist/src/sdk/index.js +25 -0
- package/dist/src/sdk/index.js.map +1 -0
- package/dist/src/sdk/primitives/approve.js +47 -0
- package/dist/src/sdk/primitives/approve.js.map +1 -0
- package/dist/src/sdk/primitives/diff.js +156 -0
- package/dist/src/sdk/primitives/diff.js.map +1 -0
- package/dist/src/sdk/primitives/exec.js +167 -0
- package/dist/src/sdk/primitives/exec.js.map +1 -0
- package/dist/src/sdk/primitives/state.js +203 -0
- package/dist/src/sdk/primitives/state.js.map +1 -0
- package/dist/src/sdk/runtime.js +131 -0
- package/dist/src/sdk/runtime.js.map +1 -0
- package/dist/src/sdk/token.js +9 -0
- package/dist/src/sdk/token.js.map +1 -0
- package/dist/src/shell.js +39 -0
- package/dist/src/shell.js.map +1 -0
- package/dist/src/state/store.js +337 -0
- package/dist/src/state/store.js.map +1 -0
- package/dist/src/token.js +15 -0
- package/dist/src/token.js.map +1 -0
- package/dist/src/validation.js +38 -0
- package/dist/src/validation.js.map +1 -0
- package/dist/src/workflows/file.js +2405 -0
- package/dist/src/workflows/file.js.map +1 -0
- package/dist/src/workflows/github_pr_monitor.js +167 -0
- package/dist/src/workflows/github_pr_monitor.js.map +1 -0
- package/dist/src/workflows/graph.js +234 -0
- package/dist/src/workflows/graph.js.map +1 -0
- package/dist/src/workflows/registry.js +57 -0
- package/dist/src/workflows/registry.js.map +1 -0
- package/dist/test/approval_id.test.js +171 -0
- package/dist/test/approval_id.test.js.map +1 -0
- package/dist/test/approve_preview.test.js +38 -0
- package/dist/test/approve_preview.test.js.map +1 -0
- package/dist/test/clawd_invoke.test.js +124 -0
- package/dist/test/clawd_invoke.test.js.map +1 -0
- package/dist/test/clawd_invoke_legacy.test.js +63 -0
- package/dist/test/clawd_invoke_legacy.test.js.map +1 -0
- package/dist/test/cli_run_file_args_json.test.js +27 -0
- package/dist/test/cli_run_file_args_json.test.js.map +1 -0
- package/dist/test/commands_list.test.js +44 -0
- package/dist/test/commands_list.test.js.map +1 -0
- package/dist/test/condition_comparison.test.js +127 -0
- package/dist/test/condition_comparison.test.js.map +1 -0
- package/dist/test/core_tool_runtime.test.js +160 -0
- package/dist/test/core_tool_runtime.test.js.map +1 -0
- package/dist/test/cost_tracker.test.js +231 -0
- package/dist/test/cost_tracker.test.js.map +1 -0
- package/dist/test/dedupe.test.js +48 -0
- package/dist/test/dedupe.test.js.map +1 -0
- package/dist/test/diff_last.test.js +70 -0
- package/dist/test/diff_last.test.js.map +1 -0
- package/dist/test/doctor.test.js +19 -0
- package/dist/test/doctor.test.js.map +1 -0
- package/dist/test/dry_run.test.js +502 -0
- package/dist/test/dry_run.test.js.map +1 -0
- package/dist/test/email_triage.test.js +296 -0
- package/dist/test/email_triage.test.js.map +1 -0
- package/dist/test/exec_stdin.test.js +43 -0
- package/dist/test/exec_stdin.test.js.map +1 -0
- package/dist/test/for_each.test.js +228 -0
- package/dist/test/for_each.test.js.map +1 -0
- package/dist/test/github_pr_notify_format.test.js +19 -0
- package/dist/test/github_pr_notify_format.test.js.map +1 -0
- package/dist/test/github_pr_summary.test.js +41 -0
- package/dist/test/github_pr_summary.test.js.map +1 -0
- package/dist/test/group_by.test.js +43 -0
- package/dist/test/group_by.test.js.map +1 -0
- package/dist/test/llm_invoke.test.js +166 -0
- package/dist/test/llm_invoke.test.js.map +1 -0
- package/dist/test/llm_task_invoke.test.js +416 -0
- package/dist/test/llm_task_invoke.test.js.map +1 -0
- package/dist/test/map.test.js +41 -0
- package/dist/test/map.test.js.map +1 -0
- package/dist/test/multi_approval_resume.test.js +48 -0
- package/dist/test/multi_approval_resume.test.js.map +1 -0
- package/dist/test/on_error.test.js +151 -0
- package/dist/test/on_error.test.js.map +1 -0
- package/dist/test/openclaw_invoke_alias.test.js +13 -0
- package/dist/test/openclaw_invoke_alias.test.js.map +1 -0
- package/dist/test/parallel.test.js +184 -0
- package/dist/test/parallel.test.js.map +1 -0
- package/dist/test/parser.test.js +39 -0
- package/dist/test/parser.test.js.map +1 -0
- package/dist/test/read_line.test.js +25 -0
- package/dist/test/read_line.test.js.map +1 -0
- package/dist/test/request_input.test.js +946 -0
- package/dist/test/request_input.test.js.map +1 -0
- package/dist/test/resume.test.js +82 -0
- package/dist/test/resume.test.js.map +1 -0
- package/dist/test/sdk_lobster.test.js +177 -0
- package/dist/test/sdk_lobster.test.js.map +1 -0
- package/dist/test/shell.test.js +31 -0
- package/dist/test/shell.test.js.map +1 -0
- package/dist/test/sort.test.js +51 -0
- package/dist/test/sort.test.js.map +1 -0
- package/dist/test/state.test.js +336 -0
- package/dist/test/state.test.js.map +1 -0
- package/dist/test/step_retry.test.js +254 -0
- package/dist/test/step_retry.test.js.map +1 -0
- package/dist/test/step_timeout.test.js +154 -0
- package/dist/test/step_timeout.test.js.map +1 -0
- package/dist/test/template.test.js +46 -0
- package/dist/test/template.test.js.map +1 -0
- package/dist/test/template_filters.test.js +107 -0
- package/dist/test/template_filters.test.js.map +1 -0
- package/dist/test/tool_envelope_version.test.js +15 -0
- package/dist/test/tool_envelope_version.test.js.map +1 -0
- package/dist/test/tool_mode.test.js +83 -0
- package/dist/test/tool_mode.test.js.map +1 -0
- package/dist/test/validation.test.js +28 -0
- package/dist/test/validation.test.js.map +1 -0
- package/dist/test/workflow_args_env.test.js +41 -0
- package/dist/test/workflow_args_env.test.js.map +1 -0
- package/dist/test/workflow_composition.test.js +238 -0
- package/dist/test/workflow_composition.test.js.map +1 -0
- package/dist/test/workflow_file.test.js +1399 -0
- package/dist/test/workflow_file.test.js.map +1 -0
- package/dist/test/workflow_graph.test.js +97 -0
- package/dist/test/workflow_graph.test.js.map +1 -0
- package/dist/test/workflows.test.js +32 -0
- package/dist/test/workflows.test.js.map +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,1399 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { createDefaultRegistry } from "../src/commands/registry.js";
|
|
8
|
+
import { runWorkflowFile } from "../src/workflows/file.js";
|
|
9
|
+
import { decodeResumeToken } from "../src/resume.js";
|
|
10
|
+
import { readStateJson } from "../src/state/store.js";
|
|
11
|
+
function streamOf(items) {
|
|
12
|
+
return (async function* () {
|
|
13
|
+
for (const item of items)
|
|
14
|
+
yield item;
|
|
15
|
+
})();
|
|
16
|
+
}
|
|
17
|
+
test("workflow file runs with approval and resume", async () => {
|
|
18
|
+
const workflow = {
|
|
19
|
+
name: "sample",
|
|
20
|
+
steps: [
|
|
21
|
+
{
|
|
22
|
+
id: "collect",
|
|
23
|
+
command: 'node -e "process.stdout.write(JSON.stringify([{value:1}]))"',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "mutate",
|
|
27
|
+
command: "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const items=JSON.parse(d);items[0].value=2;process.stdout.write(JSON.stringify(items));});\"",
|
|
28
|
+
stdin: "$collect.stdout",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "approve_step",
|
|
32
|
+
command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
|
|
33
|
+
approval: "required",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "finish",
|
|
37
|
+
command: "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const items=JSON.parse(d);process.stdout.write(JSON.stringify({done:true,value:items[0].value}));});\"",
|
|
38
|
+
stdin: "$mutate.stdout",
|
|
39
|
+
condition: "$approve_step.approved",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-"));
|
|
44
|
+
const stateDir = path.join(tmpDir, "state");
|
|
45
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
46
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
47
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
48
|
+
const first = await runWorkflowFile({
|
|
49
|
+
filePath,
|
|
50
|
+
ctx: {
|
|
51
|
+
stdin: process.stdin,
|
|
52
|
+
stdout: process.stdout,
|
|
53
|
+
stderr: process.stderr,
|
|
54
|
+
env,
|
|
55
|
+
mode: "tool",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
assert.equal(first.status, "needs_approval");
|
|
59
|
+
assert.equal(first.requiresApproval?.prompt, "Proceed?");
|
|
60
|
+
assert.ok(first.requiresApproval?.resumeToken);
|
|
61
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
62
|
+
assert.equal(payload.kind, "workflow-file");
|
|
63
|
+
const resumed = await runWorkflowFile({
|
|
64
|
+
filePath,
|
|
65
|
+
ctx: {
|
|
66
|
+
stdin: process.stdin,
|
|
67
|
+
stdout: process.stdout,
|
|
68
|
+
stderr: process.stderr,
|
|
69
|
+
env,
|
|
70
|
+
mode: "tool",
|
|
71
|
+
},
|
|
72
|
+
resume: payload,
|
|
73
|
+
approved: true,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(resumed.status, "ok");
|
|
76
|
+
assert.deepEqual(resumed.output, [{ done: true, value: 2 }]);
|
|
77
|
+
const stateFiles = await fsp.readdir(stateDir);
|
|
78
|
+
const resumeStateFiles = stateFiles.filter((name) => name.startsWith("workflow_resume_"));
|
|
79
|
+
assert.deepEqual(resumeStateFiles, []);
|
|
80
|
+
});
|
|
81
|
+
test("workflow resume cancellation cleans up resume state", async () => {
|
|
82
|
+
const workflow = {
|
|
83
|
+
steps: [
|
|
84
|
+
{
|
|
85
|
+
id: "approve_step",
|
|
86
|
+
command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
|
|
87
|
+
approval: "required",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "finish",
|
|
91
|
+
command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
|
|
92
|
+
condition: "$approve_step.approved",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-cancel-"));
|
|
97
|
+
const stateDir = path.join(tmpDir, "state");
|
|
98
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
99
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
100
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
101
|
+
const first = await runWorkflowFile({
|
|
102
|
+
filePath,
|
|
103
|
+
ctx: {
|
|
104
|
+
stdin: process.stdin,
|
|
105
|
+
stdout: process.stdout,
|
|
106
|
+
stderr: process.stderr,
|
|
107
|
+
env,
|
|
108
|
+
mode: "tool",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
assert.equal(first.status, "needs_approval");
|
|
112
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
113
|
+
assert.equal(payload.kind, "workflow-file");
|
|
114
|
+
assert.ok(payload.stateKey);
|
|
115
|
+
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
|
|
116
|
+
const cancelled = await runWorkflowFile({
|
|
117
|
+
filePath,
|
|
118
|
+
ctx: {
|
|
119
|
+
stdin: process.stdin,
|
|
120
|
+
stdout: process.stdout,
|
|
121
|
+
stderr: process.stderr,
|
|
122
|
+
env,
|
|
123
|
+
mode: "tool",
|
|
124
|
+
},
|
|
125
|
+
resume: payload,
|
|
126
|
+
approved: false,
|
|
127
|
+
});
|
|
128
|
+
assert.equal(cancelled.status, "cancelled");
|
|
129
|
+
assert.deepEqual(cancelled.output, []);
|
|
130
|
+
const files = await fsp.readdir(stateDir);
|
|
131
|
+
const resumeStateFiles = files.filter((name) => name.startsWith("workflow_resume_"));
|
|
132
|
+
assert.deepEqual(resumeStateFiles, []);
|
|
133
|
+
});
|
|
134
|
+
test("workflow resume accepts workflow-resume_ state key aliases and cleans up state", async () => {
|
|
135
|
+
const workflow = {
|
|
136
|
+
steps: [
|
|
137
|
+
{
|
|
138
|
+
id: "approve_step",
|
|
139
|
+
command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
|
|
140
|
+
approval: "required",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "finish",
|
|
144
|
+
command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
|
|
145
|
+
condition: "$approve_step.approved",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-alias-"));
|
|
150
|
+
const stateDir = path.join(tmpDir, "state");
|
|
151
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
152
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
153
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
154
|
+
const first = await runWorkflowFile({
|
|
155
|
+
filePath,
|
|
156
|
+
ctx: {
|
|
157
|
+
stdin: process.stdin,
|
|
158
|
+
stdout: process.stdout,
|
|
159
|
+
stderr: process.stderr,
|
|
160
|
+
env,
|
|
161
|
+
mode: "tool",
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
assert.equal(first.status, "needs_approval");
|
|
165
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
166
|
+
assert.equal(payload.kind, "workflow-file");
|
|
167
|
+
assert.ok(payload.stateKey?.startsWith("workflow_resume_"));
|
|
168
|
+
const aliasedPayload = {
|
|
169
|
+
...payload,
|
|
170
|
+
stateKey: (payload.stateKey ?? "").replace("workflow_resume_", "workflow-resume_"),
|
|
171
|
+
};
|
|
172
|
+
assert.ok(aliasedPayload.stateKey.startsWith("workflow-resume_"));
|
|
173
|
+
const resumed = await runWorkflowFile({
|
|
174
|
+
filePath,
|
|
175
|
+
ctx: {
|
|
176
|
+
stdin: process.stdin,
|
|
177
|
+
stdout: process.stdout,
|
|
178
|
+
stderr: process.stderr,
|
|
179
|
+
env,
|
|
180
|
+
mode: "tool",
|
|
181
|
+
},
|
|
182
|
+
resume: aliasedPayload,
|
|
183
|
+
approved: true,
|
|
184
|
+
});
|
|
185
|
+
assert.equal(resumed.status, "ok");
|
|
186
|
+
assert.deepEqual(resumed.output, [{ done: true }]);
|
|
187
|
+
const files = await fsp.readdir(stateDir);
|
|
188
|
+
const resumeStateFiles = files.filter((name) => name.startsWith("workflow_resume_") || name.startsWith("workflow-resume_"));
|
|
189
|
+
assert.deepEqual(resumeStateFiles, []);
|
|
190
|
+
});
|
|
191
|
+
test("workflow file input steps pause and resume with structured responses", async () => {
|
|
192
|
+
const workflow = {
|
|
193
|
+
steps: [
|
|
194
|
+
{
|
|
195
|
+
id: "draft",
|
|
196
|
+
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: "review",
|
|
200
|
+
input: {
|
|
201
|
+
prompt: "Review draft?",
|
|
202
|
+
responseSchema: {
|
|
203
|
+
type: "object",
|
|
204
|
+
properties: { decision: { type: "string" } },
|
|
205
|
+
required: ["decision"],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "finish",
|
|
211
|
+
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION,subject:process.env.SUBJECT}))"',
|
|
212
|
+
env: {
|
|
213
|
+
DECISION: "$review.response.decision",
|
|
214
|
+
SUBJECT: "$review.subject.text",
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-"));
|
|
220
|
+
const stateDir = path.join(tmpDir, "state");
|
|
221
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
222
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
223
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
224
|
+
const first = await runWorkflowFile({
|
|
225
|
+
filePath,
|
|
226
|
+
ctx: {
|
|
227
|
+
stdin: process.stdin,
|
|
228
|
+
stdout: process.stdout,
|
|
229
|
+
stderr: process.stderr,
|
|
230
|
+
env,
|
|
231
|
+
mode: "tool",
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
assert.equal(first.status, "needs_input");
|
|
235
|
+
assert.deepEqual(first.requiresInput?.subject, { text: "hello" });
|
|
236
|
+
assert.ok(first.requiresInput?.resumeToken);
|
|
237
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
238
|
+
assert.equal(payload.kind, "workflow-file");
|
|
239
|
+
const resumeEnv = { ...env };
|
|
240
|
+
delete resumeEnv.LONG_TEXT;
|
|
241
|
+
const resumed = await runWorkflowFile({
|
|
242
|
+
filePath,
|
|
243
|
+
ctx: {
|
|
244
|
+
stdin: process.stdin,
|
|
245
|
+
stdout: process.stdout,
|
|
246
|
+
stderr: process.stderr,
|
|
247
|
+
env: resumeEnv,
|
|
248
|
+
mode: "tool",
|
|
249
|
+
},
|
|
250
|
+
resume: payload,
|
|
251
|
+
response: { decision: "approve" },
|
|
252
|
+
});
|
|
253
|
+
assert.equal(resumed.status, "ok");
|
|
254
|
+
assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
|
|
255
|
+
});
|
|
256
|
+
test("workflow pipeline command input pauses and resumes the same pipeline step", async () => {
|
|
257
|
+
const schema = JSON.stringify({
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: { decision: { type: "string", enum: ["approve", "reject"] } },
|
|
260
|
+
required: ["decision"],
|
|
261
|
+
});
|
|
262
|
+
const workflow = {
|
|
263
|
+
steps: [
|
|
264
|
+
{
|
|
265
|
+
id: "draft",
|
|
266
|
+
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "review",
|
|
270
|
+
pipeline: `ask --subject-from-stdin --prompt 'Review draft?' --schema ${JSON.stringify(schema)} | pick decision`,
|
|
271
|
+
stdin: "$draft.json",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: "finish",
|
|
275
|
+
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION}))"',
|
|
276
|
+
env: {
|
|
277
|
+
DECISION: "$review.json.decision",
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-input-"));
|
|
283
|
+
const stateDir = path.join(tmpDir, "state");
|
|
284
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
285
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
286
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
287
|
+
const first = await runWorkflowFile({
|
|
288
|
+
filePath,
|
|
289
|
+
ctx: {
|
|
290
|
+
stdin: process.stdin,
|
|
291
|
+
stdout: process.stdout,
|
|
292
|
+
stderr: process.stderr,
|
|
293
|
+
env,
|
|
294
|
+
mode: "tool",
|
|
295
|
+
registry: createDefaultRegistry(),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
assert.equal(first.status, "needs_input");
|
|
299
|
+
assert.deepEqual(first.requiresInput?.subject, { text: '{"text":"hello"}' });
|
|
300
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
301
|
+
assert.equal(payload.kind, "workflow-file");
|
|
302
|
+
const state = (await readStateJson({ env, key: payload.stateKey }));
|
|
303
|
+
assert.equal(state.resumeAtIndex, 1);
|
|
304
|
+
assert.equal(state.inputKind, "pipeline_command");
|
|
305
|
+
assert.equal(state.inputStepId, "review");
|
|
306
|
+
assert.equal(state.pipelineInput.resumeAtIndex, 0);
|
|
307
|
+
assert.deepEqual(state.pipelineInput.items, [{ text: "hello" }]);
|
|
308
|
+
assert.deepEqual(state.pipelineInput.commandInput.pending.suspendedState, {
|
|
309
|
+
type: "ask",
|
|
310
|
+
subject: { text: '{"text":"hello"}' },
|
|
311
|
+
});
|
|
312
|
+
const resumed = await runWorkflowFile({
|
|
313
|
+
filePath,
|
|
314
|
+
ctx: {
|
|
315
|
+
stdin: process.stdin,
|
|
316
|
+
stdout: process.stdout,
|
|
317
|
+
stderr: process.stderr,
|
|
318
|
+
env,
|
|
319
|
+
mode: "tool",
|
|
320
|
+
registry: createDefaultRegistry(),
|
|
321
|
+
},
|
|
322
|
+
resume: payload,
|
|
323
|
+
response: { decision: "approve" },
|
|
324
|
+
});
|
|
325
|
+
assert.equal(resumed.status, "ok");
|
|
326
|
+
assert.deepEqual(resumed.output, [{ decision: "approve" }]);
|
|
327
|
+
});
|
|
328
|
+
test("workflow pipeline requestInput resume invariant bypasses on_error", async () => {
|
|
329
|
+
const schema = {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: { decision: { type: "string" } },
|
|
332
|
+
required: ["decision"],
|
|
333
|
+
};
|
|
334
|
+
let calls = 0;
|
|
335
|
+
let sideEffects = 0;
|
|
336
|
+
const choose = {
|
|
337
|
+
name: "choose",
|
|
338
|
+
async run({ ctx }) {
|
|
339
|
+
calls += 1;
|
|
340
|
+
if (calls > 1)
|
|
341
|
+
return { output: streamOf([{ skipped: true }]) };
|
|
342
|
+
await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
|
|
343
|
+
return { output: streamOf([]) };
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
const side = {
|
|
347
|
+
name: "side",
|
|
348
|
+
async run() {
|
|
349
|
+
sideEffects += 1;
|
|
350
|
+
return { output: streamOf([{ sideEffects }]) };
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const registry = {
|
|
354
|
+
get(name) {
|
|
355
|
+
return name === "choose" ? choose : name === "side" ? side : undefined;
|
|
356
|
+
},
|
|
357
|
+
list() {
|
|
358
|
+
return ["choose", "side"];
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
const workflow = {
|
|
362
|
+
name: "sample",
|
|
363
|
+
steps: [
|
|
364
|
+
{
|
|
365
|
+
id: "review",
|
|
366
|
+
pipeline: "choose",
|
|
367
|
+
on_error: "continue",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: "side",
|
|
371
|
+
pipeline: "side",
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-invariant-"));
|
|
376
|
+
const stateDir = path.join(tmpDir, "state");
|
|
377
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
378
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
379
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
380
|
+
const first = await runWorkflowFile({
|
|
381
|
+
filePath,
|
|
382
|
+
ctx: {
|
|
383
|
+
stdin: process.stdin,
|
|
384
|
+
stdout: process.stdout,
|
|
385
|
+
stderr: process.stderr,
|
|
386
|
+
env,
|
|
387
|
+
mode: "tool",
|
|
388
|
+
registry,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
assert.equal(first.status, "needs_input");
|
|
392
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
393
|
+
assert.equal(payload.kind, "workflow-file");
|
|
394
|
+
await assert.rejects(runWorkflowFile({
|
|
395
|
+
filePath,
|
|
396
|
+
ctx: {
|
|
397
|
+
stdin: process.stdin,
|
|
398
|
+
stdout: process.stdout,
|
|
399
|
+
stderr: process.stderr,
|
|
400
|
+
env,
|
|
401
|
+
mode: "tool",
|
|
402
|
+
registry,
|
|
403
|
+
},
|
|
404
|
+
resume: payload,
|
|
405
|
+
response: { decision: "approve" },
|
|
406
|
+
}), /not consumed/);
|
|
407
|
+
assert.equal(sideEffects, 0);
|
|
408
|
+
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
|
|
409
|
+
});
|
|
410
|
+
test("workflow pipeline requestInput resume rejects changed pipeline", async () => {
|
|
411
|
+
const schema = {
|
|
412
|
+
type: "object",
|
|
413
|
+
properties: { decision: { type: "string" } },
|
|
414
|
+
required: ["decision"],
|
|
415
|
+
};
|
|
416
|
+
let sideEffects = 0;
|
|
417
|
+
const choose = {
|
|
418
|
+
name: "choose",
|
|
419
|
+
async run({ ctx }) {
|
|
420
|
+
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
|
|
421
|
+
return { output: streamOf([{ decision: response.decision }]) };
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
const side = {
|
|
425
|
+
name: "side",
|
|
426
|
+
async run({ input }) {
|
|
427
|
+
sideEffects += 1;
|
|
428
|
+
return { output: input };
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
const registry = {
|
|
432
|
+
get(name) {
|
|
433
|
+
return name === "choose" ? choose : name === "side" ? side : undefined;
|
|
434
|
+
},
|
|
435
|
+
list() {
|
|
436
|
+
return ["choose", "side"];
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
const workflow = {
|
|
440
|
+
name: "sample",
|
|
441
|
+
steps: [
|
|
442
|
+
{
|
|
443
|
+
id: "review",
|
|
444
|
+
pipeline: "choose | side",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-change-"));
|
|
449
|
+
const stateDir = path.join(tmpDir, "state");
|
|
450
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
451
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
452
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
453
|
+
const first = await runWorkflowFile({
|
|
454
|
+
filePath,
|
|
455
|
+
ctx: {
|
|
456
|
+
stdin: process.stdin,
|
|
457
|
+
stdout: process.stdout,
|
|
458
|
+
stderr: process.stderr,
|
|
459
|
+
env,
|
|
460
|
+
mode: "tool",
|
|
461
|
+
registry,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
assert.equal(first.status, "needs_input");
|
|
465
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
466
|
+
assert.equal(payload.kind, "workflow-file");
|
|
467
|
+
workflow.steps[0].pipeline = "choose";
|
|
468
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
469
|
+
await assert.rejects(runWorkflowFile({
|
|
470
|
+
filePath,
|
|
471
|
+
ctx: {
|
|
472
|
+
stdin: process.stdin,
|
|
473
|
+
stdout: process.stdout,
|
|
474
|
+
stderr: process.stderr,
|
|
475
|
+
env,
|
|
476
|
+
mode: "tool",
|
|
477
|
+
registry,
|
|
478
|
+
},
|
|
479
|
+
resume: payload,
|
|
480
|
+
response: { decision: "approve" },
|
|
481
|
+
}), /pipeline changed/);
|
|
482
|
+
assert.equal(sideEffects, 0);
|
|
483
|
+
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
|
|
484
|
+
});
|
|
485
|
+
test("workflow pipeline requestInput keeps full pipeline across repeated suspensions", async () => {
|
|
486
|
+
const schema = {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: { decision: { type: "string" } },
|
|
489
|
+
required: ["decision"],
|
|
490
|
+
};
|
|
491
|
+
const produce = {
|
|
492
|
+
name: "produce",
|
|
493
|
+
async run() {
|
|
494
|
+
return { output: streamOf([{ id: 1 }]) };
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const choose = {
|
|
498
|
+
name: "choose",
|
|
499
|
+
async run({ ctx }) {
|
|
500
|
+
const first = await ctx.requestInput({
|
|
501
|
+
prompt: "First?",
|
|
502
|
+
responseSchema: schema,
|
|
503
|
+
suspendedState: { phase: "first" },
|
|
504
|
+
});
|
|
505
|
+
const second = await ctx.requestInput({
|
|
506
|
+
prompt: `Second after ${first.decision}`,
|
|
507
|
+
responseSchema: schema,
|
|
508
|
+
suspendedState: { phase: "second" },
|
|
509
|
+
});
|
|
510
|
+
return { output: streamOf([{ first: first.decision, second: second.decision }]) };
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
const registry = {
|
|
514
|
+
get(name) {
|
|
515
|
+
return name === "produce" ? produce : name === "choose" ? choose : undefined;
|
|
516
|
+
},
|
|
517
|
+
list() {
|
|
518
|
+
return ["produce", "choose"];
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
const workflow = {
|
|
522
|
+
name: "sample",
|
|
523
|
+
steps: [
|
|
524
|
+
{
|
|
525
|
+
id: "review",
|
|
526
|
+
pipeline: "produce | choose",
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-repeat-"));
|
|
531
|
+
const stateDir = path.join(tmpDir, "state");
|
|
532
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
533
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
534
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
535
|
+
const first = await runWorkflowFile({
|
|
536
|
+
filePath,
|
|
537
|
+
ctx: {
|
|
538
|
+
stdin: process.stdin,
|
|
539
|
+
stdout: process.stdout,
|
|
540
|
+
stderr: process.stderr,
|
|
541
|
+
env,
|
|
542
|
+
mode: "tool",
|
|
543
|
+
registry,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
assert.equal(first.status, "needs_input");
|
|
547
|
+
const firstPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
548
|
+
assert.equal(firstPayload.kind, "workflow-file");
|
|
549
|
+
const second = await runWorkflowFile({
|
|
550
|
+
filePath,
|
|
551
|
+
ctx: {
|
|
552
|
+
stdin: process.stdin,
|
|
553
|
+
stdout: process.stdout,
|
|
554
|
+
stderr: process.stderr,
|
|
555
|
+
env,
|
|
556
|
+
mode: "tool",
|
|
557
|
+
registry,
|
|
558
|
+
},
|
|
559
|
+
resume: firstPayload,
|
|
560
|
+
response: { decision: "approve" },
|
|
561
|
+
});
|
|
562
|
+
assert.equal(second.status, "needs_input");
|
|
563
|
+
const secondPayload = decodeResumeToken(second.requiresInput?.resumeToken ?? "");
|
|
564
|
+
assert.equal(secondPayload.kind, "workflow-file");
|
|
565
|
+
const state = (await readStateJson({ env, key: secondPayload.stateKey }));
|
|
566
|
+
assert.equal(state.pipelineInput.resumeAtIndex, 1);
|
|
567
|
+
assert.equal(state.pipelineInput.pipeline.length, 2);
|
|
568
|
+
const done = await runWorkflowFile({
|
|
569
|
+
filePath,
|
|
570
|
+
ctx: {
|
|
571
|
+
stdin: process.stdin,
|
|
572
|
+
stdout: process.stdout,
|
|
573
|
+
stderr: process.stderr,
|
|
574
|
+
env,
|
|
575
|
+
mode: "tool",
|
|
576
|
+
registry,
|
|
577
|
+
},
|
|
578
|
+
resume: secondPayload,
|
|
579
|
+
response: { decision: "ship" },
|
|
580
|
+
});
|
|
581
|
+
assert.equal(done.status, "ok");
|
|
582
|
+
assert.deepEqual(done.output, [{ first: "approve", second: "ship" }]);
|
|
583
|
+
});
|
|
584
|
+
test("workflow pipeline requestInput resume rejects condition bypass", async () => {
|
|
585
|
+
const schema = {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: { decision: { type: "string" } },
|
|
588
|
+
required: ["decision"],
|
|
589
|
+
};
|
|
590
|
+
let sideEffects = 0;
|
|
591
|
+
const choose = {
|
|
592
|
+
name: "choose",
|
|
593
|
+
async run({ ctx }) {
|
|
594
|
+
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
|
|
595
|
+
return { output: streamOf([{ decision: response.decision }]) };
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
const side = {
|
|
599
|
+
name: "side",
|
|
600
|
+
async run() {
|
|
601
|
+
sideEffects += 1;
|
|
602
|
+
return { output: streamOf([{ sideEffects }]) };
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
const registry = {
|
|
606
|
+
get(name) {
|
|
607
|
+
return name === "choose" ? choose : name === "side" ? side : undefined;
|
|
608
|
+
},
|
|
609
|
+
list() {
|
|
610
|
+
return ["choose", "side"];
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
const workflow = {
|
|
614
|
+
name: "sample",
|
|
615
|
+
steps: [
|
|
616
|
+
{
|
|
617
|
+
id: "gate",
|
|
618
|
+
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: "review",
|
|
622
|
+
pipeline: "choose",
|
|
623
|
+
condition: "$gate.json.ok",
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
id: "side",
|
|
627
|
+
pipeline: "side",
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
};
|
|
631
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-condition-"));
|
|
632
|
+
const stateDir = path.join(tmpDir, "state");
|
|
633
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
634
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
635
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
636
|
+
const first = await runWorkflowFile({
|
|
637
|
+
filePath,
|
|
638
|
+
ctx: {
|
|
639
|
+
stdin: process.stdin,
|
|
640
|
+
stdout: process.stdout,
|
|
641
|
+
stderr: process.stderr,
|
|
642
|
+
env,
|
|
643
|
+
mode: "tool",
|
|
644
|
+
registry,
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
assert.equal(first.status, "needs_input");
|
|
648
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
649
|
+
assert.equal(payload.kind, "workflow-file");
|
|
650
|
+
workflow.steps[1].condition = "$gate.json.missing";
|
|
651
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
652
|
+
await assert.rejects(runWorkflowFile({
|
|
653
|
+
filePath,
|
|
654
|
+
ctx: {
|
|
655
|
+
stdin: process.stdin,
|
|
656
|
+
stdout: process.stdout,
|
|
657
|
+
stderr: process.stderr,
|
|
658
|
+
env,
|
|
659
|
+
mode: "tool",
|
|
660
|
+
registry,
|
|
661
|
+
},
|
|
662
|
+
resume: payload,
|
|
663
|
+
response: { decision: "approve" },
|
|
664
|
+
}), /condition changed/);
|
|
665
|
+
assert.equal(sideEffects, 0);
|
|
666
|
+
await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
|
|
667
|
+
});
|
|
668
|
+
test("workflow pipeline command input preserves replayable stdin without suspended state", async () => {
|
|
669
|
+
const schema = {
|
|
670
|
+
type: "object",
|
|
671
|
+
properties: { decision: { type: "string" } },
|
|
672
|
+
required: ["decision"],
|
|
673
|
+
};
|
|
674
|
+
const reviewCommand = {
|
|
675
|
+
name: "review_input",
|
|
676
|
+
async run({ input, ctx }) {
|
|
677
|
+
const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
|
|
678
|
+
const items = [];
|
|
679
|
+
for await (const item of input)
|
|
680
|
+
items.push(item);
|
|
681
|
+
return { output: streamOf([{ items, decision: response.decision }]) };
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
const registry = {
|
|
685
|
+
get(name) {
|
|
686
|
+
return name === reviewCommand.name ? reviewCommand : undefined;
|
|
687
|
+
},
|
|
688
|
+
list() {
|
|
689
|
+
return [reviewCommand.name];
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
async function runCase({ sourceStep, stdin, prefix, }) {
|
|
693
|
+
const steps = [
|
|
694
|
+
...(sourceStep ? [sourceStep] : []),
|
|
695
|
+
{
|
|
696
|
+
id: "review",
|
|
697
|
+
pipeline: "review_input",
|
|
698
|
+
...(stdin ? { stdin } : null),
|
|
699
|
+
},
|
|
700
|
+
];
|
|
701
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
702
|
+
const stateDir = path.join(tmpDir, "state");
|
|
703
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
704
|
+
await fsp.writeFile(filePath, JSON.stringify({ name: "sample", steps }, null, 2), "utf8");
|
|
705
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
706
|
+
const first = await runWorkflowFile({
|
|
707
|
+
filePath,
|
|
708
|
+
ctx: {
|
|
709
|
+
stdin: process.stdin,
|
|
710
|
+
stdout: process.stdout,
|
|
711
|
+
stderr: process.stderr,
|
|
712
|
+
env,
|
|
713
|
+
mode: "tool",
|
|
714
|
+
registry,
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
assert.equal(first.status, "needs_input");
|
|
718
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
719
|
+
assert.equal(payload.kind, "workflow-file");
|
|
720
|
+
const state = (await readStateJson({ env, key: payload.stateKey }));
|
|
721
|
+
const resumed = await runWorkflowFile({
|
|
722
|
+
filePath,
|
|
723
|
+
ctx: {
|
|
724
|
+
stdin: process.stdin,
|
|
725
|
+
stdout: process.stdout,
|
|
726
|
+
stderr: process.stderr,
|
|
727
|
+
env,
|
|
728
|
+
mode: "tool",
|
|
729
|
+
registry,
|
|
730
|
+
},
|
|
731
|
+
resume: payload,
|
|
732
|
+
response: { decision: "approve" },
|
|
733
|
+
});
|
|
734
|
+
return { state, resumed };
|
|
735
|
+
}
|
|
736
|
+
const noStdin = await runCase({ prefix: "lobster-workflow-pipeline-no-stdin-" });
|
|
737
|
+
assert.deepEqual(noStdin.state.pipelineInput.items, []);
|
|
738
|
+
assert.equal(noStdin.resumed.status, "ok");
|
|
739
|
+
assert.deepEqual(noStdin.resumed.output, [{ items: [], decision: "approve" }]);
|
|
740
|
+
const withArrayStdin = await runCase({
|
|
741
|
+
prefix: "lobster-workflow-pipeline-array-stdin-",
|
|
742
|
+
sourceStep: {
|
|
743
|
+
id: "draft",
|
|
744
|
+
run: 'node -e "process.stdout.write(JSON.stringify([{id:1}]))"',
|
|
745
|
+
},
|
|
746
|
+
stdin: "$draft.json",
|
|
747
|
+
});
|
|
748
|
+
assert.deepEqual(withArrayStdin.state.pipelineInput.items, [{ id: 1 }]);
|
|
749
|
+
assert.equal(withArrayStdin.resumed.status, "ok");
|
|
750
|
+
assert.deepEqual(withArrayStdin.resumed.output, [{ items: [{ id: 1 }], decision: "approve" }]);
|
|
751
|
+
});
|
|
752
|
+
test("workflow input resumes preserve the full subject even when the tool envelope preview is truncated", async () => {
|
|
753
|
+
const longText = "x".repeat(250_000);
|
|
754
|
+
const workflow = {
|
|
755
|
+
steps: [
|
|
756
|
+
{
|
|
757
|
+
id: "draft",
|
|
758
|
+
run: "node -e \"let data=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => data += chunk); process.stdin.on('end', () => process.stdout.write(JSON.stringify({text:data})))\"",
|
|
759
|
+
stdin: longText,
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
id: "review",
|
|
763
|
+
input: {
|
|
764
|
+
prompt: "Review draft?",
|
|
765
|
+
responseSchema: {
|
|
766
|
+
type: "object",
|
|
767
|
+
properties: { decision: { type: "string" } },
|
|
768
|
+
required: ["decision"],
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
id: "finish",
|
|
774
|
+
run: "node -e \"let data=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => data += chunk); process.stdin.on('end', () => process.stdout.write(JSON.stringify({subjectLength:data.length})))\"",
|
|
775
|
+
stdin: "$review.subject.text",
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
};
|
|
779
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-truncate-"));
|
|
780
|
+
const stateDir = path.join(tmpDir, "state");
|
|
781
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
782
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
783
|
+
const env = {
|
|
784
|
+
...process.env,
|
|
785
|
+
LOBSTER_STATE_DIR: stateDir,
|
|
786
|
+
LOBSTER_MAX_TOOL_ENVELOPE_BYTES: "8192",
|
|
787
|
+
};
|
|
788
|
+
const first = await runWorkflowFile({
|
|
789
|
+
filePath,
|
|
790
|
+
ctx: {
|
|
791
|
+
stdin: process.stdin,
|
|
792
|
+
stdout: process.stdout,
|
|
793
|
+
stderr: process.stderr,
|
|
794
|
+
env,
|
|
795
|
+
mode: "tool",
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
assert.equal(first.status, "needs_input");
|
|
799
|
+
assert.deepEqual(first.requiresInput?.subject, {
|
|
800
|
+
truncated: true,
|
|
801
|
+
bytes: Buffer.byteLength(JSON.stringify({ text: longText }), "utf8"),
|
|
802
|
+
preview: JSON.stringify({ text: longText }).slice(0, 2000),
|
|
803
|
+
});
|
|
804
|
+
const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
805
|
+
assert.equal(payload.kind, "workflow-file");
|
|
806
|
+
const resumed = await runWorkflowFile({
|
|
807
|
+
filePath,
|
|
808
|
+
ctx: {
|
|
809
|
+
stdin: process.stdin,
|
|
810
|
+
stdout: process.stdout,
|
|
811
|
+
stderr: process.stderr,
|
|
812
|
+
env,
|
|
813
|
+
mode: "tool",
|
|
814
|
+
},
|
|
815
|
+
resume: payload,
|
|
816
|
+
response: { decision: "approve" },
|
|
817
|
+
});
|
|
818
|
+
assert.equal(resumed.status, "ok");
|
|
819
|
+
assert.deepEqual(resumed.output, [{ subjectLength: longText.length }]);
|
|
820
|
+
});
|
|
821
|
+
test("workflow approval resumes require an explicit decision", async () => {
|
|
822
|
+
const workflow = {
|
|
823
|
+
steps: [
|
|
824
|
+
{
|
|
825
|
+
id: "approve_step",
|
|
826
|
+
command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
|
|
827
|
+
approval: "required",
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
id: "finish",
|
|
831
|
+
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
|
|
832
|
+
condition: "$approve_step.approved",
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
};
|
|
836
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-required-"));
|
|
837
|
+
const stateDir = path.join(tmpDir, "state");
|
|
838
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
839
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
840
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
841
|
+
const first = await runWorkflowFile({
|
|
842
|
+
filePath,
|
|
843
|
+
ctx: {
|
|
844
|
+
stdin: process.stdin,
|
|
845
|
+
stdout: process.stdout,
|
|
846
|
+
stderr: process.stderr,
|
|
847
|
+
env,
|
|
848
|
+
mode: "tool",
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
assert.equal(first.status, "needs_approval");
|
|
852
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
853
|
+
assert.equal(payload.kind, "workflow-file");
|
|
854
|
+
await assert.rejects(() => runWorkflowFile({
|
|
855
|
+
filePath,
|
|
856
|
+
ctx: {
|
|
857
|
+
stdin: process.stdin,
|
|
858
|
+
stdout: process.stdout,
|
|
859
|
+
stderr: process.stderr,
|
|
860
|
+
env,
|
|
861
|
+
mode: "tool",
|
|
862
|
+
},
|
|
863
|
+
resume: payload,
|
|
864
|
+
}), /requires --approve yes\|no/i);
|
|
865
|
+
});
|
|
866
|
+
test("workflow approval can require a different approver than initiator", async () => {
|
|
867
|
+
const workflow = {
|
|
868
|
+
steps: [
|
|
869
|
+
{
|
|
870
|
+
id: "gate",
|
|
871
|
+
approval: {
|
|
872
|
+
prompt: "Proceed?",
|
|
873
|
+
require_different_approver: true,
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
id: "finish",
|
|
878
|
+
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
|
|
879
|
+
when: "$gate.approved",
|
|
880
|
+
},
|
|
881
|
+
],
|
|
882
|
+
};
|
|
883
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-identity-"));
|
|
884
|
+
const stateDir = path.join(tmpDir, "state");
|
|
885
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
886
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
887
|
+
const baseEnv = {
|
|
888
|
+
...process.env,
|
|
889
|
+
LOBSTER_STATE_DIR: stateDir,
|
|
890
|
+
LOBSTER_APPROVAL_INITIATED_BY: "agent-1",
|
|
891
|
+
};
|
|
892
|
+
const first = await runWorkflowFile({
|
|
893
|
+
filePath,
|
|
894
|
+
ctx: {
|
|
895
|
+
stdin: process.stdin,
|
|
896
|
+
stdout: process.stdout,
|
|
897
|
+
stderr: process.stderr,
|
|
898
|
+
env: baseEnv,
|
|
899
|
+
mode: "tool",
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
assert.equal(first.status, "needs_approval");
|
|
903
|
+
assert.equal(first.requiresApproval?.initiatedBy, "agent-1");
|
|
904
|
+
assert.equal(first.requiresApproval?.requireDifferentApprover, true);
|
|
905
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
906
|
+
assert.equal(payload.kind, "workflow-file");
|
|
907
|
+
await assert.rejects(() => runWorkflowFile({
|
|
908
|
+
filePath,
|
|
909
|
+
ctx: {
|
|
910
|
+
stdin: process.stdin,
|
|
911
|
+
stdout: process.stdout,
|
|
912
|
+
stderr: process.stderr,
|
|
913
|
+
env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "agent-1" },
|
|
914
|
+
mode: "tool",
|
|
915
|
+
},
|
|
916
|
+
resume: payload,
|
|
917
|
+
approved: true,
|
|
918
|
+
}), /must be granted by someone other than 'agent-1'/i);
|
|
919
|
+
const resumed = await runWorkflowFile({
|
|
920
|
+
filePath,
|
|
921
|
+
ctx: {
|
|
922
|
+
stdin: process.stdin,
|
|
923
|
+
stdout: process.stdout,
|
|
924
|
+
stderr: process.stderr,
|
|
925
|
+
env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "human-1" },
|
|
926
|
+
mode: "tool",
|
|
927
|
+
},
|
|
928
|
+
resume: payload,
|
|
929
|
+
approved: true,
|
|
930
|
+
});
|
|
931
|
+
assert.equal(resumed.status, "ok");
|
|
932
|
+
assert.deepEqual(resumed.output, [{ done: true }]);
|
|
933
|
+
});
|
|
934
|
+
test("workflow approval can require a specific approver identity", async () => {
|
|
935
|
+
const workflow = {
|
|
936
|
+
steps: [
|
|
937
|
+
{
|
|
938
|
+
id: "gate",
|
|
939
|
+
approval: {
|
|
940
|
+
prompt: "Proceed?",
|
|
941
|
+
required_approver: "alice",
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
id: "finish",
|
|
946
|
+
run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
|
|
947
|
+
when: "$gate.approved",
|
|
948
|
+
},
|
|
949
|
+
],
|
|
950
|
+
};
|
|
951
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-required-approver-"));
|
|
952
|
+
const stateDir = path.join(tmpDir, "state");
|
|
953
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
954
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
955
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
956
|
+
const first = await runWorkflowFile({
|
|
957
|
+
filePath,
|
|
958
|
+
ctx: {
|
|
959
|
+
stdin: process.stdin,
|
|
960
|
+
stdout: process.stdout,
|
|
961
|
+
stderr: process.stderr,
|
|
962
|
+
env,
|
|
963
|
+
mode: "tool",
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
assert.equal(first.status, "needs_approval");
|
|
967
|
+
assert.equal(first.requiresApproval?.requiredApprover, "alice");
|
|
968
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
969
|
+
assert.equal(payload.kind, "workflow-file");
|
|
970
|
+
await assert.rejects(() => runWorkflowFile({
|
|
971
|
+
filePath,
|
|
972
|
+
ctx: {
|
|
973
|
+
stdin: process.stdin,
|
|
974
|
+
stdout: process.stdout,
|
|
975
|
+
stderr: process.stderr,
|
|
976
|
+
env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "bob" },
|
|
977
|
+
mode: "tool",
|
|
978
|
+
},
|
|
979
|
+
resume: payload,
|
|
980
|
+
approved: true,
|
|
981
|
+
}), /requires approver 'alice', got 'bob'/i);
|
|
982
|
+
const resumed = await runWorkflowFile({
|
|
983
|
+
filePath,
|
|
984
|
+
ctx: {
|
|
985
|
+
stdin: process.stdin,
|
|
986
|
+
stdout: process.stdout,
|
|
987
|
+
stderr: process.stderr,
|
|
988
|
+
env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "alice" },
|
|
989
|
+
mode: "tool",
|
|
990
|
+
},
|
|
991
|
+
resume: payload,
|
|
992
|
+
approved: true,
|
|
993
|
+
});
|
|
994
|
+
assert.equal(resumed.status, "ok");
|
|
995
|
+
assert.deepEqual(resumed.output, [{ done: true }]);
|
|
996
|
+
});
|
|
997
|
+
test("workflow conditions support comparisons, boolean operators, and parentheses", async () => {
|
|
998
|
+
const workflow = {
|
|
999
|
+
steps: [
|
|
1000
|
+
{
|
|
1001
|
+
id: "collect",
|
|
1002
|
+
run: "node -e \"process.stdout.write(JSON.stringify({kind:'deploy',count:2}))\"",
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
id: "review",
|
|
1006
|
+
input: {
|
|
1007
|
+
prompt: "Review draft?",
|
|
1008
|
+
responseSchema: {
|
|
1009
|
+
type: "object",
|
|
1010
|
+
properties: { decision: { type: "string" } },
|
|
1011
|
+
required: ["decision"],
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
id: "approve_step",
|
|
1017
|
+
command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
|
|
1018
|
+
approval: "required",
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: "finish",
|
|
1022
|
+
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
|
|
1023
|
+
condition: "($approve_step.approved && $review.response.decision == approve) && !($collect.json.kind != deploy || $collect.json.count != 2)",
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
id: "fallback",
|
|
1027
|
+
run: 'node -e "process.stdout.write(JSON.stringify({ok:false}))"',
|
|
1028
|
+
condition: "$review.response.decision == reject || $collect.json.kind == skip",
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
};
|
|
1032
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-conditions-"));
|
|
1033
|
+
const stateDir = path.join(tmpDir, "state");
|
|
1034
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1035
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1036
|
+
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
|
|
1037
|
+
const first = await runWorkflowFile({
|
|
1038
|
+
filePath,
|
|
1039
|
+
ctx: {
|
|
1040
|
+
stdin: process.stdin,
|
|
1041
|
+
stdout: process.stdout,
|
|
1042
|
+
stderr: process.stderr,
|
|
1043
|
+
env,
|
|
1044
|
+
mode: "tool",
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
assert.equal(first.status, "needs_input");
|
|
1048
|
+
const inputPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
|
|
1049
|
+
assert.equal(inputPayload.kind, "workflow-file");
|
|
1050
|
+
const second = await runWorkflowFile({
|
|
1051
|
+
filePath,
|
|
1052
|
+
ctx: {
|
|
1053
|
+
stdin: process.stdin,
|
|
1054
|
+
stdout: process.stdout,
|
|
1055
|
+
stderr: process.stderr,
|
|
1056
|
+
env,
|
|
1057
|
+
mode: "tool",
|
|
1058
|
+
},
|
|
1059
|
+
resume: inputPayload,
|
|
1060
|
+
response: { decision: "approve" },
|
|
1061
|
+
});
|
|
1062
|
+
assert.equal(second.status, "needs_approval");
|
|
1063
|
+
const approvalPayload = decodeResumeToken(second.requiresApproval?.resumeToken ?? "");
|
|
1064
|
+
assert.equal(approvalPayload.kind, "workflow-file");
|
|
1065
|
+
const resumed = await runWorkflowFile({
|
|
1066
|
+
filePath,
|
|
1067
|
+
ctx: {
|
|
1068
|
+
stdin: process.stdin,
|
|
1069
|
+
stdout: process.stdout,
|
|
1070
|
+
stderr: process.stderr,
|
|
1071
|
+
env,
|
|
1072
|
+
mode: "tool",
|
|
1073
|
+
},
|
|
1074
|
+
resume: approvalPayload,
|
|
1075
|
+
approved: true,
|
|
1076
|
+
});
|
|
1077
|
+
assert.equal(resumed.status, "ok");
|
|
1078
|
+
assert.deepEqual(resumed.output, [{ ok: true }]);
|
|
1079
|
+
});
|
|
1080
|
+
test("workflow conditions reject standalone bare identifiers", async () => {
|
|
1081
|
+
const workflow = {
|
|
1082
|
+
steps: [
|
|
1083
|
+
{ id: "collect", run: "echo hello" },
|
|
1084
|
+
{ id: "finish", run: "echo done", condition: "approve" },
|
|
1085
|
+
],
|
|
1086
|
+
};
|
|
1087
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-invalid-"));
|
|
1088
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1089
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1090
|
+
await assert.rejects(() => runWorkflowFile({
|
|
1091
|
+
filePath,
|
|
1092
|
+
ctx: {
|
|
1093
|
+
stdin: process.stdin,
|
|
1094
|
+
stdout: process.stdout,
|
|
1095
|
+
stderr: process.stderr,
|
|
1096
|
+
env: { ...process.env },
|
|
1097
|
+
mode: "tool",
|
|
1098
|
+
},
|
|
1099
|
+
}), /Unsupported condition: approve/);
|
|
1100
|
+
});
|
|
1101
|
+
test("workflow conditions reject unknown step refs even under negation", async () => {
|
|
1102
|
+
const workflow = {
|
|
1103
|
+
steps: [
|
|
1104
|
+
{ id: "collect", run: "echo hello" },
|
|
1105
|
+
{ id: "finish", run: "echo done", condition: "!$aprove.approved" },
|
|
1106
|
+
],
|
|
1107
|
+
};
|
|
1108
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-typo-"));
|
|
1109
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1110
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1111
|
+
await assert.rejects(() => runWorkflowFile({
|
|
1112
|
+
filePath,
|
|
1113
|
+
ctx: {
|
|
1114
|
+
stdin: process.stdin,
|
|
1115
|
+
stdout: process.stdout,
|
|
1116
|
+
stderr: process.stderr,
|
|
1117
|
+
env: { ...process.env },
|
|
1118
|
+
mode: "tool",
|
|
1119
|
+
},
|
|
1120
|
+
}), /Unknown step reference: aprove\.approved/);
|
|
1121
|
+
});
|
|
1122
|
+
test("workflow conditions compare object refs without key-order sensitivity", async () => {
|
|
1123
|
+
const workflow = {
|
|
1124
|
+
steps: [
|
|
1125
|
+
{
|
|
1126
|
+
id: "left",
|
|
1127
|
+
run: 'node -e "process.stdout.write(JSON.stringify({a:1,b:2}))"',
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
id: "right",
|
|
1131
|
+
run: 'node -e "process.stdout.write(JSON.stringify({b:2,a:1}))"',
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
id: "finish",
|
|
1135
|
+
run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
|
|
1136
|
+
condition: "$left.json == $right.json",
|
|
1137
|
+
},
|
|
1138
|
+
],
|
|
1139
|
+
};
|
|
1140
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-object-eq-"));
|
|
1141
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1142
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1143
|
+
const result = await runWorkflowFile({
|
|
1144
|
+
filePath,
|
|
1145
|
+
ctx: {
|
|
1146
|
+
stdin: process.stdin,
|
|
1147
|
+
stdout: process.stdout,
|
|
1148
|
+
stderr: process.stderr,
|
|
1149
|
+
env: { ...process.env },
|
|
1150
|
+
mode: "tool",
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
assert.equal(result.status, "ok");
|
|
1154
|
+
assert.deepEqual(result.output, [{ ok: true }]);
|
|
1155
|
+
});
|
|
1156
|
+
test("workflow files can mix shell steps, approval-only steps, and pipeline llm steps", async () => {
|
|
1157
|
+
const registry = createDefaultRegistry();
|
|
1158
|
+
const requests = [];
|
|
1159
|
+
const server = http.createServer((req, res) => {
|
|
1160
|
+
if (req.method !== "POST" || req.url !== "/invoke") {
|
|
1161
|
+
res.writeHead(404);
|
|
1162
|
+
res.end("nope");
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
let body = "";
|
|
1166
|
+
req.setEncoding("utf8");
|
|
1167
|
+
req.on("data", (chunk) => {
|
|
1168
|
+
body += chunk;
|
|
1169
|
+
});
|
|
1170
|
+
req.on("end", () => {
|
|
1171
|
+
const parsed = JSON.parse(body || "{}");
|
|
1172
|
+
requests.push(parsed);
|
|
1173
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1174
|
+
res.end(JSON.stringify({
|
|
1175
|
+
ok: true,
|
|
1176
|
+
result: {
|
|
1177
|
+
runId: "http_1",
|
|
1178
|
+
model: parsed.model || "test-model",
|
|
1179
|
+
prompt: parsed.prompt,
|
|
1180
|
+
output: {
|
|
1181
|
+
format: "json",
|
|
1182
|
+
text: '{"recommendation":"no","reason":"warm"}',
|
|
1183
|
+
data: { recommendation: "no", reason: "warm" },
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
}));
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
1190
|
+
const addr = server.address();
|
|
1191
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1192
|
+
const workflow = {
|
|
1193
|
+
name: "mixed-workflow",
|
|
1194
|
+
steps: [
|
|
1195
|
+
{
|
|
1196
|
+
id: "fetch",
|
|
1197
|
+
run: "node -e \"process.stdout.write(JSON.stringify({location:'Phoenix',temp_f:73.8,humidity_pct:13,wind_mph:3.4}))\"",
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
id: "confirm",
|
|
1201
|
+
approval: "Want jacket advice from the LLM?",
|
|
1202
|
+
stdin: "$fetch.json",
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
id: "advice",
|
|
1206
|
+
pipeline: 'llm.invoke --provider http --prompt "Given this weather data, should I wear a jacket? Return JSON." --disable-cache',
|
|
1207
|
+
stdin: "$fetch.json",
|
|
1208
|
+
when: "$confirm.approved",
|
|
1209
|
+
},
|
|
1210
|
+
],
|
|
1211
|
+
};
|
|
1212
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-mixed-"));
|
|
1213
|
+
const stateDir = path.join(tmpDir, "state");
|
|
1214
|
+
const cacheDir = path.join(tmpDir, "cache");
|
|
1215
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1216
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1217
|
+
const env = {
|
|
1218
|
+
...process.env,
|
|
1219
|
+
LOBSTER_STATE_DIR: stateDir,
|
|
1220
|
+
LOBSTER_CACHE_DIR: cacheDir,
|
|
1221
|
+
LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
|
|
1222
|
+
};
|
|
1223
|
+
try {
|
|
1224
|
+
const first = await runWorkflowFile({
|
|
1225
|
+
filePath,
|
|
1226
|
+
ctx: {
|
|
1227
|
+
stdin: process.stdin,
|
|
1228
|
+
stdout: process.stdout,
|
|
1229
|
+
stderr: process.stderr,
|
|
1230
|
+
env,
|
|
1231
|
+
mode: "tool",
|
|
1232
|
+
registry,
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
assert.equal(first.status, "needs_approval");
|
|
1236
|
+
assert.equal(first.requiresApproval?.prompt, "Want jacket advice from the LLM?");
|
|
1237
|
+
assert.match(first.requiresApproval?.preview ?? "", /Phoenix/);
|
|
1238
|
+
assert.ok(first.requiresApproval?.resumeToken);
|
|
1239
|
+
const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
|
|
1240
|
+
assert.equal(payload.kind, "workflow-file");
|
|
1241
|
+
const resumed = await runWorkflowFile({
|
|
1242
|
+
filePath,
|
|
1243
|
+
ctx: {
|
|
1244
|
+
stdin: process.stdin,
|
|
1245
|
+
stdout: process.stdout,
|
|
1246
|
+
stderr: process.stderr,
|
|
1247
|
+
env,
|
|
1248
|
+
mode: "tool",
|
|
1249
|
+
registry,
|
|
1250
|
+
},
|
|
1251
|
+
resume: payload,
|
|
1252
|
+
approved: true,
|
|
1253
|
+
});
|
|
1254
|
+
assert.equal(resumed.status, "ok");
|
|
1255
|
+
assert.equal(resumed.output.length, 1);
|
|
1256
|
+
assert.equal(resumed.output[0].kind, "llm.invoke");
|
|
1257
|
+
assert.equal(resumed.output[0].output.data.recommendation, "no");
|
|
1258
|
+
assert.equal(requests.length, 1);
|
|
1259
|
+
assert.equal(requests[0].artifacts[0].location, "Phoenix");
|
|
1260
|
+
}
|
|
1261
|
+
finally {
|
|
1262
|
+
await closeServer(server);
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
test("workflow pipeline llm_task.invoke consumes stdin artifacts from previous step", async () => {
|
|
1266
|
+
const registry = createDefaultRegistry();
|
|
1267
|
+
const requests = [];
|
|
1268
|
+
const server = http.createServer((req, res) => {
|
|
1269
|
+
if (req.method !== "POST" || req.url !== "/tools/invoke") {
|
|
1270
|
+
res.writeHead(404);
|
|
1271
|
+
res.end("nope");
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
let body = "";
|
|
1275
|
+
req.setEncoding("utf8");
|
|
1276
|
+
req.on("data", (chunk) => {
|
|
1277
|
+
body += chunk;
|
|
1278
|
+
});
|
|
1279
|
+
req.on("end", () => {
|
|
1280
|
+
const parsed = JSON.parse(body || "{}");
|
|
1281
|
+
requests.push(parsed);
|
|
1282
|
+
const text = String(parsed?.args?.artifacts?.[0]?.text ?? "");
|
|
1283
|
+
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
|
|
1284
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1285
|
+
res.end(JSON.stringify({
|
|
1286
|
+
ok: true,
|
|
1287
|
+
result: {
|
|
1288
|
+
ok: true,
|
|
1289
|
+
result: {
|
|
1290
|
+
runId: "task_1",
|
|
1291
|
+
model: parsed?.args?.model ?? "test-model",
|
|
1292
|
+
prompt: parsed?.args?.prompt,
|
|
1293
|
+
output: {
|
|
1294
|
+
text: JSON.stringify({ word_count: wordCount }),
|
|
1295
|
+
data: { word_count: wordCount },
|
|
1296
|
+
format: "json",
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
}));
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
1304
|
+
const addr = server.address();
|
|
1305
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1306
|
+
const workflow = {
|
|
1307
|
+
name: "word-counter",
|
|
1308
|
+
steps: [
|
|
1309
|
+
{
|
|
1310
|
+
id: "make_words",
|
|
1311
|
+
run: 'echo "One two three four five six"',
|
|
1312
|
+
},
|
|
1313
|
+
{
|
|
1314
|
+
id: "count_words",
|
|
1315
|
+
pipeline: 'llm_task.invoke --prompt "How many words have been pasted below?" --disable-cache',
|
|
1316
|
+
stdin: "$make_words.stdout",
|
|
1317
|
+
},
|
|
1318
|
+
],
|
|
1319
|
+
};
|
|
1320
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-llm-task-stdin-"));
|
|
1321
|
+
const stateDir = path.join(tmpDir, "state");
|
|
1322
|
+
const cacheDir = path.join(tmpDir, "cache");
|
|
1323
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1324
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1325
|
+
const env = {
|
|
1326
|
+
...process.env,
|
|
1327
|
+
LOBSTER_STATE_DIR: stateDir,
|
|
1328
|
+
LOBSTER_CACHE_DIR: cacheDir,
|
|
1329
|
+
OPENCLAW_URL: `http://127.0.0.1:${port}`,
|
|
1330
|
+
};
|
|
1331
|
+
try {
|
|
1332
|
+
const result = await runWorkflowFile({
|
|
1333
|
+
filePath,
|
|
1334
|
+
ctx: {
|
|
1335
|
+
stdin: process.stdin,
|
|
1336
|
+
stdout: process.stdout,
|
|
1337
|
+
stderr: process.stderr,
|
|
1338
|
+
env,
|
|
1339
|
+
mode: "tool",
|
|
1340
|
+
registry,
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
assert.equal(result.status, "ok");
|
|
1344
|
+
assert.equal(result.output.length, 1);
|
|
1345
|
+
assert.equal(result.output[0].kind, "llm_task.invoke");
|
|
1346
|
+
assert.equal(result.output[0].output.data.word_count, 6);
|
|
1347
|
+
assert.equal(requests.length, 1);
|
|
1348
|
+
assert.equal(requests[0].tool, "llm-task");
|
|
1349
|
+
assert.equal(requests[0].action, "invoke");
|
|
1350
|
+
assert.equal(requests[0].args.prompt, "How many words have been pasted below?");
|
|
1351
|
+
assert.match(String(requests[0].args.artifacts?.[0]?.text ?? ""), /One two three four five six/);
|
|
1352
|
+
}
|
|
1353
|
+
finally {
|
|
1354
|
+
await closeServer(server);
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
test("workflow pipeline steps respect cwd and feed later shell steps via stdout refs", async () => {
|
|
1358
|
+
const registry = createDefaultRegistry();
|
|
1359
|
+
const workflow = {
|
|
1360
|
+
cwd: "${TARGET_DIR}",
|
|
1361
|
+
steps: [
|
|
1362
|
+
{
|
|
1363
|
+
id: "pwd",
|
|
1364
|
+
pipeline: "exec pwd",
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
id: "capture",
|
|
1368
|
+
run: "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{process.stdout.write(JSON.stringify({pwd:d.trim()}));});\"",
|
|
1369
|
+
stdin: "$pwd.stdout",
|
|
1370
|
+
},
|
|
1371
|
+
],
|
|
1372
|
+
};
|
|
1373
|
+
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-cwd-"));
|
|
1374
|
+
const targetDir = path.join(tmpDir, "nested");
|
|
1375
|
+
const filePath = path.join(tmpDir, "workflow.lobster");
|
|
1376
|
+
await fsp.mkdir(targetDir, { recursive: true });
|
|
1377
|
+
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
|
|
1378
|
+
const result = await runWorkflowFile({
|
|
1379
|
+
filePath,
|
|
1380
|
+
args: { TARGET_DIR: targetDir },
|
|
1381
|
+
ctx: {
|
|
1382
|
+
stdin: process.stdin,
|
|
1383
|
+
stdout: process.stdout,
|
|
1384
|
+
stderr: process.stderr,
|
|
1385
|
+
env: { ...process.env, LOBSTER_STATE_DIR: path.join(tmpDir, "state") },
|
|
1386
|
+
mode: "tool",
|
|
1387
|
+
registry,
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
assert.equal(result.status, "ok");
|
|
1391
|
+
const resolvedTargetDir = await fsp.realpath(targetDir);
|
|
1392
|
+
assert.deepEqual(result.output, [{ pwd: resolvedTargetDir }]);
|
|
1393
|
+
});
|
|
1394
|
+
async function closeServer(server) {
|
|
1395
|
+
if (!server.listening)
|
|
1396
|
+
return;
|
|
1397
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1398
|
+
}
|
|
1399
|
+
//# sourceMappingURL=workflow_file.test.js.map
|