@openclaw/lobster 2026.9.8 → 2026.10.1-beta.2

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.
Files changed (141) hide show
  1. package/README.md +10 -9
  2. package/dist/index.js +45 -39
  3. package/node_modules/@clawdbot/lobster/README.md +4 -0
  4. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js +4 -2
  5. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  6. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +35 -12
  7. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  8. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js +13 -1
  9. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  10. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js +1 -4
  11. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  12. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js +3 -5
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  14. package/node_modules/@clawdbot/lobster/package.json +7 -7
  15. package/package.json +5 -5
  16. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js +0 -171
  17. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +0 -1
  18. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js +0 -38
  19. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +0 -1
  20. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +0 -25
  21. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +0 -1
  22. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +0 -5171
  23. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +0 -1
  24. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +0 -146
  25. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +0 -1
  26. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js +0 -63
  27. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +0 -1
  28. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js +0 -53
  29. package/node_modules/@clawdbot/lobster/dist/test/cli_run_errors.test.js.map +0 -1
  30. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js +0 -27
  31. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +0 -1
  32. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js +0 -82
  33. package/node_modules/@clawdbot/lobster/dist/test/command_arguments.test.js.map +0 -1
  34. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js +0 -44
  35. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +0 -1
  36. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js +0 -127
  37. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +0 -1
  38. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js +0 -160
  39. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +0 -1
  40. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +0 -1993
  41. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +0 -1
  42. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js +0 -48
  43. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +0 -1
  44. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js +0 -70
  45. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +0 -1
  46. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js +0 -19
  47. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +0 -1
  48. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js +0 -502
  49. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +0 -1
  50. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js +0 -296
  51. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +0 -1
  52. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js +0 -43
  53. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +0 -1
  54. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +0 -482
  55. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +0 -1
  56. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js +0 -19
  57. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +0 -1
  58. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js +0 -41
  59. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +0 -1
  60. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +0 -176
  61. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +0 -1
  62. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +0 -100
  63. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +0 -1
  64. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js +0 -43
  65. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +0 -1
  66. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js +0 -17
  67. package/node_modules/@clawdbot/lobster/dist/test/helpers/node_shell_fixture.js.map +0 -1
  68. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +0 -19
  69. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +0 -1
  70. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +0 -318
  71. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +0 -1
  72. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +0 -1113
  73. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +0 -1
  74. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +0 -519
  75. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +0 -1
  76. package/node_modules/@clawdbot/lobster/dist/test/map.test.js +0 -41
  77. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +0 -1
  78. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js +0 -48
  79. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +0 -1
  80. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js +0 -151
  81. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +0 -1
  82. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +0 -169
  83. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +0 -1
  84. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +0 -96
  85. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +0 -1
  86. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js +0 -13
  87. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +0 -1
  88. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js +0 -184
  89. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +0 -1
  90. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +0 -96
  91. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +0 -1
  92. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js +0 -65
  93. package/node_modules/@clawdbot/lobster/dist/test/pipeline_resume_state.test.js.map +0 -1
  94. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +0 -175
  95. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +0 -1
  96. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +0 -79
  97. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +0 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +0 -39
  99. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +0 -1
  100. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +0 -946
  101. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +0 -1
  102. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js +0 -82
  103. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +0 -1
  104. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +0 -248
  105. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +0 -1
  106. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js +0 -177
  107. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +0 -1
  108. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js +0 -31
  109. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +0 -1
  110. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js +0 -51
  111. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +0 -1
  112. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +0 -919
  113. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +0 -1
  114. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js +0 -254
  115. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +0 -1
  116. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +0 -44
  117. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +0 -1
  118. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +0 -220
  119. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +0 -1
  120. package/node_modules/@clawdbot/lobster/dist/test/template.test.js +0 -46
  121. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +0 -1
  122. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +0 -144
  123. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +0 -1
  124. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js +0 -15
  125. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +0 -1
  126. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js +0 -83
  127. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +0 -1
  128. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js +0 -28
  129. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +0 -1
  130. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +0 -94
  131. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +0 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +0 -43
  133. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +0 -1
  134. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js +0 -238
  135. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +0 -1
  136. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +0 -2045
  137. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +0 -1
  138. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +0 -107
  139. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +0 -1
  140. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js +0 -32
  141. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +0 -1
@@ -1,2045 +0,0 @@
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 { keyToPath, readStateJsonWithLock as 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
- const APPEND_EFFECT_AND_WAIT = "node -e \"require('fs').appendFileSync(process.env.LOBSTER_TEST_EFFECT_PATH, 'run\\n'); setInterval(() => {}, 1000)\"";
18
- const COUNT_ATTEMPTS_AND_RETRY = "node -e \"const fs=require('fs'); const file=process.env.LOBSTER_TEST_ATTEMPTS_PATH; const attempt=fs.existsSync(file) ? Number(fs.readFileSync(file, 'utf8')) : 0; fs.writeFileSync(file, String(attempt + 1)); if (attempt === 0) setInterval(() => {}, 1000); else process.stdout.write('retried');\"";
19
- test("workflow file runs with approval and resume", async () => {
20
- const workflow = {
21
- name: "sample",
22
- steps: [
23
- {
24
- id: "collect",
25
- command: 'node -e "process.stdout.write(JSON.stringify([{value:1}]))"',
26
- },
27
- {
28
- id: "mutate",
29
- 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));});\"",
30
- stdin: "$collect.stdout",
31
- },
32
- {
33
- id: "approve_step",
34
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
35
- approval: "required",
36
- },
37
- {
38
- id: "finish",
39
- 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}));});\"",
40
- stdin: "$mutate.stdout",
41
- condition: "$approve_step.approved",
42
- },
43
- ],
44
- };
45
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-"));
46
- const stateDir = path.join(tmpDir, "state");
47
- const filePath = path.join(tmpDir, "workflow.lobster");
48
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
49
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
50
- const first = await runWorkflowFile({
51
- filePath,
52
- ctx: {
53
- stdin: process.stdin,
54
- stdout: process.stdout,
55
- stderr: process.stderr,
56
- env,
57
- mode: "tool",
58
- },
59
- });
60
- assert.equal(first.status, "needs_approval");
61
- assert.equal(first.requiresApproval?.prompt, "Proceed?");
62
- assert.ok(first.requiresApproval?.resumeToken);
63
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
64
- assert.equal(payload.kind, "workflow-file");
65
- const resumed = await runWorkflowFile({
66
- filePath,
67
- ctx: {
68
- stdin: process.stdin,
69
- stdout: process.stdout,
70
- stderr: process.stderr,
71
- env,
72
- mode: "tool",
73
- },
74
- resume: payload,
75
- approved: true,
76
- });
77
- assert.equal(resumed.status, "ok");
78
- assert.deepEqual(resumed.output, [{ done: true, value: 2 }]);
79
- const stateFiles = await fsp.readdir(stateDir);
80
- const resumeStateFiles = stateFiles.filter((name) => name.startsWith("workflow_resume_"));
81
- assert.deepEqual(resumeStateFiles, []);
82
- });
83
- test("sequential workflow approvals reclaim superseded resume markers", async () => {
84
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-sequential-approval-"));
85
- try {
86
- const stateDir = path.join(tmpDir, "state");
87
- const filePath = path.join(tmpDir, "workflow.lobster");
88
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
89
- await fsp.writeFile(filePath, JSON.stringify({
90
- steps: [
91
- { id: "first", approval: "First?" },
92
- { id: "second", approval: "Second?" },
93
- { id: "finish", command: "node -e \"process.stdout.write('{}')\"" },
94
- ],
95
- }), "utf8");
96
- const ctx = {
97
- stdin: process.stdin,
98
- stdout: process.stdout,
99
- stderr: process.stderr,
100
- env,
101
- mode: "tool",
102
- };
103
- const first = await runWorkflowFile({ filePath, ctx });
104
- assert.equal(first.status, "needs_approval");
105
- const firstPayload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
106
- assert.equal(firstPayload.kind, "workflow-file");
107
- const second = await runWorkflowFile({
108
- filePath,
109
- ctx,
110
- resume: firstPayload,
111
- approved: true,
112
- });
113
- assert.equal(second.status, "needs_approval");
114
- const secondPayload = decodeResumeToken(second.requiresApproval?.resumeToken ?? "");
115
- assert.equal(secondPayload.kind, "workflow-file");
116
- const completed = await runWorkflowFile({
117
- filePath,
118
- ctx,
119
- resume: secondPayload,
120
- approved: true,
121
- });
122
- assert.equal(completed.status, "ok");
123
- const stateFiles = await fsp.readdir(stateDir);
124
- assert.deepEqual(stateFiles.filter((name) => name.startsWith("workflow_resume_")), []);
125
- }
126
- finally {
127
- await fsp.rm(tmpDir, { recursive: true, force: true });
128
- }
129
- });
130
- test("workflow resume cancellation cleans up resume state", async () => {
131
- const workflow = {
132
- steps: [
133
- {
134
- id: "approve_step",
135
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
136
- approval: "required",
137
- },
138
- {
139
- id: "finish",
140
- command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
141
- condition: "$approve_step.approved",
142
- },
143
- ],
144
- };
145
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-cancel-"));
146
- const stateDir = path.join(tmpDir, "state");
147
- const filePath = path.join(tmpDir, "workflow.lobster");
148
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
149
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
150
- const first = await runWorkflowFile({
151
- filePath,
152
- ctx: {
153
- stdin: process.stdin,
154
- stdout: process.stdout,
155
- stderr: process.stderr,
156
- env,
157
- mode: "tool",
158
- },
159
- });
160
- assert.equal(first.status, "needs_approval");
161
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
162
- assert.equal(payload.kind, "workflow-file");
163
- assert.ok(payload.stateKey);
164
- await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
165
- const cancelled = await runWorkflowFile({
166
- filePath,
167
- ctx: {
168
- stdin: process.stdin,
169
- stdout: process.stdout,
170
- stderr: process.stderr,
171
- env,
172
- mode: "tool",
173
- },
174
- resume: payload,
175
- approved: false,
176
- });
177
- assert.equal(cancelled.status, "cancelled");
178
- assert.deepEqual(cancelled.output, []);
179
- const files = await fsp.readdir(stateDir);
180
- const resumeStateFiles = files.filter((name) => name.startsWith("workflow_resume_"));
181
- assert.deepEqual(resumeStateFiles, []);
182
- });
183
- test("direct workflow resume consumes its capability after cancellation starts an effect", async () => {
184
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-direct-cancel-"));
185
- const stateDir = path.join(tmpDir, "state");
186
- const effectPath = path.join(tmpDir, "effects.log");
187
- const filePath = path.join(tmpDir, "workflow.lobster");
188
- await fsp.writeFile(filePath, JSON.stringify({
189
- steps: [
190
- {
191
- id: "approve",
192
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
193
- approval: "required",
194
- },
195
- {
196
- id: "effect",
197
- run: APPEND_EFFECT_AND_WAIT,
198
- env: { LOBSTER_TEST_EFFECT_PATH: effectPath },
199
- condition: "$approve.approved",
200
- },
201
- ],
202
- }), "utf8");
203
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
204
- const first = await runWorkflowFile({
205
- filePath,
206
- ctx: {
207
- stdin: process.stdin,
208
- stdout: process.stdout,
209
- stderr: process.stderr,
210
- env,
211
- mode: "tool",
212
- },
213
- });
214
- assert.equal(first.status, "needs_approval");
215
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
216
- assert.equal(payload.kind, "workflow-file");
217
- assert.ok(payload.stateKey);
218
- const controller = new AbortController();
219
- const resumed = runWorkflowFile({
220
- filePath,
221
- ctx: {
222
- stdin: process.stdin,
223
- stdout: process.stdout,
224
- stderr: process.stderr,
225
- env,
226
- mode: "tool",
227
- signal: controller.signal,
228
- },
229
- resume: payload,
230
- approved: true,
231
- });
232
- try {
233
- for (let attempt = 0; attempt < 1000; attempt++) {
234
- try {
235
- await fsp.access(effectPath);
236
- break;
237
- }
238
- catch {
239
- if (attempt === 999)
240
- throw new Error("workflow effect did not start");
241
- await new Promise((resolve) => setTimeout(resolve, 10));
242
- }
243
- }
244
- }
245
- finally {
246
- // A startup failure must still cancel and reap the hanging fixture.
247
- controller.abort(new Error("cancel after dispatch"));
248
- await assert.rejects(() => resumed, /cancel after dispatch/);
249
- }
250
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
251
- const stateFiles = await fsp.readdir(stateDir);
252
- assert.equal(stateFiles.some((file) => file.startsWith("approval_")), false);
253
- await assert.rejects(() => runWorkflowFile({
254
- filePath,
255
- ctx: {
256
- stdin: process.stdin,
257
- stdout: process.stdout,
258
- stderr: process.stderr,
259
- env,
260
- mode: "tool",
261
- },
262
- resume: payload,
263
- approved: true,
264
- }), /Workflow resume state not found/);
265
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
266
- });
267
- test("workflow template setup failure preserves an unstarted approval capability", async () => {
268
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-template-resume-"));
269
- try {
270
- const stateDir = path.join(tmpDir, "state");
271
- const filePath = path.join(tmpDir, "workflow.lobster");
272
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
273
- const broken = {
274
- steps: [
275
- { id: "approve", approval: "Continue?" },
276
- {
277
- id: "effect",
278
- run: "node -e \"process.stdout.write('should not run')\"",
279
- stdin: "$missing.stdout",
280
- },
281
- ],
282
- };
283
- await fsp.writeFile(filePath, JSON.stringify(broken), "utf8");
284
- const first = await runWorkflowFile({
285
- filePath,
286
- ctx: {
287
- stdin: process.stdin,
288
- stdout: process.stdout,
289
- stderr: process.stderr,
290
- env,
291
- mode: "tool",
292
- },
293
- });
294
- assert.equal(first.status, "needs_approval");
295
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
296
- assert.equal(payload.kind, "workflow-file");
297
- assert.ok(payload.stateKey);
298
- await assert.rejects(() => runWorkflowFile({
299
- filePath,
300
- ctx: {
301
- stdin: process.stdin,
302
- stdout: process.stdout,
303
- stderr: process.stderr,
304
- env,
305
- mode: "tool",
306
- },
307
- resume: payload,
308
- approved: true,
309
- }), /Unknown step reference: missing\.stdout/);
310
- assert.notEqual(await readStateJson({ env, key: payload.stateKey }), null);
311
- await fsp.writeFile(filePath, JSON.stringify({
312
- steps: [
313
- { id: "approve", approval: "Continue?" },
314
- { id: "effect", run: "node -e \"process.stdout.write('ok')\"" },
315
- ],
316
- }), "utf8");
317
- const retried = await runWorkflowFile({
318
- filePath,
319
- ctx: {
320
- stdin: process.stdin,
321
- stdout: process.stdout,
322
- stderr: process.stderr,
323
- env,
324
- mode: "tool",
325
- },
326
- resume: payload,
327
- approved: true,
328
- });
329
- assert.equal(retried.status, "ok");
330
- assert.deepEqual(retried.output, ["ok"]);
331
- }
332
- finally {
333
- await fsp.rm(tmpDir, { recursive: true, force: true });
334
- }
335
- });
336
- test("workflow resume consumes its capability after a step timeout starts an effect", async () => {
337
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-step-timeout-"));
338
- const stateDir = path.join(tmpDir, "state");
339
- const effectPath = path.join(tmpDir, "effects.log");
340
- const filePath = path.join(tmpDir, "workflow.lobster");
341
- await fsp.writeFile(filePath, JSON.stringify({
342
- steps: [
343
- {
344
- id: "approve",
345
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
346
- approval: "required",
347
- },
348
- {
349
- id: "effect",
350
- run: APPEND_EFFECT_AND_WAIT,
351
- env: { LOBSTER_TEST_EFFECT_PATH: effectPath },
352
- condition: "$approve.approved",
353
- timeout_ms: 1500,
354
- },
355
- ],
356
- }), "utf8");
357
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
358
- const first = await runWorkflowFile({
359
- filePath,
360
- ctx: {
361
- stdin: process.stdin,
362
- stdout: process.stdout,
363
- stderr: process.stderr,
364
- env,
365
- mode: "tool",
366
- },
367
- });
368
- assert.equal(first.status, "needs_approval");
369
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
370
- assert.equal(payload.kind, "workflow-file");
371
- assert.ok(payload.stateKey);
372
- await assert.rejects(() => runWorkflowFile({
373
- filePath,
374
- ctx: {
375
- stdin: process.stdin,
376
- stdout: process.stdout,
377
- stderr: process.stderr,
378
- env,
379
- mode: "tool",
380
- },
381
- resume: payload,
382
- approved: true,
383
- }), /timed out|timeout|abort|cancel/i);
384
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
385
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
386
- await assert.rejects(() => runWorkflowFile({
387
- filePath,
388
- ctx: {
389
- stdin: process.stdin,
390
- stdout: process.stdout,
391
- stderr: process.stderr,
392
- env,
393
- mode: "tool",
394
- },
395
- resume: payload,
396
- approved: true,
397
- }), /Workflow resume state not found/);
398
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
399
- });
400
- test("workflow resume applies on_error after a timed-out effect", async () => {
401
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-timeout-on-error-"));
402
- const stateDir = path.join(tmpDir, "state");
403
- const effectPath = path.join(tmpDir, "effects.log");
404
- const filePath = path.join(tmpDir, "workflow.lobster");
405
- await fsp.writeFile(filePath, JSON.stringify({
406
- steps: [
407
- {
408
- id: "approve",
409
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
410
- approval: "required",
411
- },
412
- {
413
- id: "effect",
414
- run: APPEND_EFFECT_AND_WAIT,
415
- env: { LOBSTER_TEST_EFFECT_PATH: effectPath },
416
- condition: "$approve.approved",
417
- timeout_ms: 1500,
418
- on_error: "continue",
419
- },
420
- { id: "after", run: "echo continued" },
421
- ],
422
- }), "utf8");
423
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
424
- const first = await runWorkflowFile({
425
- filePath,
426
- ctx: {
427
- stdin: process.stdin,
428
- stdout: process.stdout,
429
- stderr: process.stderr,
430
- env,
431
- mode: "tool",
432
- },
433
- });
434
- assert.equal(first.status, "needs_approval");
435
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
436
- assert.equal(payload.kind, "workflow-file");
437
- assert.ok(payload.stateKey);
438
- const resumed = await runWorkflowFile({
439
- filePath,
440
- ctx: {
441
- stdin: process.stdin,
442
- stdout: process.stdout,
443
- stderr: process.stderr,
444
- env,
445
- mode: "tool",
446
- },
447
- resume: payload,
448
- approved: true,
449
- });
450
- assert.equal(resumed.status, "ok");
451
- assert.deepEqual(resumed.output, ["continued\n"]);
452
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
453
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
454
- });
455
- test("workflow resume retries a timed-out effect before consuming its capability", async () => {
456
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-timeout-retry-"));
457
- const stateDir = path.join(tmpDir, "state");
458
- const attemptsPath = path.join(tmpDir, "attempts");
459
- const filePath = path.join(tmpDir, "workflow.lobster");
460
- await fsp.writeFile(filePath, JSON.stringify({
461
- steps: [
462
- {
463
- id: "approve",
464
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
465
- approval: "required",
466
- },
467
- {
468
- id: "effect",
469
- run: COUNT_ATTEMPTS_AND_RETRY,
470
- env: { LOBSTER_TEST_ATTEMPTS_PATH: attemptsPath },
471
- condition: "$approve.approved",
472
- timeout_ms: 1500,
473
- retry: { max: 2, delay_ms: 10 },
474
- },
475
- ],
476
- }), "utf8");
477
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
478
- const first = await runWorkflowFile({
479
- filePath,
480
- ctx: {
481
- stdin: process.stdin,
482
- stdout: process.stdout,
483
- stderr: process.stderr,
484
- env,
485
- mode: "tool",
486
- },
487
- });
488
- assert.equal(first.status, "needs_approval");
489
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
490
- assert.equal(payload.kind, "workflow-file");
491
- assert.ok(payload.stateKey);
492
- const resumed = await runWorkflowFile({
493
- filePath,
494
- ctx: {
495
- stdin: process.stdin,
496
- stdout: process.stdout,
497
- stderr: process.stderr,
498
- env,
499
- mode: "tool",
500
- },
501
- resume: payload,
502
- approved: true,
503
- });
504
- assert.equal(resumed.status, "ok");
505
- assert.deepEqual(resumed.output, ["retried"]);
506
- assert.equal(await fsp.readFile(attemptsPath, "utf8"), "2");
507
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
508
- });
509
- test("workflow resume retries a claim blocked by a state lock before dispatch", async () => {
510
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-claim-retry-"));
511
- try {
512
- const stateDir = path.join(tmpDir, "state");
513
- const filePath = path.join(tmpDir, "workflow.lobster");
514
- await fsp.writeFile(filePath, JSON.stringify({
515
- steps: [
516
- { id: "approve", approval: "Proceed?" },
517
- {
518
- id: "effect",
519
- run: "printf ran",
520
- condition: "$approve.approved",
521
- timeout_ms: 1000,
522
- retry: { max: 2, delay_ms: 250 },
523
- },
524
- ],
525
- }), "utf8");
526
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
527
- const first = await runWorkflowFile({
528
- filePath,
529
- ctx: {
530
- stdin: process.stdin,
531
- stdout: process.stdout,
532
- stderr: process.stderr,
533
- env,
534
- mode: "tool",
535
- },
536
- });
537
- assert.equal(first.status, "needs_approval");
538
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
539
- assert.equal(payload.kind, "workflow-file");
540
- assert.ok(payload.stateKey);
541
- const lockPath = `${keyToPath(stateDir, payload.stateKey)}.lock`;
542
- const originalRename = fsp.rename;
543
- const originalRm = fsp.rm;
544
- let releasedInitialReadLocks = 0;
545
- let lockReplaced;
546
- const replacedInitialReadLocks = new Promise((resolve) => {
547
- lockReplaced = resolve;
548
- });
549
- Object.defineProperty(fsp, "rename", {
550
- configurable: true,
551
- writable: true,
552
- async value(oldPath, newPath) {
553
- const result = await originalRename(oldPath, newPath);
554
- if (String(oldPath) === lockPath && ++releasedInitialReadLocks === 2) {
555
- await fsp.mkdir(lockPath);
556
- await fsp.writeFile(path.join(lockPath, "owner"), `${process.pid}::live-writer\n`, "utf8");
557
- lockReplaced();
558
- }
559
- return result;
560
- },
561
- });
562
- try {
563
- const resumedRun = runWorkflowFile({
564
- filePath,
565
- ctx: {
566
- stdin: process.stdin,
567
- stdout: process.stdout,
568
- stderr: process.stderr,
569
- env,
570
- mode: "tool",
571
- },
572
- resume: payload,
573
- approved: true,
574
- });
575
- await replacedInitialReadLocks;
576
- const release = setTimeout(() => void originalRm(lockPath, { recursive: true, force: true }), 1200);
577
- const resumed = await resumedRun;
578
- clearTimeout(release);
579
- assert.equal(resumed.status, "ok");
580
- assert.deepEqual(resumed.output, ["ran"]);
581
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
582
- }
583
- finally {
584
- Object.defineProperty(fsp, "rename", {
585
- configurable: true,
586
- writable: true,
587
- value: originalRename,
588
- });
589
- await fsp.rm(lockPath, { recursive: true, force: true });
590
- }
591
- }
592
- finally {
593
- await fsp.rm(tmpDir, { recursive: true, force: true });
594
- }
595
- });
596
- test("workflow resume consumes its capability after a parallel timeout starts an effect", async () => {
597
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-parallel-timeout-"));
598
- const stateDir = path.join(tmpDir, "state");
599
- const effectPath = path.join(tmpDir, "effects.log");
600
- const filePath = path.join(tmpDir, "workflow.lobster");
601
- await fsp.writeFile(filePath, JSON.stringify({
602
- steps: [
603
- {
604
- id: "approve",
605
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?',items:[{id:1}]}}))\"",
606
- approval: "required",
607
- },
608
- {
609
- id: "effect",
610
- condition: "$approve.approved",
611
- parallel: {
612
- timeout_ms: 5000,
613
- branches: [
614
- {
615
- id: "side-effect",
616
- run: APPEND_EFFECT_AND_WAIT,
617
- env: { LOBSTER_TEST_EFFECT_PATH: effectPath },
618
- },
619
- ],
620
- },
621
- },
622
- ],
623
- }), "utf8");
624
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
625
- const first = await runWorkflowFile({
626
- filePath,
627
- ctx: {
628
- stdin: process.stdin,
629
- stdout: process.stdout,
630
- stderr: process.stderr,
631
- env,
632
- mode: "tool",
633
- },
634
- });
635
- assert.equal(first.status, "needs_approval");
636
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
637
- assert.equal(payload.kind, "workflow-file");
638
- assert.ok(payload.stateKey);
639
- await assert.rejects(() => runWorkflowFile({
640
- filePath,
641
- ctx: {
642
- stdin: process.stdin,
643
- stdout: process.stdout,
644
- stderr: process.stderr,
645
- env,
646
- mode: "tool",
647
- },
648
- resume: payload,
649
- approved: true,
650
- }), /timed out|timeout|abort|cancel/i);
651
- assert.equal(await readStateJson({ env, key: payload.stateKey }), null);
652
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
653
- await assert.rejects(() => runWorkflowFile({
654
- filePath,
655
- ctx: {
656
- stdin: process.stdin,
657
- stdout: process.stdout,
658
- stderr: process.stderr,
659
- env,
660
- mode: "tool",
661
- },
662
- resume: payload,
663
- approved: true,
664
- }), /Workflow resume state not found/);
665
- assert.equal((await fsp.readFile(effectPath, "utf8")).trim().split(/\r?\n/).length, 1);
666
- });
667
- test("workflow resume accepts workflow-resume_ state key aliases and cleans up state", async () => {
668
- const workflow = {
669
- steps: [
670
- {
671
- id: "approve_step",
672
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
673
- approval: "required",
674
- },
675
- {
676
- id: "finish",
677
- command: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
678
- condition: "$approve_step.approved",
679
- },
680
- ],
681
- };
682
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-alias-"));
683
- const stateDir = path.join(tmpDir, "state");
684
- const filePath = path.join(tmpDir, "workflow.lobster");
685
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
686
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
687
- const first = await runWorkflowFile({
688
- filePath,
689
- ctx: {
690
- stdin: process.stdin,
691
- stdout: process.stdout,
692
- stderr: process.stderr,
693
- env,
694
- mode: "tool",
695
- },
696
- });
697
- assert.equal(first.status, "needs_approval");
698
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
699
- assert.equal(payload.kind, "workflow-file");
700
- assert.ok(payload.stateKey?.startsWith("workflow_resume_"));
701
- const aliasedPayload = {
702
- ...payload,
703
- stateKey: (payload.stateKey ?? "").replace("workflow_resume_", "workflow-resume_"),
704
- };
705
- assert.ok(aliasedPayload.stateKey.startsWith("workflow-resume_"));
706
- const resumed = await runWorkflowFile({
707
- filePath,
708
- ctx: {
709
- stdin: process.stdin,
710
- stdout: process.stdout,
711
- stderr: process.stderr,
712
- env,
713
- mode: "tool",
714
- },
715
- resume: aliasedPayload,
716
- approved: true,
717
- });
718
- assert.equal(resumed.status, "ok");
719
- assert.deepEqual(resumed.output, [{ done: true }]);
720
- const files = await fsp.readdir(stateDir);
721
- const resumeStateFiles = files.filter((name) => name.startsWith("workflow_resume_") || name.startsWith("workflow-resume_"));
722
- assert.deepEqual(resumeStateFiles, []);
723
- });
724
- test("workflow file input steps pause and resume with structured responses", async () => {
725
- const workflow = {
726
- steps: [
727
- {
728
- id: "draft",
729
- run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
730
- },
731
- {
732
- id: "review",
733
- input: {
734
- prompt: "Review draft?",
735
- responseSchema: {
736
- type: "object",
737
- properties: { decision: { type: "string" } },
738
- required: ["decision"],
739
- },
740
- },
741
- },
742
- {
743
- id: "finish",
744
- run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION,subject:process.env.SUBJECT}))"',
745
- env: {
746
- DECISION: "$review.response.decision",
747
- SUBJECT: "$review.subject.text",
748
- },
749
- },
750
- ],
751
- };
752
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-"));
753
- const stateDir = path.join(tmpDir, "state");
754
- const filePath = path.join(tmpDir, "workflow.lobster");
755
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
756
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
757
- const first = await runWorkflowFile({
758
- filePath,
759
- ctx: {
760
- stdin: process.stdin,
761
- stdout: process.stdout,
762
- stderr: process.stderr,
763
- env,
764
- mode: "tool",
765
- },
766
- });
767
- assert.equal(first.status, "needs_input");
768
- assert.deepEqual(first.requiresInput?.subject, { text: "hello" });
769
- assert.ok(first.requiresInput?.resumeToken);
770
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
771
- assert.equal(payload.kind, "workflow-file");
772
- const resumeEnv = { ...env };
773
- delete resumeEnv.LONG_TEXT;
774
- const resumed = await runWorkflowFile({
775
- filePath,
776
- ctx: {
777
- stdin: process.stdin,
778
- stdout: process.stdout,
779
- stderr: process.stderr,
780
- env: resumeEnv,
781
- mode: "tool",
782
- },
783
- resume: payload,
784
- response: { decision: "approve" },
785
- });
786
- assert.equal(resumed.status, "ok");
787
- assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
788
- });
789
- test("workflow input resume contains prototype-named step ids", async () => {
790
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-prototype-step-"));
791
- const stateDir = path.join(tmpDir, "state");
792
- const filePath = path.join(tmpDir, "workflow.lobster");
793
- const objectPrototype = Object.prototype;
794
- try {
795
- await fsp.writeFile(filePath, JSON.stringify({
796
- steps: [
797
- {
798
- id: "__proto__",
799
- input: {
800
- prompt: "Continue?",
801
- responseSchema: {
802
- type: "object",
803
- properties: { decision: { type: "string" } },
804
- required: ["decision"],
805
- },
806
- },
807
- },
808
- {
809
- id: "finish",
810
- run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION}))"',
811
- env: { DECISION: "$__proto__.response.decision" },
812
- },
813
- ],
814
- }), "utf8");
815
- const ctx = {
816
- stdin: process.stdin,
817
- stdout: process.stdout,
818
- stderr: process.stderr,
819
- env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
820
- mode: "tool",
821
- };
822
- const first = await runWorkflowFile({ filePath, ctx });
823
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
824
- assert.equal(payload.kind, "workflow-file");
825
- const resumed = await runWorkflowFile({
826
- filePath,
827
- ctx,
828
- resume: payload,
829
- response: { decision: "approve" },
830
- });
831
- assert.equal(Object.hasOwn(objectPrototype, "response"), false);
832
- assert.equal(Object.hasOwn(objectPrototype, "subject"), false);
833
- assert.deepEqual(resumed.output, [{ decision: "approve" }]);
834
- }
835
- finally {
836
- delete objectPrototype.response;
837
- delete objectPrototype.subject;
838
- await fsp.rm(tmpDir, { recursive: true, force: true });
839
- }
840
- });
841
- test("workflow pipeline command input pauses and resumes the same pipeline step", async () => {
842
- const schema = JSON.stringify({
843
- type: "object",
844
- properties: { decision: { type: "string", enum: ["approve", "reject"] } },
845
- required: ["decision"],
846
- });
847
- const workflow = {
848
- steps: [
849
- {
850
- id: "draft",
851
- run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
852
- },
853
- {
854
- id: "review",
855
- pipeline: `ask --subject-from-stdin --prompt 'Review draft?' --schema ${JSON.stringify(schema)} | pick decision`,
856
- stdin: "$draft.json",
857
- },
858
- {
859
- id: "finish",
860
- run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION}))"',
861
- env: {
862
- DECISION: "$review.json.decision",
863
- },
864
- },
865
- ],
866
- };
867
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-input-"));
868
- const stateDir = path.join(tmpDir, "state");
869
- const filePath = path.join(tmpDir, "workflow.lobster");
870
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
871
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
872
- const first = await runWorkflowFile({
873
- filePath,
874
- ctx: {
875
- stdin: process.stdin,
876
- stdout: process.stdout,
877
- stderr: process.stderr,
878
- env,
879
- mode: "tool",
880
- registry: createDefaultRegistry(),
881
- },
882
- });
883
- assert.equal(first.status, "needs_input");
884
- assert.deepEqual(first.requiresInput?.subject, { text: '{"text":"hello"}' });
885
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
886
- assert.equal(payload.kind, "workflow-file");
887
- const state = (await readStateJson({ env, key: payload.stateKey }));
888
- assert.equal(state.resumeAtIndex, 1);
889
- assert.equal(state.inputKind, "pipeline_command");
890
- assert.equal(state.inputStepId, "review");
891
- assert.equal(state.pipelineInput.resumeAtIndex, 0);
892
- assert.deepEqual(state.pipelineInput.items, [{ text: "hello" }]);
893
- assert.deepEqual(state.pipelineInput.commandInput.pending.suspendedState, {
894
- type: "ask",
895
- subject: { text: '{"text":"hello"}' },
896
- });
897
- const resumed = await runWorkflowFile({
898
- filePath,
899
- ctx: {
900
- stdin: process.stdin,
901
- stdout: process.stdout,
902
- stderr: process.stderr,
903
- env,
904
- mode: "tool",
905
- registry: createDefaultRegistry(),
906
- },
907
- resume: payload,
908
- response: { decision: "approve" },
909
- });
910
- assert.equal(resumed.status, "ok");
911
- assert.deepEqual(resumed.output, [{ decision: "approve" }]);
912
- });
913
- test("workflow pipeline requestInput resume invariant bypasses on_error", async () => {
914
- const schema = {
915
- type: "object",
916
- properties: { decision: { type: "string" } },
917
- required: ["decision"],
918
- };
919
- let calls = 0;
920
- let sideEffects = 0;
921
- const choose = {
922
- name: "choose",
923
- meta: { resumeSafeBeforeInput: true },
924
- async run({ ctx }) {
925
- calls += 1;
926
- if (calls > 1)
927
- return { output: streamOf([{ skipped: true }]) };
928
- await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
929
- return { output: streamOf([]) };
930
- },
931
- };
932
- const side = {
933
- name: "side",
934
- async run() {
935
- sideEffects += 1;
936
- return { output: streamOf([{ sideEffects }]) };
937
- },
938
- };
939
- const registry = {
940
- get(name) {
941
- return name === "choose" ? choose : name === "side" ? side : undefined;
942
- },
943
- list() {
944
- return ["choose", "side"];
945
- },
946
- };
947
- const workflow = {
948
- name: "sample",
949
- steps: [
950
- {
951
- id: "review",
952
- pipeline: "choose",
953
- on_error: "continue",
954
- },
955
- {
956
- id: "side",
957
- pipeline: "side",
958
- },
959
- ],
960
- };
961
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-invariant-"));
962
- const stateDir = path.join(tmpDir, "state");
963
- const filePath = path.join(tmpDir, "workflow.lobster");
964
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
965
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
966
- const first = await runWorkflowFile({
967
- filePath,
968
- ctx: {
969
- stdin: process.stdin,
970
- stdout: process.stdout,
971
- stderr: process.stderr,
972
- env,
973
- mode: "tool",
974
- registry,
975
- },
976
- });
977
- assert.equal(first.status, "needs_input");
978
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
979
- assert.equal(payload.kind, "workflow-file");
980
- await assert.rejects(runWorkflowFile({
981
- filePath,
982
- ctx: {
983
- stdin: process.stdin,
984
- stdout: process.stdout,
985
- stderr: process.stderr,
986
- env,
987
- mode: "tool",
988
- registry,
989
- },
990
- resume: payload,
991
- response: { decision: "approve" },
992
- }), /not consumed/);
993
- assert.equal(sideEffects, 0);
994
- await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
995
- });
996
- test("workflow pipeline requestInput resume rejects changed pipeline", async () => {
997
- const schema = {
998
- type: "object",
999
- properties: { decision: { type: "string" } },
1000
- required: ["decision"],
1001
- };
1002
- let sideEffects = 0;
1003
- const choose = {
1004
- name: "choose",
1005
- async run({ ctx }) {
1006
- const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
1007
- return { output: streamOf([{ decision: response.decision }]) };
1008
- },
1009
- };
1010
- const side = {
1011
- name: "side",
1012
- async run({ input }) {
1013
- sideEffects += 1;
1014
- return { output: input };
1015
- },
1016
- };
1017
- const registry = {
1018
- get(name) {
1019
- return name === "choose" ? choose : name === "side" ? side : undefined;
1020
- },
1021
- list() {
1022
- return ["choose", "side"];
1023
- },
1024
- };
1025
- const workflow = {
1026
- name: "sample",
1027
- steps: [
1028
- {
1029
- id: "review",
1030
- pipeline: "choose | side",
1031
- },
1032
- ],
1033
- };
1034
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-change-"));
1035
- const stateDir = path.join(tmpDir, "state");
1036
- const filePath = path.join(tmpDir, "workflow.lobster");
1037
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1038
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1039
- const first = await runWorkflowFile({
1040
- filePath,
1041
- ctx: {
1042
- stdin: process.stdin,
1043
- stdout: process.stdout,
1044
- stderr: process.stderr,
1045
- env,
1046
- mode: "tool",
1047
- registry,
1048
- },
1049
- });
1050
- assert.equal(first.status, "needs_input");
1051
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1052
- assert.equal(payload.kind, "workflow-file");
1053
- workflow.steps[0].pipeline = "choose";
1054
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1055
- await assert.rejects(runWorkflowFile({
1056
- filePath,
1057
- ctx: {
1058
- stdin: process.stdin,
1059
- stdout: process.stdout,
1060
- stderr: process.stderr,
1061
- env,
1062
- mode: "tool",
1063
- registry,
1064
- },
1065
- resume: payload,
1066
- response: { decision: "approve" },
1067
- }), /pipeline changed/);
1068
- assert.equal(sideEffects, 0);
1069
- await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
1070
- });
1071
- test("workflow pipeline requestInput keeps full pipeline across repeated suspensions", async () => {
1072
- const schema = {
1073
- type: "object",
1074
- properties: { decision: { type: "string" } },
1075
- required: ["decision"],
1076
- };
1077
- const produce = {
1078
- name: "produce",
1079
- async run() {
1080
- return { output: streamOf([{ id: 1 }]) };
1081
- },
1082
- };
1083
- const choose = {
1084
- name: "choose",
1085
- async run({ ctx }) {
1086
- const first = await ctx.requestInput({
1087
- prompt: "First?",
1088
- responseSchema: schema,
1089
- suspendedState: { phase: "first" },
1090
- });
1091
- const second = await ctx.requestInput({
1092
- prompt: `Second after ${first.decision}`,
1093
- responseSchema: schema,
1094
- suspendedState: { phase: "second" },
1095
- });
1096
- return { output: streamOf([{ first: first.decision, second: second.decision }]) };
1097
- },
1098
- };
1099
- const registry = {
1100
- get(name) {
1101
- return name === "produce" ? produce : name === "choose" ? choose : undefined;
1102
- },
1103
- list() {
1104
- return ["produce", "choose"];
1105
- },
1106
- };
1107
- const workflow = {
1108
- name: "sample",
1109
- steps: [
1110
- {
1111
- id: "review",
1112
- pipeline: "produce | choose",
1113
- },
1114
- ],
1115
- };
1116
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-repeat-"));
1117
- const stateDir = path.join(tmpDir, "state");
1118
- const filePath = path.join(tmpDir, "workflow.lobster");
1119
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1120
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1121
- const first = await runWorkflowFile({
1122
- filePath,
1123
- ctx: {
1124
- stdin: process.stdin,
1125
- stdout: process.stdout,
1126
- stderr: process.stderr,
1127
- env,
1128
- mode: "tool",
1129
- registry,
1130
- },
1131
- });
1132
- assert.equal(first.status, "needs_input");
1133
- const firstPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1134
- assert.equal(firstPayload.kind, "workflow-file");
1135
- const second = await runWorkflowFile({
1136
- filePath,
1137
- ctx: {
1138
- stdin: process.stdin,
1139
- stdout: process.stdout,
1140
- stderr: process.stderr,
1141
- env,
1142
- mode: "tool",
1143
- registry,
1144
- },
1145
- resume: firstPayload,
1146
- response: { decision: "approve" },
1147
- });
1148
- assert.equal(second.status, "needs_input");
1149
- const secondPayload = decodeResumeToken(second.requiresInput?.resumeToken ?? "");
1150
- assert.equal(secondPayload.kind, "workflow-file");
1151
- const state = (await readStateJson({ env, key: secondPayload.stateKey }));
1152
- assert.equal(state.pipelineInput.resumeAtIndex, 1);
1153
- assert.equal(state.pipelineInput.pipeline.length, 2);
1154
- const done = await runWorkflowFile({
1155
- filePath,
1156
- ctx: {
1157
- stdin: process.stdin,
1158
- stdout: process.stdout,
1159
- stderr: process.stderr,
1160
- env,
1161
- mode: "tool",
1162
- registry,
1163
- },
1164
- resume: secondPayload,
1165
- response: { decision: "ship" },
1166
- });
1167
- assert.equal(done.status, "ok");
1168
- assert.deepEqual(done.output, [{ first: "approve", second: "ship" }]);
1169
- });
1170
- test("workflow pipeline requestInput resume rejects condition bypass", async () => {
1171
- const schema = {
1172
- type: "object",
1173
- properties: { decision: { type: "string" } },
1174
- required: ["decision"],
1175
- };
1176
- let sideEffects = 0;
1177
- const choose = {
1178
- name: "choose",
1179
- async run({ ctx }) {
1180
- const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
1181
- return { output: streamOf([{ decision: response.decision }]) };
1182
- },
1183
- };
1184
- const side = {
1185
- name: "side",
1186
- async run() {
1187
- sideEffects += 1;
1188
- return { output: streamOf([{ sideEffects }]) };
1189
- },
1190
- };
1191
- const registry = {
1192
- get(name) {
1193
- return name === "choose" ? choose : name === "side" ? side : undefined;
1194
- },
1195
- list() {
1196
- return ["choose", "side"];
1197
- },
1198
- };
1199
- const workflow = {
1200
- name: "sample",
1201
- steps: [
1202
- {
1203
- id: "gate",
1204
- run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
1205
- },
1206
- {
1207
- id: "review",
1208
- pipeline: "choose",
1209
- condition: "$gate.json.ok",
1210
- },
1211
- {
1212
- id: "side",
1213
- pipeline: "side",
1214
- },
1215
- ],
1216
- };
1217
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-condition-"));
1218
- const stateDir = path.join(tmpDir, "state");
1219
- const filePath = path.join(tmpDir, "workflow.lobster");
1220
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1221
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1222
- const first = await runWorkflowFile({
1223
- filePath,
1224
- ctx: {
1225
- stdin: process.stdin,
1226
- stdout: process.stdout,
1227
- stderr: process.stderr,
1228
- env,
1229
- mode: "tool",
1230
- registry,
1231
- },
1232
- });
1233
- assert.equal(first.status, "needs_input");
1234
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1235
- assert.equal(payload.kind, "workflow-file");
1236
- workflow.steps[1].condition = "$gate.json.missing";
1237
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1238
- await assert.rejects(runWorkflowFile({
1239
- filePath,
1240
- ctx: {
1241
- stdin: process.stdin,
1242
- stdout: process.stdout,
1243
- stderr: process.stderr,
1244
- env,
1245
- mode: "tool",
1246
- registry,
1247
- },
1248
- resume: payload,
1249
- response: { decision: "approve" },
1250
- }), /condition changed/);
1251
- assert.equal(sideEffects, 0);
1252
- await fsp.access(path.join(stateDir, `${payload.stateKey}.json`));
1253
- });
1254
- test("workflow pipeline command input preserves replayable stdin without suspended state", async () => {
1255
- const schema = {
1256
- type: "object",
1257
- properties: { decision: { type: "string" } },
1258
- required: ["decision"],
1259
- };
1260
- const reviewCommand = {
1261
- name: "review_input",
1262
- async run({ input, ctx }) {
1263
- const response = await ctx.requestInput({ prompt: "Review?", responseSchema: schema });
1264
- const items = [];
1265
- for await (const item of input)
1266
- items.push(item);
1267
- return { output: streamOf([{ items, decision: response.decision }]) };
1268
- },
1269
- };
1270
- const registry = {
1271
- get(name) {
1272
- return name === reviewCommand.name ? reviewCommand : undefined;
1273
- },
1274
- list() {
1275
- return [reviewCommand.name];
1276
- },
1277
- };
1278
- async function runCase({ sourceStep, stdin, prefix, }) {
1279
- const steps = [
1280
- ...(sourceStep ? [sourceStep] : []),
1281
- {
1282
- id: "review",
1283
- pipeline: "review_input",
1284
- ...(stdin ? { stdin } : null),
1285
- },
1286
- ];
1287
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
1288
- const stateDir = path.join(tmpDir, "state");
1289
- const filePath = path.join(tmpDir, "workflow.lobster");
1290
- await fsp.writeFile(filePath, JSON.stringify({ name: "sample", steps }, null, 2), "utf8");
1291
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1292
- const first = await runWorkflowFile({
1293
- filePath,
1294
- ctx: {
1295
- stdin: process.stdin,
1296
- stdout: process.stdout,
1297
- stderr: process.stderr,
1298
- env,
1299
- mode: "tool",
1300
- registry,
1301
- },
1302
- });
1303
- assert.equal(first.status, "needs_input");
1304
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1305
- assert.equal(payload.kind, "workflow-file");
1306
- const state = (await readStateJson({ env, key: payload.stateKey }));
1307
- const resumed = await runWorkflowFile({
1308
- filePath,
1309
- ctx: {
1310
- stdin: process.stdin,
1311
- stdout: process.stdout,
1312
- stderr: process.stderr,
1313
- env,
1314
- mode: "tool",
1315
- registry,
1316
- },
1317
- resume: payload,
1318
- response: { decision: "approve" },
1319
- });
1320
- return { state, resumed };
1321
- }
1322
- const noStdin = await runCase({ prefix: "lobster-workflow-pipeline-no-stdin-" });
1323
- assert.deepEqual(noStdin.state.pipelineInput.items, []);
1324
- assert.equal(noStdin.resumed.status, "ok");
1325
- assert.deepEqual(noStdin.resumed.output, [{ items: [], decision: "approve" }]);
1326
- const withArrayStdin = await runCase({
1327
- prefix: "lobster-workflow-pipeline-array-stdin-",
1328
- sourceStep: {
1329
- id: "draft",
1330
- run: 'node -e "process.stdout.write(JSON.stringify([{id:1}]))"',
1331
- },
1332
- stdin: "$draft.json",
1333
- });
1334
- assert.deepEqual(withArrayStdin.state.pipelineInput.items, [{ id: 1 }]);
1335
- assert.equal(withArrayStdin.resumed.status, "ok");
1336
- assert.deepEqual(withArrayStdin.resumed.output, [{ items: [{ id: 1 }], decision: "approve" }]);
1337
- });
1338
- test("workflow input resumes preserve the full subject even when the tool envelope preview is truncated", async () => {
1339
- const longText = "x".repeat(250_000);
1340
- const workflow = {
1341
- steps: [
1342
- {
1343
- id: "draft",
1344
- 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})))\"",
1345
- stdin: longText,
1346
- },
1347
- {
1348
- id: "review",
1349
- input: {
1350
- prompt: "Review draft?",
1351
- responseSchema: {
1352
- type: "object",
1353
- properties: { decision: { type: "string" } },
1354
- required: ["decision"],
1355
- },
1356
- },
1357
- },
1358
- {
1359
- id: "finish",
1360
- 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})))\"",
1361
- stdin: "$review.subject.text",
1362
- },
1363
- ],
1364
- };
1365
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-input-truncate-"));
1366
- const stateDir = path.join(tmpDir, "state");
1367
- const filePath = path.join(tmpDir, "workflow.lobster");
1368
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1369
- const env = {
1370
- ...process.env,
1371
- LOBSTER_STATE_DIR: stateDir,
1372
- LOBSTER_MAX_TOOL_ENVELOPE_BYTES: "8192",
1373
- };
1374
- const first = await runWorkflowFile({
1375
- filePath,
1376
- ctx: {
1377
- stdin: process.stdin,
1378
- stdout: process.stdout,
1379
- stderr: process.stderr,
1380
- env,
1381
- mode: "tool",
1382
- },
1383
- });
1384
- assert.equal(first.status, "needs_input");
1385
- assert.deepEqual(first.requiresInput?.subject, {
1386
- truncated: true,
1387
- bytes: Buffer.byteLength(JSON.stringify({ text: longText }), "utf8"),
1388
- preview: JSON.stringify({ text: longText }).slice(0, 2000),
1389
- });
1390
- const payload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1391
- assert.equal(payload.kind, "workflow-file");
1392
- const resumed = await runWorkflowFile({
1393
- filePath,
1394
- ctx: {
1395
- stdin: process.stdin,
1396
- stdout: process.stdout,
1397
- stderr: process.stderr,
1398
- env,
1399
- mode: "tool",
1400
- },
1401
- resume: payload,
1402
- response: { decision: "approve" },
1403
- });
1404
- assert.equal(resumed.status, "ok");
1405
- assert.deepEqual(resumed.output, [{ subjectLength: longText.length }]);
1406
- });
1407
- test("workflow approval resumes require an explicit decision", async () => {
1408
- const workflow = {
1409
- steps: [
1410
- {
1411
- id: "approve_step",
1412
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
1413
- approval: "required",
1414
- },
1415
- {
1416
- id: "finish",
1417
- run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
1418
- condition: "$approve_step.approved",
1419
- },
1420
- ],
1421
- };
1422
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-required-"));
1423
- const stateDir = path.join(tmpDir, "state");
1424
- const filePath = path.join(tmpDir, "workflow.lobster");
1425
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1426
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1427
- const first = await runWorkflowFile({
1428
- filePath,
1429
- ctx: {
1430
- stdin: process.stdin,
1431
- stdout: process.stdout,
1432
- stderr: process.stderr,
1433
- env,
1434
- mode: "tool",
1435
- },
1436
- });
1437
- assert.equal(first.status, "needs_approval");
1438
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
1439
- assert.equal(payload.kind, "workflow-file");
1440
- await assert.rejects(() => runWorkflowFile({
1441
- filePath,
1442
- ctx: {
1443
- stdin: process.stdin,
1444
- stdout: process.stdout,
1445
- stderr: process.stderr,
1446
- env,
1447
- mode: "tool",
1448
- },
1449
- resume: payload,
1450
- }), /requires --approve yes\|no/i);
1451
- });
1452
- test("workflow approval can require a different approver than initiator", async () => {
1453
- const workflow = {
1454
- steps: [
1455
- {
1456
- id: "gate",
1457
- approval: {
1458
- prompt: "Proceed?",
1459
- require_different_approver: true,
1460
- },
1461
- },
1462
- {
1463
- id: "finish",
1464
- run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
1465
- when: "$gate.approved",
1466
- },
1467
- ],
1468
- };
1469
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-approval-identity-"));
1470
- const stateDir = path.join(tmpDir, "state");
1471
- const filePath = path.join(tmpDir, "workflow.lobster");
1472
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1473
- const baseEnv = {
1474
- ...process.env,
1475
- LOBSTER_STATE_DIR: stateDir,
1476
- LOBSTER_APPROVAL_INITIATED_BY: "agent-1",
1477
- };
1478
- const first = await runWorkflowFile({
1479
- filePath,
1480
- ctx: {
1481
- stdin: process.stdin,
1482
- stdout: process.stdout,
1483
- stderr: process.stderr,
1484
- env: baseEnv,
1485
- mode: "tool",
1486
- },
1487
- });
1488
- assert.equal(first.status, "needs_approval");
1489
- assert.equal(first.requiresApproval?.initiatedBy, "agent-1");
1490
- assert.equal(first.requiresApproval?.requireDifferentApprover, true);
1491
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
1492
- assert.equal(payload.kind, "workflow-file");
1493
- await assert.rejects(() => runWorkflowFile({
1494
- filePath,
1495
- ctx: {
1496
- stdin: process.stdin,
1497
- stdout: process.stdout,
1498
- stderr: process.stderr,
1499
- env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "agent-1" },
1500
- mode: "tool",
1501
- },
1502
- resume: payload,
1503
- approved: true,
1504
- }), /must be granted by someone other than 'agent-1'/i);
1505
- const resumed = await runWorkflowFile({
1506
- filePath,
1507
- ctx: {
1508
- stdin: process.stdin,
1509
- stdout: process.stdout,
1510
- stderr: process.stderr,
1511
- env: { ...baseEnv, LOBSTER_APPROVAL_APPROVED_BY: "human-1" },
1512
- mode: "tool",
1513
- },
1514
- resume: payload,
1515
- approved: true,
1516
- });
1517
- assert.equal(resumed.status, "ok");
1518
- assert.deepEqual(resumed.output, [{ done: true }]);
1519
- });
1520
- test("workflow approval can require a specific approver identity", async () => {
1521
- const workflow = {
1522
- steps: [
1523
- {
1524
- id: "gate",
1525
- approval: {
1526
- prompt: "Proceed?",
1527
- required_approver: "alice",
1528
- },
1529
- },
1530
- {
1531
- id: "finish",
1532
- run: 'node -e "process.stdout.write(JSON.stringify({done:true}))"',
1533
- when: "$gate.approved",
1534
- },
1535
- ],
1536
- };
1537
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-required-approver-"));
1538
- const stateDir = path.join(tmpDir, "state");
1539
- const filePath = path.join(tmpDir, "workflow.lobster");
1540
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1541
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1542
- const first = await runWorkflowFile({
1543
- filePath,
1544
- ctx: {
1545
- stdin: process.stdin,
1546
- stdout: process.stdout,
1547
- stderr: process.stderr,
1548
- env,
1549
- mode: "tool",
1550
- },
1551
- });
1552
- assert.equal(first.status, "needs_approval");
1553
- assert.equal(first.requiresApproval?.requiredApprover, "alice");
1554
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
1555
- assert.equal(payload.kind, "workflow-file");
1556
- await assert.rejects(() => runWorkflowFile({
1557
- filePath,
1558
- ctx: {
1559
- stdin: process.stdin,
1560
- stdout: process.stdout,
1561
- stderr: process.stderr,
1562
- env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "bob" },
1563
- mode: "tool",
1564
- },
1565
- resume: payload,
1566
- approved: true,
1567
- }), /requires approver 'alice', got 'bob'/i);
1568
- const resumed = await runWorkflowFile({
1569
- filePath,
1570
- ctx: {
1571
- stdin: process.stdin,
1572
- stdout: process.stdout,
1573
- stderr: process.stderr,
1574
- env: { ...env, LOBSTER_APPROVAL_APPROVED_BY: "alice" },
1575
- mode: "tool",
1576
- },
1577
- resume: payload,
1578
- approved: true,
1579
- });
1580
- assert.equal(resumed.status, "ok");
1581
- assert.deepEqual(resumed.output, [{ done: true }]);
1582
- });
1583
- test("workflow conditions support comparisons, boolean operators, and parentheses", async () => {
1584
- const workflow = {
1585
- steps: [
1586
- {
1587
- id: "collect",
1588
- run: "node -e \"process.stdout.write(JSON.stringify({kind:'deploy',count:2}))\"",
1589
- },
1590
- {
1591
- id: "review",
1592
- input: {
1593
- prompt: "Review draft?",
1594
- responseSchema: {
1595
- type: "object",
1596
- properties: { decision: { type: "string" } },
1597
- required: ["decision"],
1598
- },
1599
- },
1600
- },
1601
- {
1602
- id: "approve_step",
1603
- command: "node -e \"process.stdout.write(JSON.stringify({requiresApproval:{prompt:'Proceed?', items:[{id:1}]}}))\"",
1604
- approval: "required",
1605
- },
1606
- {
1607
- id: "finish",
1608
- run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
1609
- condition: "($approve_step.approved && $review.response.decision == approve) && !($collect.json.kind != deploy || $collect.json.count != 2)",
1610
- },
1611
- {
1612
- id: "fallback",
1613
- run: 'node -e "process.stdout.write(JSON.stringify({ok:false}))"',
1614
- condition: "$review.response.decision == reject || $collect.json.kind == skip",
1615
- },
1616
- ],
1617
- };
1618
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-conditions-"));
1619
- const stateDir = path.join(tmpDir, "state");
1620
- const filePath = path.join(tmpDir, "workflow.lobster");
1621
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1622
- const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
1623
- const first = await runWorkflowFile({
1624
- filePath,
1625
- ctx: {
1626
- stdin: process.stdin,
1627
- stdout: process.stdout,
1628
- stderr: process.stderr,
1629
- env,
1630
- mode: "tool",
1631
- },
1632
- });
1633
- assert.equal(first.status, "needs_input");
1634
- const inputPayload = decodeResumeToken(first.requiresInput?.resumeToken ?? "");
1635
- assert.equal(inputPayload.kind, "workflow-file");
1636
- const second = await runWorkflowFile({
1637
- filePath,
1638
- ctx: {
1639
- stdin: process.stdin,
1640
- stdout: process.stdout,
1641
- stderr: process.stderr,
1642
- env,
1643
- mode: "tool",
1644
- },
1645
- resume: inputPayload,
1646
- response: { decision: "approve" },
1647
- });
1648
- assert.equal(second.status, "needs_approval");
1649
- const approvalPayload = decodeResumeToken(second.requiresApproval?.resumeToken ?? "");
1650
- assert.equal(approvalPayload.kind, "workflow-file");
1651
- const resumed = await runWorkflowFile({
1652
- filePath,
1653
- ctx: {
1654
- stdin: process.stdin,
1655
- stdout: process.stdout,
1656
- stderr: process.stderr,
1657
- env,
1658
- mode: "tool",
1659
- },
1660
- resume: approvalPayload,
1661
- approved: true,
1662
- });
1663
- assert.equal(resumed.status, "ok");
1664
- assert.deepEqual(resumed.output, [{ ok: true }]);
1665
- });
1666
- test("workflow conditions reject standalone bare identifiers", async () => {
1667
- const workflow = {
1668
- steps: [
1669
- { id: "collect", run: "echo hello" },
1670
- { id: "finish", run: "echo done", condition: "approve" },
1671
- ],
1672
- };
1673
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-invalid-"));
1674
- const filePath = path.join(tmpDir, "workflow.lobster");
1675
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1676
- await assert.rejects(() => runWorkflowFile({
1677
- filePath,
1678
- ctx: {
1679
- stdin: process.stdin,
1680
- stdout: process.stdout,
1681
- stderr: process.stderr,
1682
- env: { ...process.env },
1683
- mode: "tool",
1684
- },
1685
- }), /Unsupported condition: approve/);
1686
- });
1687
- test("parallel wait:any fails promptly when a registered loser ignores cancellation", async () => {
1688
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-unsettled-"));
1689
- const filePath = path.join(tmpDir, "workflow.lobster");
1690
- const registry = {
1691
- get(name) {
1692
- if (name === "winner") {
1693
- return {
1694
- name,
1695
- help: () => "winner",
1696
- async run() {
1697
- return { output: [{ winner: true }] };
1698
- },
1699
- };
1700
- }
1701
- if (name === "never") {
1702
- return {
1703
- name,
1704
- help: () => "never",
1705
- async run() {
1706
- await new Promise(() => { });
1707
- },
1708
- };
1709
- }
1710
- return undefined;
1711
- },
1712
- };
1713
- try {
1714
- await fsp.writeFile(filePath, JSON.stringify({
1715
- steps: [
1716
- {
1717
- id: "parallel",
1718
- parallel: {
1719
- wait: "any",
1720
- branches: [
1721
- { id: "winner", pipeline: "winner" },
1722
- { id: "never", pipeline: "never" },
1723
- ],
1724
- },
1725
- },
1726
- ],
1727
- }), "utf8");
1728
- await assert.rejects(Promise.race([
1729
- runWorkflowFile({
1730
- filePath,
1731
- ctx: {
1732
- stdin: process.stdin,
1733
- stdout: process.stdout,
1734
- stderr: process.stderr,
1735
- env: { ...process.env },
1736
- mode: "tool",
1737
- registry,
1738
- },
1739
- }),
1740
- new Promise((_resolve, reject) => setTimeout(() => reject(new Error("workflow did not finish")), 1_000)),
1741
- ]), /Parallel branches did not settle after cancellation/);
1742
- }
1743
- finally {
1744
- await fsp.rm(tmpDir, { recursive: true, force: true });
1745
- }
1746
- });
1747
- test("workflow conditions reject unknown step refs even under negation", async () => {
1748
- const workflow = {
1749
- steps: [
1750
- { id: "collect", run: "echo hello" },
1751
- { id: "finish", run: "echo done", condition: "!$aprove.approved" },
1752
- ],
1753
- };
1754
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-typo-"));
1755
- const filePath = path.join(tmpDir, "workflow.lobster");
1756
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1757
- await assert.rejects(() => runWorkflowFile({
1758
- filePath,
1759
- ctx: {
1760
- stdin: process.stdin,
1761
- stdout: process.stdout,
1762
- stderr: process.stderr,
1763
- env: { ...process.env },
1764
- mode: "tool",
1765
- },
1766
- }), /Unknown step reference: aprove\.approved/);
1767
- });
1768
- test("workflow conditions compare object refs without key-order sensitivity", async () => {
1769
- const workflow = {
1770
- steps: [
1771
- {
1772
- id: "left",
1773
- run: 'node -e "process.stdout.write(JSON.stringify({a:1,b:2}))"',
1774
- },
1775
- {
1776
- id: "right",
1777
- run: 'node -e "process.stdout.write(JSON.stringify({b:2,a:1}))"',
1778
- },
1779
- {
1780
- id: "finish",
1781
- run: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"',
1782
- condition: "$left.json == $right.json",
1783
- },
1784
- ],
1785
- };
1786
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-condition-object-eq-"));
1787
- const filePath = path.join(tmpDir, "workflow.lobster");
1788
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1789
- const result = await runWorkflowFile({
1790
- filePath,
1791
- ctx: {
1792
- stdin: process.stdin,
1793
- stdout: process.stdout,
1794
- stderr: process.stderr,
1795
- env: { ...process.env },
1796
- mode: "tool",
1797
- },
1798
- });
1799
- assert.equal(result.status, "ok");
1800
- assert.deepEqual(result.output, [{ ok: true }]);
1801
- });
1802
- test("workflow files can mix shell steps, approval-only steps, and pipeline llm steps", async () => {
1803
- const registry = createDefaultRegistry();
1804
- const requests = [];
1805
- const server = http.createServer((req, res) => {
1806
- if (req.method !== "POST" || req.url !== "/invoke") {
1807
- res.writeHead(404);
1808
- res.end("nope");
1809
- return;
1810
- }
1811
- let body = "";
1812
- req.setEncoding("utf8");
1813
- req.on("data", (chunk) => {
1814
- body += chunk;
1815
- });
1816
- req.on("end", () => {
1817
- const parsed = JSON.parse(body || "{}");
1818
- requests.push(parsed);
1819
- res.writeHead(200, { "content-type": "application/json" });
1820
- res.end(JSON.stringify({
1821
- ok: true,
1822
- result: {
1823
- runId: "http_1",
1824
- model: parsed.model || "test-model",
1825
- prompt: parsed.prompt,
1826
- output: {
1827
- format: "json",
1828
- text: '{"recommendation":"no","reason":"warm"}',
1829
- data: { recommendation: "no", reason: "warm" },
1830
- },
1831
- },
1832
- }));
1833
- });
1834
- });
1835
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1836
- const addr = server.address();
1837
- const port = typeof addr === "object" && addr ? addr.port : 0;
1838
- const workflow = {
1839
- name: "mixed-workflow",
1840
- steps: [
1841
- {
1842
- id: "fetch",
1843
- run: "node -e \"process.stdout.write(JSON.stringify({location:'Phoenix',temp_f:73.8,humidity_pct:13,wind_mph:3.4}))\"",
1844
- },
1845
- {
1846
- id: "confirm",
1847
- approval: "Want jacket advice from the LLM?",
1848
- stdin: "$fetch.json",
1849
- },
1850
- {
1851
- id: "advice",
1852
- pipeline: 'llm.invoke --provider http --prompt "Given this weather data, should I wear a jacket? Return JSON." --disable-cache',
1853
- stdin: "$fetch.json",
1854
- when: "$confirm.approved",
1855
- },
1856
- ],
1857
- };
1858
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-mixed-"));
1859
- const stateDir = path.join(tmpDir, "state");
1860
- const cacheDir = path.join(tmpDir, "cache");
1861
- const filePath = path.join(tmpDir, "workflow.lobster");
1862
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1863
- const env = {
1864
- ...process.env,
1865
- LOBSTER_STATE_DIR: stateDir,
1866
- LOBSTER_CACHE_DIR: cacheDir,
1867
- LOBSTER_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
1868
- };
1869
- try {
1870
- const first = await runWorkflowFile({
1871
- filePath,
1872
- ctx: {
1873
- stdin: process.stdin,
1874
- stdout: process.stdout,
1875
- stderr: process.stderr,
1876
- env,
1877
- mode: "tool",
1878
- registry,
1879
- },
1880
- });
1881
- assert.equal(first.status, "needs_approval");
1882
- assert.equal(first.requiresApproval?.prompt, "Want jacket advice from the LLM?");
1883
- assert.match(first.requiresApproval?.preview ?? "", /Phoenix/);
1884
- assert.ok(first.requiresApproval?.resumeToken);
1885
- const payload = decodeResumeToken(first.requiresApproval?.resumeToken ?? "");
1886
- assert.equal(payload.kind, "workflow-file");
1887
- const resumed = await runWorkflowFile({
1888
- filePath,
1889
- ctx: {
1890
- stdin: process.stdin,
1891
- stdout: process.stdout,
1892
- stderr: process.stderr,
1893
- env,
1894
- mode: "tool",
1895
- registry,
1896
- },
1897
- resume: payload,
1898
- approved: true,
1899
- });
1900
- assert.equal(resumed.status, "ok");
1901
- assert.equal(resumed.output.length, 1);
1902
- assert.equal(resumed.output[0].kind, "llm.invoke");
1903
- assert.equal(resumed.output[0].output.data.recommendation, "no");
1904
- assert.equal(requests.length, 1);
1905
- assert.equal(requests[0].artifacts[0].location, "Phoenix");
1906
- }
1907
- finally {
1908
- await closeServer(server);
1909
- }
1910
- });
1911
- test("workflow pipeline llm_task.invoke consumes stdin artifacts from previous step", async () => {
1912
- const registry = createDefaultRegistry();
1913
- const requests = [];
1914
- const server = http.createServer((req, res) => {
1915
- if (req.method !== "POST" || req.url !== "/tools/invoke") {
1916
- res.writeHead(404);
1917
- res.end("nope");
1918
- return;
1919
- }
1920
- let body = "";
1921
- req.setEncoding("utf8");
1922
- req.on("data", (chunk) => {
1923
- body += chunk;
1924
- });
1925
- req.on("end", () => {
1926
- const parsed = JSON.parse(body || "{}");
1927
- requests.push(parsed);
1928
- const text = String(parsed?.args?.artifacts?.[0]?.text ?? "");
1929
- const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
1930
- res.writeHead(200, { "content-type": "application/json" });
1931
- res.end(JSON.stringify({
1932
- ok: true,
1933
- result: {
1934
- ok: true,
1935
- result: {
1936
- runId: "task_1",
1937
- model: parsed?.args?.model ?? "test-model",
1938
- prompt: parsed?.args?.prompt,
1939
- output: {
1940
- text: JSON.stringify({ word_count: wordCount }),
1941
- data: { word_count: wordCount },
1942
- format: "json",
1943
- },
1944
- },
1945
- },
1946
- }));
1947
- });
1948
- });
1949
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1950
- const addr = server.address();
1951
- const port = typeof addr === "object" && addr ? addr.port : 0;
1952
- const workflow = {
1953
- name: "word-counter",
1954
- steps: [
1955
- {
1956
- id: "make_words",
1957
- run: 'echo "One two three four five six"',
1958
- },
1959
- {
1960
- id: "count_words",
1961
- pipeline: 'llm_task.invoke --prompt "How many words have been pasted below?" --disable-cache',
1962
- stdin: "$make_words.stdout",
1963
- },
1964
- ],
1965
- };
1966
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-llm-task-stdin-"));
1967
- const stateDir = path.join(tmpDir, "state");
1968
- const cacheDir = path.join(tmpDir, "cache");
1969
- const filePath = path.join(tmpDir, "workflow.lobster");
1970
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
1971
- const env = {
1972
- ...process.env,
1973
- LOBSTER_STATE_DIR: stateDir,
1974
- LOBSTER_CACHE_DIR: cacheDir,
1975
- OPENCLAW_URL: `http://127.0.0.1:${port}`,
1976
- };
1977
- try {
1978
- const result = await runWorkflowFile({
1979
- filePath,
1980
- ctx: {
1981
- stdin: process.stdin,
1982
- stdout: process.stdout,
1983
- stderr: process.stderr,
1984
- env,
1985
- mode: "tool",
1986
- registry,
1987
- },
1988
- });
1989
- assert.equal(result.status, "ok");
1990
- assert.equal(result.output.length, 1);
1991
- assert.equal(result.output[0].kind, "llm_task.invoke");
1992
- assert.equal(result.output[0].output.data.word_count, 6);
1993
- assert.equal(requests.length, 1);
1994
- assert.equal(requests[0].tool, "llm-task");
1995
- assert.equal(requests[0].action, "invoke");
1996
- assert.equal(requests[0].args.prompt, "How many words have been pasted below?");
1997
- assert.match(String(requests[0].args.artifacts?.[0]?.text ?? ""), /One two three four five six/);
1998
- }
1999
- finally {
2000
- await closeServer(server);
2001
- }
2002
- });
2003
- test("workflow pipeline steps respect cwd and feed later shell steps via stdout refs", async () => {
2004
- const registry = createDefaultRegistry();
2005
- const workflow = {
2006
- cwd: "${TARGET_DIR}",
2007
- steps: [
2008
- {
2009
- id: "pwd",
2010
- pipeline: "exec pwd",
2011
- },
2012
- {
2013
- id: "capture",
2014
- run: "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{process.stdout.write(JSON.stringify({pwd:d.trim()}));});\"",
2015
- stdin: "$pwd.stdout",
2016
- },
2017
- ],
2018
- };
2019
- const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-pipeline-cwd-"));
2020
- const targetDir = path.join(tmpDir, "nested");
2021
- const filePath = path.join(tmpDir, "workflow.lobster");
2022
- await fsp.mkdir(targetDir, { recursive: true });
2023
- await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
2024
- const result = await runWorkflowFile({
2025
- filePath,
2026
- args: { TARGET_DIR: targetDir },
2027
- ctx: {
2028
- stdin: process.stdin,
2029
- stdout: process.stdout,
2030
- stderr: process.stderr,
2031
- env: { ...process.env, LOBSTER_STATE_DIR: path.join(tmpDir, "state") },
2032
- mode: "tool",
2033
- registry,
2034
- },
2035
- });
2036
- assert.equal(result.status, "ok");
2037
- const resolvedTargetDir = await fsp.realpath(targetDir);
2038
- assert.deepEqual(result.output, [{ pwd: resolvedTargetDir }]);
2039
- });
2040
- async function closeServer(server) {
2041
- if (!server.listening)
2042
- return;
2043
- await new Promise((resolve) => server.close(() => resolve()));
2044
- }
2045
- //# sourceMappingURL=workflow_file.test.js.map