Compare commits

...

17 Commits

Author SHA1 Message Date
Peter Steinberger
e4514ecad5
style: format with oxfmt 2026-05-04 01:56:15 +01:00
Vincent Koc
0af9ff116f
fix(deps): update vulnerable parser dependencies 2026-04-30 14:58:13 -07:00
Vignesh Natarajan
a95f133c2e fix(workflows): support legacy resume key aliases cleanly (#4) (thanks @brownetw-ai) 2026-04-11 16:42:58 -07:00
Bruce
14e46b388c Fix resume state lookup for workflow key variants 2026-04-11 16:42:58 -07:00
Vignesh Natarajan
595f862d73 docs: document workflow retry feature (#84) (thanks @scottgl9) 2026-04-11 16:40:17 -07:00
scottgl
7d6ecec694 feat: step-level retry with configurable backoff
Adds retry field to workflow steps with exponential/fixed backoff,
jitter, and configurable max attempts. Retry delays are signal-aware
(abort cancels immediately). Abort errors never trigger retries.

- New src/core/retry.ts: withRetry utility with backoff calculation
- Step execution wrapped in retry loop when retry.max > 1
- Retry attempts logged to stderr as [RETRY] messages
- Dry-run renders retry config (attempts, backoff, base delay)
- Comprehensive validation for all retry fields
2026-04-11 16:40:17 -07:00
Vignesh Natarajan
04e01b5027
feat: add approval identity constraints for workflow gates 2026-04-11 16:07:27 -07:00
Vignesh Natarajan
6d397c51c1
test/docs: clarify llm_task workflow stdin usage 2026-04-11 16:02:30 -07:00
Vignesh Natarajan
7d6a22a0c3
feat: add workflow graph visualization command 2026-04-11 15:54:17 -07:00
Vignesh Natarajan
60c976571a
feat: add template filters for template command 2026-04-11 15:44:57 -07:00
Vignesh Natarajan
933fb49f6f
feat: add for_each workflow step type 2026-04-11 15:43:40 -07:00
Vignesh Natarajan
503a2f6053
feat: add parallel workflow step execution 2026-04-11 15:37:35 -07:00
Vignesh Natarajan
373c447e39
feat: add llm cost tracking and spending limits 2026-04-11 15:35:00 -07:00
Vignesh Natarajan
05e34d741f
feat: add comparison operators for workflow conditions 2026-04-11 15:33:39 -07:00
Vignesh Natarajan
425198e09d
test: add on_error workflow coverage 2026-04-11 15:32:52 -07:00
Vignesh Natarajan
47eb7e81b3
feat: add workflow composition and step timeout controls 2026-04-11 15:31:54 -07:00
Peter Steinberger
b9a1b1b2a5
docs: reorder 2026.4.6 release notes 2026-04-06 14:59:55 +01:00
104 changed files with 8739 additions and 3180 deletions

View File

@ -4,16 +4,30 @@ All notable changes to Lobster will be documented in this file.
## Unreleased
- Improve workflow resume compatibility for `stateKey` naming by accepting both `workflow_resume_` and `workflow-resume_` prefixes, including cleanup against the resolved on-disk key. Thanks to [@brownetw-ai](https://github.com/brownetw-ai) (PR [#4](https://github.com/openclaw/lobster/pull/4)).
- Add per-step workflow `retry` policies (`max`, `backoff`, `delay_ms`, `max_delay_ms`, `jitter`) with retry-aware stderr logs and dry-run visibility. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#84](https://github.com/openclaw/lobster/pull/84)).
- Add optional approval identity constraints for workflow gates (`approval.initiated_by`, `approval.required_approver`, `approval.require_different_approver`) with resume-time enforcement via `LOBSTER_APPROVAL_APPROVED_BY` and envelope metadata for integrations. Thanks to [@coolmanns](https://github.com/coolmanns) (Issue [#44](https://github.com/openclaw/lobster/issues/44)).
- Clarify `pipeline:` vs `run:` usage for `llm.invoke` / `llm_task.invoke` in workflow files, and add regression coverage to ensure `stdin: $step.stdout` is forwarded as LLM artifacts for `llm_task.invoke` pipeline steps. Thanks to [@RatkoJ](https://github.com/RatkoJ) (Issue [#41](https://github.com/openclaw/lobster/issues/41)).
- Add `lobster graph` workflow visualization with `mermaid` (default), `dot`, and `ascii` outputs, including step-type nodes, `stdin` data-flow edges, conditional dependency labels (`when`/`condition`), approval-gate diamond shapes, and `--args-json` label resolution support. Thanks to [@vignesh07](https://github.com/vignesh07) (Issue [#53](https://github.com/openclaw/lobster/issues/53)).
- Add workflow composition via `workflow:` + `workflow_args`, including recursive sub-workflow execution, cycle detection, and dry-run visibility for workflow steps. Sub-workflow approval/input halts are rejected with resume-state cleanup. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#73](https://github.com/openclaw/lobster/pull/73)).
- Add per-step `on_error` workflow policies (`stop|continue|skip_rest`) for partial-failure recovery, with structured step error fields (`error`, `errorMessage`) for condition-based branching. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#72](https://github.com/openclaw/lobster/pull/72)).
- Add per-step workflow `timeout_ms` handling, including timeout-triggered aborts, `SIGKILL` for timed shell steps, and dry-run annotations. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#74](https://github.com/openclaw/lobster/pull/74)).
- Add workflow condition comparison operators `<`, `<=`, `>`, and `>=` with strict numeric semantics (booleans/null do not coerce), including mixed boolean-expression support with `&&`/`||`. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#71](https://github.com/openclaw/lobster/pull/71)).
- Add workflow-level LLM cost tracking with `_meta.cost` summaries, per-step usage attribution, and optional `cost_limit` controls with `warn`/`stop` actions (plus custom pricing via `LOBSTER_LLM_PRICING_JSON`). Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#70](https://github.com/openclaw/lobster/pull/70)).
- Add `parallel` workflow steps with branch fan-out, `wait: all|any`, block-level timeout support, and branch result references in downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#69](https://github.com/openclaw/lobster/pull/69)).
- Add `for_each` workflow steps for per-item sub-step execution over arrays, including loop-scoped vars (`item_var`/`index_var`), optional `batch_size` + `pause_ms`, and collected iteration outputs for downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#68](https://github.com/openclaw/lobster/pull/68)).
- Add pipe-based template filters (for example `upper`, `length`, `join`, `default`, `date`) for the `template` command with quote-aware filter parsing and chain evaluation. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#67](https://github.com/openclaw/lobster/pull/67)).
## 2026.4.6
- Add workflow file support for `.lobster`/YAML/JSON files, including args, env, native pipeline steps, and shell-safe `LOBSTER_ARG_*` workflow args.
- Export the embeddable core runtime via `@clawdbot/lobster/core` so Lobster can be loaded in-process by OpenClaw and other hosts.
- Add compact state-backed workflow and pipeline resume tokens, plus hardened approval ID handling and safer resume validation.
- Add structured input pauses with `ask`, workflow `input`, `needs_input`, and `lobster resume --response-json '{...}'`.
- Add workflow file support for `.lobster`, YAML, and JSON, including workflow args/env, native pipeline steps, and shell-safe `LOBSTER_ARG_*` inputs.
- Add structured input pauses with `ask`, workflow `input`, `needs_input`, and `lobster resume --response-json '{...}'` for resumable human-in-the-loop flows.
- Add richer workflow condition expressions with `!`, `==`, `!=`, `&&`, `||`, and parentheses.
- Export the embeddable runtime via `@clawdbot/lobster/core` so Lobster can run in-process inside OpenClaw and other hosts.
- Add generic `llm.invoke` adapters, `openclaw.invoke --each`, and keep `clawd.invoke` as a supported alias.
- Add `exec --stdin raw|json|jsonl`, `approve --preview-from-stdin --limit N`, and extensive dry-run hardening for workflow templates and shell-variable preservation.
- Improve Windows compatibility for CLI startup/build scripts and fix quoted-argument parser edge cases.
- Add compact state-backed workflow/pipeline resume tokens, safer resume validation, and hardened approval ID handling.
- Improve dry-run and shell interoperability with `exec --stdin raw|json|jsonl`, `approve --preview-from-stdin --limit N`, and better template/shell-variable preservation.
- Improve Windows CLI/build compatibility and fix quoted-argument parser edge cases.
## 2026.1.21-1

View File

@ -208,6 +208,38 @@ Notes:
- `pipeline:` shares the same args/env/results model as shell steps, so later steps can still reference `$step.stdout` or `$step.json`.
- If you need a human checkpoint before an LLM call, use a dedicated `approval:` step in the workflow file rather than `approve` inside the nested pipeline.
- `cwd`, `env`, `stdin`, `when`, and `condition` work for both shell and pipeline steps.
- Use `retry`, `timeout_ms`, and `on_error` per step to control transient-failure behavior and recovery.
- Approval steps can optionally enforce identity constraints:
- `approval.required_approver` (or `requiredApprover`) requires an exact approver id.
- `approval.require_different_approver` (or `requireDifferentApprover`) requires approver id to differ from initiator.
- `approval.initiated_by` (or `initiatedBy`) sets the initiator id for comparison.
- `LOBSTER_APPROVAL_INITIATED_BY` can provide a default initiator id at run time.
- `LOBSTER_APPROVAL_APPROVED_BY` is used at resume/approval time for identity checks.
## Visualizing workflows
Use `lobster graph` to inspect workflow structure before execution.
```bash
lobster graph --file path/to/workflow.lobster
lobster graph --file path/to/workflow.lobster --format mermaid
lobster graph --file path/to/workflow.lobster --format dot
lobster graph --file path/to/workflow.lobster --format ascii
lobster graph --file path/to/workflow.lobster --args-json '{"location":"Seattle"}'
```
What gets visualized:
- each workflow step as a node (`run`, `pipeline`, `approval`, etc.)
- data-flow edges from `stdin: $step.stdout` / `$step.json` references
- conditional dependencies from `when:` / `condition:` expressions
- approval gates as diamond-shaped nodes in `mermaid` and `dot` output
Format notes:
- `mermaid` (default): emits `flowchart TD` text for GitHub/Markdown rendering
- `dot`: emits Graphviz DOT syntax
- `ascii`: emits a terminal-friendly node/edge list
## Calling LLMs from workflows
@ -233,6 +265,23 @@ Built-in providers today:
`llm_task.invoke` remains available as a backward-compatible alias for the OpenClaw provider.
### `pipeline:` vs `run:` for LLM calls
- Use `pipeline:` for `llm.invoke` and `llm_task.invoke` (they are Lobster pipeline stages, not shell executables).
- Use `run:` only for real binaries in your shell (for example `openclaw.invoke`).
Example (`stdin` from a prior step is passed to the LLM as artifacts):
```yaml
steps:
- id: make_words
run: echo "One two three four five six"
- id: count_words
pipeline: llm_task.invoke --prompt "How many words have been pasted below?"
stdin: $make_words.stdout
```
## Calling OpenClaw tools from workflows
Shell `run:` steps execute in your system shell, so OpenClaw tool calls there must be real executables.

View File

@ -2,11 +2,28 @@
"name": "@clawdbot/lobster",
"version": "2026.4.6",
"description": "Workflow runtime for AI agents - deterministic pipelines with approval gates",
"type": "module",
"keywords": [
"ai-agent",
"approval",
"automation",
"lobster",
"openclaw",
"pipeline",
"workflow"
],
"homepage": "https://github.com/openclaw/lobster#readme",
"bugs": {
"url": "https://github.com/openclaw/lobster/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/openclaw/lobster.git"
},
"bin": {
"clawd.invoke": "bin/clawd.invoke.js",
"lobster": "bin/lobster.js",
"openclaw.invoke": "bin/openclaw.invoke.js",
"clawd.invoke": "bin/clawd.invoke.js"
"openclaw.invoke": "bin/openclaw.invoke.js"
},
"files": [
"bin",
@ -15,50 +32,38 @@
"LICENSE",
"VISION.md"
],
"type": "module",
"main": "./dist/src/sdk/index.js",
"exports": {
".": "./dist/src/sdk/index.js",
"./sdk": "./dist/src/sdk/index.js",
"./core": "./dist/src/core/index.js",
"./recipes/github": "./dist/src/recipes/github/index.js"
},
"main": "./dist/src/sdk/index.js",
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "pnpm clean && pnpm exec tsc -p tsconfig.json",
"build": "pnpm clean && tsgo -p tsconfig.json",
"prepack": "pnpm build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "oxlint --tsconfig tsconfig.json src test",
"fmt": "oxlint --tsconfig tsconfig.json --fix src test",
"typecheck": "tsgo -p tsconfig.json --noEmit",
"format": "oxfmt --write package.json tsconfig.json src test",
"format:check": "oxfmt --check package.json tsconfig.json src test",
"lint": "pnpm format:check && oxlint --tsconfig tsconfig.json src test",
"fmt": "pnpm format",
"test": "pnpm build && node --test dist/test/*.test.js"
},
"dependencies": {
"ajv": "^8.20.0",
"yaml": "^2.8.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"oxlint": "^0.15.0",
"typescript": "^5.7.0"
"@types/node": "^25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260503.1",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=20"
},
"keywords": [
"workflow",
"automation",
"ai-agent",
"approval",
"pipeline",
"lobster",
"openclaw"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openclaw/lobster.git"
},
"bugs": {
"url": "https://github.com/openclaw/lobster/issues"
},
"homepage": "https://github.com/openclaw/lobster#readme",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
"yaml": "^2.8.2"
}
}

618
pnpm-lock.yaml generated
View File

@ -9,69 +9,359 @@ importers:
.:
dependencies:
ajv:
specifier: ^8.17.1
version: 8.17.1
specifier: ^8.20.0
version: 8.20.0
yaml:
specifier: ^2.8.2
version: 2.8.2
specifier: ^2.8.4
version: 2.8.4
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
specifier: ^25.6.0
version: 25.6.0
'@typescript/native-preview':
specifier: 7.0.0-dev.20260503.1
version: 7.0.0-dev.20260503.1
oxfmt:
specifier: ^0.47.0
version: 0.47.0
oxlint:
specifier: ^0.15.0
version: 0.15.15
specifier: ^1.62.0
version: 1.62.0(oxlint-tsgolint@0.22.1)
oxlint-tsgolint:
specifier: ^0.22.1
version: 0.22.1
typescript:
specifier: ^5.7.0
version: 5.9.3
specifier: ^6.0.3
version: 6.0.3
packages:
'@oxlint/darwin-arm64@0.15.15':
resolution: {integrity: sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg==}
'@oxfmt/binding-android-arm-eabi@0.47.0':
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.47.0':
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.47.0':
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/darwin-x64@0.15.15':
resolution: {integrity: sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow==}
'@oxfmt/binding-darwin-x64@0.47.0':
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/linux-arm64-gnu@0.15.15':
resolution: {integrity: sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA==}
'@oxfmt/binding-freebsd-x64@0.47.0':
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/linux-arm64-musl@0.15.15':
resolution: {integrity: sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA==}
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/linux-x64-gnu@0.15.15':
resolution: {integrity: sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng==}
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/linux-x64-musl@0.15.15':
resolution: {integrity: sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ==}
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/win32-arm64@0.15.15':
resolution: {integrity: sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg==}
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/win32-x64@0.15.15':
resolution: {integrity: sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg==}
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.47.0':
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@oxlint-tsgolint/darwin-arm64@0.22.1':
resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==}
cpu: [arm64]
os: [darwin]
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
'@oxlint-tsgolint/darwin-x64@0.22.1':
resolution: {integrity: sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.22.1':
resolution: {integrity: sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.22.1':
resolution: {integrity: sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.22.1':
resolution: {integrity: sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.22.1':
resolution: {integrity: sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.62.0':
resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.62.0':
resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.62.0':
resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.62.0':
resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.62.0':
resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.62.0':
resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.62.0':
resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.62.0':
resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.62.0':
resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-rUZQVuBcZlxADagx+pnhDHqjX2Ewh+KWave6vtilRWR5vsGyR3FaCzPFqXteiwPg6XRijEMnMaXg60t5itm8EA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-YtK8ac8RCkRh7jwoP8Wt4LaBhpGK8cOVMi3e0cvF/8Xn5XV1ewJK3/HdNBnlDhCib3j0eVRxK/Pg9GIq/hFUxQ==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-c5yM3Ea2WZSLKmscMxUhEDdaR2Ugp7P9CX6osciIPgZIF9c4LDiuKQohjQAyidx9JZKCsSXnfS88QssWH/+ONQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-28vAomYeU8hpz1f0dDoWz6PYehz0ffEfkJhFq4tqXzUM/QY1I15u5y03EJQ5UcP4LRwrdJe22J5LdrYpM2Lr3w==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-M64z7LwpqNfOXYCBKmD/ObwyxYOobUk4tDv0ECNLit7pDER1sswNZjJGjgRYjQsKokmydy6p3FqtJ1uUPUP/sw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-QHy3R1VBb8MYszjpz0IyuDjKaW6JUHg8hYEqzhZIxvrydKLF3gyDeKohnmWSFTS/oII0GvUmYM3h83fbanBvcQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-nrapqpVONrg0IZjKp3AzV9M+jxPwKGTQZEOxuKh6WHMhy/UElN8RnOFlfetA8ZMtKvPkmjgjqw0qoAa5QMwhPA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260503.1':
resolution: {integrity: sha512-gDro38CPFiBUGbaFGNt+ufOsEd1OrZrfrOPxsLSfBcvvoGaqAxV++ul/BHTOShoEkIYHiFsoDX2az1IPCDV2jQ==}
engines: {node: '>=16.20.0'}
hasBin: true
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -82,59 +372,216 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
oxlint@0.15.15:
resolution: {integrity: sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA==}
engines: {node: '>=8.*'}
oxfmt@0.47.0:
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.22.1:
resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==}
hasBin: true
oxlint@1.62.0:
resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots:
'@oxlint/darwin-arm64@0.15.15':
'@oxfmt/binding-android-arm-eabi@0.47.0':
optional: true
'@oxlint/darwin-x64@0.15.15':
'@oxfmt/binding-android-arm64@0.47.0':
optional: true
'@oxlint/linux-arm64-gnu@0.15.15':
'@oxfmt/binding-darwin-arm64@0.47.0':
optional: true
'@oxlint/linux-arm64-musl@0.15.15':
'@oxfmt/binding-darwin-x64@0.47.0':
optional: true
'@oxlint/linux-x64-gnu@0.15.15':
'@oxfmt/binding-freebsd-x64@0.47.0':
optional: true
'@oxlint/linux-x64-musl@0.15.15':
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
optional: true
'@oxlint/win32-arm64@0.15.15':
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
optional: true
'@oxlint/win32-x64@0.15.15':
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
optional: true
'@types/node@22.19.7':
'@oxfmt/binding-linux-arm64-musl@0.47.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.47.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.47.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.47.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.47.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/darwin-x64@0.22.1':
optional: true
'@oxlint-tsgolint/linux-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/linux-x64@0.22.1':
optional: true
'@oxlint-tsgolint/win32-arm64@0.22.1':
optional: true
'@oxlint-tsgolint/win32-x64@0.22.1':
optional: true
'@oxlint/binding-android-arm-eabi@1.62.0':
optional: true
'@oxlint/binding-android-arm64@1.62.0':
optional: true
'@oxlint/binding-darwin-arm64@1.62.0':
optional: true
'@oxlint/binding-darwin-x64@1.62.0':
optional: true
'@oxlint/binding-freebsd-x64@1.62.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.62.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.62.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.62.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.62.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.62.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.62.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.62.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.62.0':
optional: true
'@types/node@25.6.0':
dependencies:
undici-types: 6.21.0
undici-types: 7.19.2
ajv@8.17.1:
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20260503.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260503.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260503.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260503.1
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
@ -147,21 +594,68 @@ snapshots:
json-schema-traverse@1.0.0: {}
oxlint@0.15.15:
oxfmt@0.47.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxlint/darwin-arm64': 0.15.15
'@oxlint/darwin-x64': 0.15.15
'@oxlint/linux-arm64-gnu': 0.15.15
'@oxlint/linux-arm64-musl': 0.15.15
'@oxlint/linux-x64-gnu': 0.15.15
'@oxlint/linux-x64-musl': 0.15.15
'@oxlint/win32-arm64': 0.15.15
'@oxlint/win32-x64': 0.15.15
'@oxfmt/binding-android-arm-eabi': 0.47.0
'@oxfmt/binding-android-arm64': 0.47.0
'@oxfmt/binding-darwin-arm64': 0.47.0
'@oxfmt/binding-darwin-x64': 0.47.0
'@oxfmt/binding-freebsd-x64': 0.47.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
'@oxfmt/binding-linux-arm64-musl': 0.47.0
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
'@oxfmt/binding-linux-x64-gnu': 0.47.0
'@oxfmt/binding-linux-x64-musl': 0.47.0
'@oxfmt/binding-openharmony-arm64': 0.47.0
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
'@oxfmt/binding-win32-x64-msvc': 0.47.0
oxlint-tsgolint@0.22.1:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.22.1
'@oxlint-tsgolint/darwin-x64': 0.22.1
'@oxlint-tsgolint/linux-arm64': 0.22.1
'@oxlint-tsgolint/linux-x64': 0.22.1
'@oxlint-tsgolint/win32-arm64': 0.22.1
'@oxlint-tsgolint/win32-x64': 0.22.1
oxlint@1.62.0(oxlint-tsgolint@0.22.1):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.62.0
'@oxlint/binding-android-arm64': 1.62.0
'@oxlint/binding-darwin-arm64': 1.62.0
'@oxlint/binding-darwin-x64': 1.62.0
'@oxlint/binding-freebsd-x64': 1.62.0
'@oxlint/binding-linux-arm-gnueabihf': 1.62.0
'@oxlint/binding-linux-arm-musleabihf': 1.62.0
'@oxlint/binding-linux-arm64-gnu': 1.62.0
'@oxlint/binding-linux-arm64-musl': 1.62.0
'@oxlint/binding-linux-ppc64-gnu': 1.62.0
'@oxlint/binding-linux-riscv64-gnu': 1.62.0
'@oxlint/binding-linux-riscv64-musl': 1.62.0
'@oxlint/binding-linux-s390x-gnu': 1.62.0
'@oxlint/binding-linux-x64-gnu': 1.62.0
'@oxlint/binding-linux-x64-musl': 1.62.0
'@oxlint/binding-openharmony-arm64': 1.62.0
'@oxlint/binding-win32-arm64-msvc': 1.62.0
'@oxlint/binding-win32-ia32-msvc': 1.62.0
'@oxlint/binding-win32-x64-msvc': 1.62.0
oxlint-tsgolint: 0.22.1
require-from-string@2.0.2: {}
typescript@5.9.3: {}
tinypool@2.1.0: {}
undici-types@6.21.0: {}
typescript@6.0.3: {}
yaml@2.8.2: {}
undici-types@7.19.2: {}
yaml@2.8.4: {}

View File

@ -1,25 +1,32 @@
import { parsePipeline } from './parser.js';
import { createDefaultRegistry } from './commands/registry.js';
import { runPipeline } from './runtime.js';
import { decodeResumeToken, parseResumeArgs, resolveApprovalId } from './resume.js';
import { cleanupApprovalIndexByStateKey, deleteApprovalId } from './state/store.js';
import { WorkflowResumeArgumentError, runWorkflowFile } from './workflows/file.js';
import { deleteStateJson } from './state/store.js';
import { parsePipeline } from "./parser.js";
import { createDefaultRegistry } from "./commands/registry.js";
import { runPipeline } from "./runtime.js";
import { decodeResumeToken, parseResumeArgs, resolveApprovalId } from "./resume.js";
import { cleanupApprovalIndexByStateKey, deleteApprovalId } from "./state/store.js";
import {
WorkflowResumeArgumentError,
loadWorkflowFile,
resolveWorkflowArgs,
runWorkflowFile,
} from "./workflows/file.js";
import { renderWorkflowGraph } from "./workflows/graph.js";
import type { WorkflowGraphFormat } from "./workflows/graph.js";
import { deleteStateJson } from "./state/store.js";
import {
finalizePipelineToolRun,
loadPipelineResumeState,
validatePipelineInputResponse,
} from './pipeline_resume_state.js';
} from "./pipeline_resume_state.js";
export async function runCli(argv) {
const registry = createDefaultRegistry();
if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
process.stdout.write(helpText());
return;
}
if (argv[0] === 'help') {
if (argv[0] === "help") {
const topic = argv[1];
if (!topic) {
process.stdout.write(helpText());
@ -35,22 +42,27 @@ export async function runCli(argv) {
return;
}
if (argv[0] === 'version' || argv[0] === '--version' || argv[0] === '-v') {
if (argv[0] === "version" || argv[0] === "--version" || argv[0] === "-v") {
process.stdout.write(`${await readVersion()}\n`);
return;
}
if (argv[0] === 'doctor') {
if (argv[0] === "doctor") {
await handleDoctor({ argv: argv.slice(1), registry });
return;
}
if (argv[0] === 'run') {
if (argv[0] === "graph") {
await handleGraph({ argv: argv.slice(1) });
return;
}
if (argv[0] === "run") {
await handleRun({ argv: argv.slice(1), registry });
return;
}
if (argv[0] === 'resume') {
if (argv[0] === "resume") {
await handleResume({ argv: argv.slice(1), registry });
return;
}
@ -59,6 +71,67 @@ export async function runCli(argv) {
await handleRun({ argv, registry });
}
async function handleGraph({ argv }) {
const parsed = parseGraphArgs(argv);
if (parsed.help) {
process.stdout.write(graphHelpText());
return;
}
if (!parsed.filePath) {
process.stderr.write("graph requires a workflow file path (use --file <path>)\n");
process.exitCode = 2;
return;
}
if (!isWorkflowGraphFormat(parsed.format)) {
process.stderr.write("graph --format must be one of: mermaid, dot, ascii\n");
process.exitCode = 2;
return;
}
let argsJson: Record<string, unknown> = {};
if (parsed.argsJson) {
try {
const value = JSON.parse(parsed.argsJson);
if (!value || typeof value !== "object" || Array.isArray(value)) {
process.stderr.write("graph --args-json must be a JSON object\n");
process.exitCode = 2;
return;
}
argsJson = value as Record<string, unknown>;
} catch {
process.stderr.write("graph --args-json must be valid JSON\n");
process.exitCode = 2;
return;
}
}
let filePath: string;
try {
filePath = await resolveWorkflowFile(parsed.filePath);
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
return;
}
try {
const workflow = await loadWorkflowFile(filePath);
const args = resolveWorkflowArgs(workflow.args, argsJson);
const graph = renderWorkflowGraph({ workflow, format: parsed.format, args });
process.stdout.write(graph);
process.stdout.write("\n");
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
}
}
function isWorkflowGraphFormat(value: string): value is WorkflowGraphFormat {
return value === "mermaid" || value === "dot" || value === "ascii";
}
async function handleRun({ argv, registry }) {
const parsed = parseRunArgs(argv);
const { mode, argsJson } = parsed;
@ -74,12 +147,15 @@ async function handleRun({ argv, registry }) {
try {
parsedArgs = JSON.parse(argsJson);
} catch {
if (mode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: 'run --args-json must be valid JSON' } });
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: "run --args-json must be valid JSON" },
});
process.exitCode = 2;
return;
}
process.stderr.write('run --args-json must be valid JSON\n');
process.stderr.write("run --args-json must be valid JSON\n");
process.exitCode = 2;
return;
}
@ -100,11 +176,11 @@ async function handleRun({ argv, registry }) {
},
});
if (normalizedMode === 'tool') {
if (output.status === 'needs_approval') {
if (normalizedMode === "tool") {
if (output.status === "needs_approval") {
writeToolEnvelope({
ok: true,
status: 'needs_approval',
status: "needs_approval",
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: null,
@ -112,10 +188,10 @@ async function handleRun({ argv, registry }) {
return;
}
if (output.status === 'needs_input') {
if (output.status === "needs_input") {
writeToolEnvelope({
ok: true,
status: 'needs_input',
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: output.requiresInput ?? null,
@ -125,7 +201,7 @@ async function handleRun({ argv, registry }) {
writeToolEnvelope({
ok: true,
status: 'ok',
status: "ok",
output: output.output,
requiresApproval: null,
requiresInput: null,
@ -133,25 +209,34 @@ async function handleRun({ argv, registry }) {
return;
}
if (output.status === 'needs_approval' || output.status === 'needs_input') {
process.stdout.write(JSON.stringify({
status: output.status,
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: output.requiresInput ?? null,
}, null, 2));
process.stdout.write('\n');
if (output.status === "needs_approval" || output.status === "needs_input") {
process.stdout.write(
JSON.stringify(
{
status: output.status,
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: output.requiresInput ?? null,
},
null,
2,
),
);
process.stdout.write("\n");
return;
}
if (output.status === 'ok' && output.output.length) {
if (output.status === "ok" && output.output.length) {
process.stdout.write(JSON.stringify(output.output, null, 2));
process.stdout.write('\n');
process.stdout.write("\n");
}
return;
} catch (err) {
if (normalizedMode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
@ -161,14 +246,17 @@ async function handleRun({ argv, registry }) {
}
}
const pipelineString = rest.join(' ');
const pipelineString = rest.join(" ");
let pipeline;
try {
pipeline = parsePipeline(pipelineString);
} catch (err) {
if (mode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err?.message ?? String(err) } });
if (mode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
@ -190,7 +278,7 @@ async function handleRun({ argv, registry }) {
dryRun,
});
if (normalizedMode === 'tool') {
if (normalizedMode === "tool") {
const finalized = await finalizePipelineToolRun({
env: process.env,
pipeline,
@ -209,11 +297,14 @@ async function handleRun({ argv, registry }) {
// Human mode: if the last command didn't render, print JSON.
if (!output.rendered) {
process.stdout.write(JSON.stringify(output.items, null, 2));
process.stdout.write('\n');
process.stdout.write("\n");
}
} catch (err) {
if (normalizedMode === 'tool') {
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
if (normalizedMode === "tool") {
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
@ -224,7 +315,7 @@ async function handleRun({ argv, registry }) {
function parseRunArgs(argv) {
const rest = [];
let mode = 'human';
let mode = "human";
let filePath = null;
let argsJson = null;
let dryRun = false;
@ -236,12 +327,12 @@ function parseRunArgs(argv) {
// args begin. Once rest has started, the token may belong to the command.
// Trailing workflow-file --dry-run is handled later after we can prove the
// first positional token is actually a workflow file.
if (tok === '--dry-run' && rest.length === 0) {
if (tok === "--dry-run" && rest.length === 0) {
dryRun = true;
continue;
}
if (tok === '--mode') {
if (tok === "--mode") {
const value = argv[i + 1];
if (value) {
mode = value;
@ -250,12 +341,12 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith('--mode=')) {
mode = tok.slice('--mode='.length) || 'human';
if (tok.startsWith("--mode=")) {
mode = tok.slice("--mode=".length) || "human";
continue;
}
if (tok === '--file') {
if (tok === "--file") {
const value = argv[i + 1];
if (value) {
filePath = value;
@ -264,12 +355,12 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith('--file=')) {
filePath = tok.slice('--file='.length);
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
continue;
}
if (tok === '--args-json') {
if (tok === "--args-json") {
const value = argv[i + 1];
if (value) {
argsJson = value;
@ -278,8 +369,8 @@ function parseRunArgs(argv) {
continue;
}
if (tok.startsWith('--args-json=')) {
argsJson = tok.slice('--args-json='.length);
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
continue;
}
@ -289,13 +380,79 @@ function parseRunArgs(argv) {
return { mode, rest, filePath, argsJson, dryRun };
}
function parseGraphArgs(argv: string[]) {
const rest: string[] = [];
let filePath: string | null = null;
let format = "mermaid";
let argsJson: string | null = null;
let help = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === "-h" || tok === "--help") {
help = true;
continue;
}
if (tok === "--file") {
const value = argv[i + 1];
if (value) {
filePath = value;
i++;
}
continue;
}
if (tok.startsWith("--file=")) {
filePath = tok.slice("--file=".length);
continue;
}
if (tok === "--format") {
const value = argv[i + 1];
if (value) {
format = value;
i++;
}
continue;
}
if (tok.startsWith("--format=")) {
format = tok.slice("--format=".length) || "mermaid";
continue;
}
if (tok === "--args-json") {
const value = argv[i + 1];
if (value) {
argsJson = value;
i++;
}
continue;
}
if (tok.startsWith("--args-json=")) {
argsJson = tok.slice("--args-json=".length);
continue;
}
rest.push(tok);
}
if (!filePath && rest.length > 0) {
filePath = rest[0];
}
return { filePath, format, argsJson, help };
}
async function resolveRunTarget(parsed: {
rest: string[];
filePath: string | null;
dryRun: boolean;
}) {
if (parsed.filePath) return parsed;
const restWithoutDryRun = parsed.rest.filter((token) => token !== '--dry-run');
const restWithoutDryRun = parsed.rest.filter((token) => token !== "--dry-run");
if (restWithoutDryRun.length === 1 && restWithoutDryRun.length !== parsed.rest.length) {
try {
const workflowFile = await resolveWorkflowFile(restWithoutDryRun[0]);
@ -308,13 +465,13 @@ async function resolveRunTarget(parsed: {
}
function normalizeMode(mode) {
return mode === 'tool' ? 'tool' : 'human';
return mode === "tool" ? "tool" : "human";
}
async function detectWorkflowFile(rest) {
if (rest.length !== 1) return null;
const candidate = rest[0];
if (!candidate || candidate.includes('|')) return null;
if (!candidate || candidate.includes("|")) return null;
try {
return await resolveWorkflowFile(candidate);
} catch {
@ -323,22 +480,22 @@ async function detectWorkflowFile(rest) {
}
async function resolveWorkflowFile(candidate) {
const { promises: fsp } = await import('node:fs');
const { resolve, extname, isAbsolute } = await import('node:path');
const { promises: fsp } = await import("node:fs");
const { resolve, extname, isAbsolute } = await import("node:path");
const resolved = isAbsolute(candidate) ? candidate : resolve(process.cwd(), candidate);
const stat = await fsp.stat(resolved);
if (!stat.isFile()) throw new Error('Workflow path is not a file');
if (!stat.isFile()) throw new Error("Workflow path is not a file");
const ext = extname(resolved).toLowerCase();
if (!['.lobster', '.yaml', '.yml', '.json'].includes(ext)) {
throw new Error('Workflow file must end in .lobster, .yaml, .yml, or .json');
if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
}
return resolved;
}
async function handleResume({ argv, registry }) {
const mode = 'tool';
const mode = "tool";
let approved: boolean | undefined;
let response: unknown = undefined;
let cancel = false;
@ -360,7 +517,10 @@ async function handleResume({ argv, registry }) {
}
payload = decodeResumeToken(token);
} catch (err) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err?.message ?? String(err) } });
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
@ -376,17 +536,23 @@ async function handleResume({ argv, registry }) {
if (cancel === true) {
await cleanupIndex();
if (payload.kind === 'workflow-file' && payload.stateKey) {
if (payload.kind === "workflow-file" && payload.stateKey) {
await deleteStateJson({ env: process.env, key: payload.stateKey });
}
if (payload.kind === 'pipeline-resume' && payload.stateKey) {
if (payload.kind === "pipeline-resume" && payload.stateKey) {
await deleteStateJson({ env: process.env, key: payload.stateKey });
}
writeToolEnvelope({ ok: true, status: 'cancelled', output: [], requiresApproval: null, requiresInput: null });
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
if (payload.kind === 'workflow-file') {
if (payload.kind === "workflow-file") {
try {
const output = await runWorkflowFile({
filePath: payload.filePath,
@ -395,7 +561,7 @@ async function handleResume({ argv, registry }) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
mode: "tool",
registry,
},
resume: payload,
@ -404,10 +570,10 @@ async function handleResume({ argv, registry }) {
cancel,
});
if (output.status === 'needs_approval') {
if (output.status === "needs_approval") {
writeToolEnvelope({
ok: true,
status: 'needs_approval',
status: "needs_approval",
output: [],
requiresApproval: output.requiresApproval ?? null,
requiresInput: null,
@ -415,10 +581,10 @@ async function handleResume({ argv, registry }) {
return;
}
if (output.status === 'needs_input') {
if (output.status === "needs_input") {
writeToolEnvelope({
ok: true,
status: 'needs_input',
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: output.requiresInput ?? null,
@ -427,20 +593,35 @@ async function handleResume({ argv, registry }) {
}
await cleanupIndex();
if (output.status === 'cancelled') {
writeToolEnvelope({ ok: true, status: 'cancelled', output: [], requiresApproval: null, requiresInput: null });
if (output.status === "cancelled") {
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
writeToolEnvelope({ ok: true, status: 'ok', output: output.output, requiresApproval: null, requiresInput: null });
writeToolEnvelope({
ok: true,
status: "ok",
output: output.output,
requiresApproval: null,
requiresInput: null,
});
return;
} catch (err) {
if (err instanceof WorkflowResumeArgumentError) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err.message } });
writeToolEnvelope({ ok: false, error: { type: "parse_error", message: err.message } });
process.exitCode = 2;
return;
}
// Don't clean up index on error — allow retry by --id
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
@ -450,44 +631,76 @@ async function handleResume({ argv, registry }) {
try {
resumeState = await loadPipelineResumeState(process.env, previousStateKey);
} catch (err) {
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
return;
}
if (resumeState.haltType === 'input_request') {
if (resumeState.haltType === "input_request") {
if (approved !== undefined) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: 'pipeline input resumes require --response-json <json>' } });
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "pipeline input resumes require --response-json <json>",
},
});
process.exitCode = 2;
return;
}
if (response === undefined) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: 'pipeline input resumes require --response-json <json>' } });
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "pipeline input resumes require --response-json <json>",
},
});
process.exitCode = 2;
return;
}
try {
validatePipelineInputResponse(resumeState.inputSchema, response);
} catch (err) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: err?.message ?? String(err) } });
writeToolEnvelope({
ok: false,
error: { type: "parse_error", message: err?.message ?? String(err) },
});
process.exitCode = 2;
return;
}
} else {
if (response !== undefined) {
writeToolEnvelope({ ok: false, error: { type: 'parse_error', message: 'approval resumes require --approve yes|no, not --response-json' } });
writeToolEnvelope({
ok: false,
error: {
type: "parse_error",
message: "approval resumes require --approve yes|no, not --response-json",
},
});
process.exitCode = 2;
return;
}
if (approved !== true) {
await cleanupIndex();
await deleteStateJson({ env: process.env, key: previousStateKey });
writeToolEnvelope({ ok: true, status: 'cancelled', output: [], requiresApproval: null, requiresInput: null });
writeToolEnvelope({
ok: true,
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
});
return;
}
}
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(resumeState.haltType === 'input_request' ? [response] : resumeState.items);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
try {
const output = await runPipeline({
@ -516,7 +729,10 @@ async function handleResume({ argv, registry }) {
});
} catch (err) {
// Don't clean up index on error — allow retry by --id
writeToolEnvelope({ ok: false, error: { type: 'runtime_error', message: err?.message ?? String(err) } });
writeToolEnvelope({
ok: false,
error: { type: "runtime_error", message: err?.message ?? String(err) },
});
process.exitCode = 1;
}
}
@ -528,18 +744,18 @@ function streamFromItems(items: unknown[]) {
}
async function readVersion() {
const { readFile } = await import('node:fs/promises');
const { fileURLToPath } = await import('node:url');
const { dirname, join } = await import('node:path');
const { readFile } = await import("node:fs/promises");
const { fileURLToPath } = await import("node:url");
const { dirname, join } = await import("node:path");
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(here, '..', '..', 'package.json');
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
return pkg.version ?? '0.0.0';
const pkgPath = join(here, "..", "..", "package.json");
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
return pkg.version ?? "0.0.0";
}
async function handleDoctor({ argv, registry }) {
const mode = 'tool';
const mode = "tool";
const pipeline = "exec --json --shell 'echo [1]'";
const output: any = await (async () => {
try {
@ -562,7 +778,7 @@ async function handleDoctor({ argv, registry }) {
if (output?.error) {
writeToolEnvelope({
ok: false,
error: { type: 'doctor_error', message: output.error?.message ?? String(output.error) },
error: { type: "doctor_error", message: output.error?.message ?? String(output.error) },
});
process.exitCode = 1;
return;
@ -570,13 +786,15 @@ async function handleDoctor({ argv, registry }) {
writeToolEnvelope({
ok: true,
status: 'ok',
output: [{
toolMode: true,
protocolVersion: 1,
version: await readVersion(),
notes: argv.length ? argv : undefined,
}],
status: "ok",
output: [
{
toolMode: true,
protocolVersion: 1,
version: await readVersion(),
notes: argv.length ? argv : undefined,
},
],
requiresApproval: null,
requiresInput: null,
});
@ -588,11 +806,12 @@ function writeToolEnvelope(payload) {
...payload,
};
process.stdout.write(JSON.stringify(envelope, null, 2));
process.stdout.write('\n');
process.stdout.write("\n");
}
function helpText() {
return `lobster — OpenClaw-native typed shell\n\n` +
return (
`lobster — OpenClaw-native typed shell\n\n` +
`Usage:\n` +
` lobster '<pipeline>'\n` +
` lobster run --mode tool '<pipeline>'\n` +
@ -600,6 +819,9 @@ function helpText() {
` lobster run --file path/to/workflow.lobster --args-json '{...}'\n` +
` lobster run --dry-run --file path/to/workflow.lobster\n` +
` lobster run --dry-run '<pipeline>'\n` +
` lobster graph --file path/to/workflow.lobster --format mermaid\n` +
` lobster graph --file path/to/workflow.lobster --format dot\n` +
` lobster graph --file path/to/workflow.lobster --format ascii\n` +
` lobster resume --token <token> --approve yes|no\n` +
` lobster resume --token <token> --response-json '{...}'\n` +
` lobster resume --token <token> --cancel\n` +
@ -615,5 +837,19 @@ function helpText() {
` lobster 'exec --json "echo [1,2,3]" | json'\n` +
` lobster run --mode tool 'exec --json "echo [1]" | approve --prompt "ok?"'\n\n` +
`Commands:\n` +
` exec, head, json, pick, table, where, approve, ask, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`;
` exec, head, json, pick, table, where, approve, ask, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run, graph\n`
);
}
function graphHelpText() {
return (
`lobster graph — render workflow step graphs\n\n` +
`Usage:\n` +
` lobster graph --file path/to/workflow.lobster [--format mermaid|dot|ascii] [--args-json '{...}']\n` +
` lobster graph path/to/workflow.lobster [--format mermaid|dot|ascii]\n\n` +
`Flags:\n` +
` --file Workflow file path (.lobster, .yaml, .yml, .json)\n` +
` --format Output format: mermaid (default), dot, ascii\n` +
` --args-json JSON object used to resolve workflow args for labels\n`
);
}

View File

@ -1,13 +1,15 @@
import type { CommandMeta, LobsterCommand } from './types.js';
import type { CommandMeta, LobsterCommand } from "./types.js";
function parseDescriptionFromHelp(helpText: string): string {
const firstLine = helpText.split('\n').find((l) => l.trim().length > 0) ?? '';
const firstLine = helpText.split("\n").find((l) => l.trim().length > 0) ?? "";
// Expected pattern: "name — description" but fall back to the line as-is.
return firstLine.includes('—') ? firstLine.split('—').slice(1).join('—').trim() : firstLine.trim();
return firstLine.includes("—")
? firstLine.split("—").slice(1).join("—").trim()
: firstLine.trim();
}
export const commandsListCommand: LobsterCommand = {
name: 'commands.list',
name: "commands.list",
help() {
return (
`commands.list — list available Lobster pipeline commands\n\n` +
@ -19,8 +21,8 @@ export const commandsListCommand: LobsterCommand = {
);
},
meta: {
description: 'List available Lobster pipeline commands',
argsSchema: { type: 'object', properties: {}, required: [] },
description: "List available Lobster pipeline commands",
argsSchema: { type: "object", properties: {}, required: [] },
sideEffects: [],
} satisfies CommandMeta,
async run({ input, ctx }) {
@ -32,7 +34,7 @@ export const commandsListCommand: LobsterCommand = {
const names = ctx.registry.list();
const output = names.map((name) => {
const cmd = ctx.registry.get(name) as LobsterCommand | undefined;
const help = typeof cmd?.help === 'function' ? String(cmd.help()) : '';
const help = typeof cmd?.help === "function" ? String(cmd.help()) : "";
const description = cmd?.meta?.description ?? parseDescriptionFromHelp(help);
return {

View File

@ -1,19 +1,19 @@
import { readLineFromStream } from '../../read_line.js';
import { readLineFromStream } from "../../read_line.js";
function isInteractive(stdin) {
return Boolean(stdin.isTTY);
}
export const approveCommand = {
name: 'approve',
name: "approve",
meta: {
description: 'Require confirmation to continue',
description: "Require confirmation to continue",
argsSchema: {
type: 'object',
type: "object",
properties: {
prompt: { type: 'string', description: 'Approval prompt text', default: 'Approve?' },
emit: { type: 'boolean', description: 'Force emit approval request + halt' },
_: { type: 'array', items: { type: 'string' } },
prompt: { type: "string", description: "Approval prompt text", default: "Approve?" },
emit: { type: "boolean", description: "Force emit approval request + halt" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -23,15 +23,15 @@ export const approveCommand = {
return `approve — require confirmation to continue\n\nUsage:\n ... | approve --prompt "Send these emails?"\n ... | approve --emit --prompt "Send these emails?"\n ... | approve --emit --preview-from-stdin --limit 5 --prompt "Proceed?"\n\nModes:\n - Interactive (default): prompts on TTY and passes items through if approved.\n - Emit (--emit): returns an approval request object and stops the pipeline.\n\nNotes:\n - In tool mode (or non-interactive), this emits an approval request and halts.\n`;
},
async run({ input, args, ctx }) {
const prompt = args.prompt ?? 'Approve?';
const previewFromStdin = Boolean(args.previewFromStdin ?? args['preview-from-stdin']);
const previewLimitRaw = args.limit ?? args.previewLimit ?? args['preview-limit'];
const prompt = args.prompt ?? "Approve?";
const previewFromStdin = Boolean(args.previewFromStdin ?? args["preview-from-stdin"]);
const previewLimitRaw = args.limit ?? args.previewLimit ?? args["preview-limit"];
const previewLimit = Number.isFinite(Number(previewLimitRaw)) ? Number(previewLimitRaw) : 5;
const items = [];
for await (const item of input) items.push(item);
const emit = Boolean(args.emit) || ctx.mode === 'tool' || !isInteractive(ctx.stdin);
const emit = Boolean(args.emit) || ctx.mode === "tool" || !isInteractive(ctx.stdin);
if (emit) {
const preview = previewFromStdin
@ -41,7 +41,7 @@ export const approveCommand = {
halt: true,
output: (async function* () {
yield {
type: 'approval_request',
type: "approval_request",
prompt,
items,
...(preview ? { preview } : null),
@ -56,7 +56,7 @@ export const approveCommand = {
});
if (!/^y(es)?$/i.test(String(answer).trim())) {
throw new Error('Not approved');
throw new Error("Not approved");
}
return { output: asStream(items) };
@ -64,9 +64,9 @@ export const approveCommand = {
};
function buildPreview(items) {
if (!items.length) return '';
if (items.every((item) => typeof item === 'string')) {
return items.join('\n');
if (!items.length) return "";
if (items.every((item) => typeof item === "string")) {
return items.join("\n");
}
return JSON.stringify(items, null, 2);
}

View File

@ -1,4 +1,4 @@
import { sharedAjv } from '../../validation.js';
import { sharedAjv } from "../../validation.js";
function isInteractive(stdin) {
return Boolean(stdin.isTTY);
@ -8,7 +8,7 @@ function compileAskValidator(schema) {
try {
return sharedAjv.compile(schema);
} catch {
throw new Error('ask response schema is invalid');
throw new Error("ask response schema is invalid");
}
}
@ -16,8 +16,8 @@ function validateAskResponse(validator, response) {
const ok = validator(response);
if (ok) return;
const first = validator.errors?.[0];
const pathValue = first?.instancePath || '/';
const reason = first?.message ? ` ${first.message}` : '';
const pathValue = first?.instancePath || "/";
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`ask response failed schema validation at ${pathValue}:${reason}`);
}
@ -28,24 +28,31 @@ function parseInteractiveCandidates(text) {
} catch {
return [text, { decision: text }];
}
if (typeof parsed === 'string') {
if (typeof parsed === "string") {
return [parsed, { decision: parsed }];
}
return [parsed];
}
export const askCommand = {
name: 'ask',
name: "ask",
meta: {
description: 'Pause and request structured input from the user',
description: "Pause and request structured input from the user",
argsSchema: {
type: 'object',
type: "object",
properties: {
prompt: { type: 'string', description: 'Question or instruction to show', default: 'Input required' },
schema: { type: 'string', description: 'JSON Schema string for the expected response' },
'subject-from-stdin': { type: 'boolean', description: 'Use stdin content as the subject (preview text)' },
emit: { type: 'boolean', description: 'Force emit mode' },
_: { type: 'array', items: { type: 'string' } },
prompt: {
type: "string",
description: "Question or instruction to show",
default: "Input required",
},
schema: { type: "string", description: "JSON Schema string for the expected response" },
"subject-from-stdin": {
type: "boolean",
description: "Use stdin content as the subject (preview text)",
},
emit: { type: "boolean", description: "Force emit mode" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -53,34 +60,34 @@ export const askCommand = {
},
help() {
return [
'ask — pause and request structured input from the user',
'',
'Usage:',
"ask — pause and request structured input from the user",
"",
"Usage:",
' ... | ask --prompt "Approve, reject, or send feedback:"',
' ... | ask --prompt "Feedback?" --schema \'{"type":"object","properties":{"decision":{"type":"string"},"feedback":{"type":"string"}},"required":["decision"]}\'',
' ... | ask --subject-from-stdin --prompt "Review this draft:"',
'',
'Notes:',
' - In tool mode (or non-interactive), emits a needs_input envelope and halts.',
' - Use --schema to constrain the response shape (JSON Schema).',
' - Use --subject-from-stdin to embed the current pipeline value as preview text.',
].join('\n');
"",
"Notes:",
" - In tool mode (or non-interactive), emits a needs_input envelope and halts.",
" - Use --schema to constrain the response shape (JSON Schema).",
" - Use --subject-from-stdin to embed the current pipeline value as preview text.",
].join("\n");
},
async run({ input, args, ctx }) {
const prompt = typeof args.prompt === 'string' ? args.prompt : 'Input required';
const subjectFromStdin = Boolean(args['subject-from-stdin'] ?? args.subjectFromStdin);
const schemaRaw = typeof args.schema === 'string' ? args.schema : null;
const prompt = typeof args.prompt === "string" ? args.prompt : "Input required";
const subjectFromStdin = Boolean(args["subject-from-stdin"] ?? args.subjectFromStdin);
const schemaRaw = typeof args.schema === "string" ? args.schema : null;
const items = [];
for await (const item of input) items.push(item);
const defaultSchema = {
type: 'object',
type: "object",
properties: {
decision: { type: 'string', enum: ['approve', 'reject', 'redraft'] },
feedback: { type: 'string', description: 'Feedback for redraft' },
decision: { type: "string", enum: ["approve", "reject", "redraft"] },
feedback: { type: "string", description: "Feedback for redraft" },
},
required: ['decision'],
required: ["decision"],
};
let responseSchema = defaultSchema;
@ -89,10 +96,10 @@ export const askCommand = {
try {
parsedSchema = JSON.parse(schemaRaw);
} catch {
throw new Error('ask --schema must be valid JSON');
throw new Error("ask --schema must be valid JSON");
}
if (!parsedSchema || typeof parsedSchema !== 'object' || Array.isArray(parsedSchema)) {
throw new Error('ask --schema must decode to a JSON schema object');
if (!parsedSchema || typeof parsedSchema !== "object" || Array.isArray(parsedSchema)) {
throw new Error("ask --schema must decode to a JSON schema object");
}
responseSchema = parsedSchema;
}
@ -101,19 +108,19 @@ export const askCommand = {
let subject;
if (subjectFromStdin && items.length > 0) {
const preview = items
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
.map((item) => (typeof item === "string" ? item : JSON.stringify(item)))
.join("\n")
.slice(0, 2000);
subject = { text: preview };
}
const emit = Boolean(args.emit) || ctx.mode === 'tool' || !isInteractive(ctx.stdin);
const emit = Boolean(args.emit) || ctx.mode === "tool" || !isInteractive(ctx.stdin);
if (emit) {
return {
halt: true,
output: (async function* () {
yield {
type: 'input_request',
type: "input_request",
prompt,
responseSchema,
...(subject ? { subject } : null),
@ -124,19 +131,23 @@ export const askCommand = {
}
ctx.stdout.write(`${prompt}\n> `);
const { readLineFromStream } = await import('../../read_line.js');
const { readLineFromStream } = await import("../../read_line.js");
const raw = await readLineFromStream(ctx.stdin, { timeoutMs: 0 });
const text = String(raw ?? '').trim();
const text = String(raw ?? "").trim();
let lastError;
for (const candidate of parseInteractiveCandidates(text)) {
try {
validateAskResponse(responseValidator, candidate);
return { output: (async function* () { yield candidate; })() };
return {
output: (async function* () {
yield candidate;
})(),
};
} catch (err) {
lastError = err;
}
}
throw lastError ?? new Error('ask response failed schema validation');
throw lastError ?? new Error("ask response failed schema validation");
},
};

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (!path) return obj;
const parts = path.split('.').filter(Boolean);
const parts = path.split(".").filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -10,14 +10,17 @@ function getByPath(obj: any, path: string): any {
}
export const dedupeCommand = {
name: 'dedupe',
name: "dedupe",
meta: {
description: 'Remove duplicate items, keeping first occurrence (stable)',
description: "Remove duplicate items, keeping first occurrence (stable)",
argsSchema: {
type: 'object',
type: "object",
properties: {
key: { type: 'string', description: 'Dot-path key used for identity (defaults to whole item)' },
_: { type: 'array', items: { type: 'string' } },
key: {
type: "string",
description: "Dot-path key used for identity (defaults to whole item)",
},
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -34,7 +37,7 @@ export const dedupeCommand = {
);
},
async run({ input, args }: any) {
const key = typeof args.key === 'string' ? args.key : undefined;
const key = typeof args.key === "string" ? args.key : undefined;
const seen = new Set<string>();
return {

View File

@ -1,25 +1,25 @@
import { diffAndStore } from '../../state/store.js';
import { diffAndStore } from "../../state/store.js";
export const diffLastCommand = {
name: 'diff.last',
name: "diff.last",
meta: {
description: 'Compare current items to last stored snapshot',
description: "Compare current items to last stored snapshot",
argsSchema: {
type: 'object',
type: "object",
properties: {
key: { type: 'string', description: 'State key to diff against' },
_: { type: 'array', items: { type: 'string' } },
key: { type: "string", description: "State key to diff against" },
_: { type: "array", items: { type: "string" } },
},
required: ['key'],
required: ["key"],
},
sideEffects: ['writes_state'],
sideEffects: ["writes_state"],
},
help() {
return `diff.last — compare current items to last stored snapshot\n\nUsage:\n <items> | diff.last --key <stateKey>\n\nOutput:\n { changed, key, before, after }\n`;
},
async run({ input, args, ctx }) {
const key = args.key ?? args._[0];
if (!key) throw new Error('diff.last requires --key');
if (!key) throw new Error("diff.last requires --key");
const afterItems = [];
for await (const item of input) afterItems.push(item);
@ -29,7 +29,7 @@ export const diffLastCommand = {
return {
output: (async function* () {
yield { kind: 'diff.last', key, changed, before, after };
yield { kind: "diff.last", key, changed, before, after };
})(),
};
},

View File

@ -8,17 +8,19 @@ type EmailLike = {
labels?: string[];
};
type NormalizedEmail = Required<Pick<EmailLike, 'id' | 'threadId' | 'from' | 'subject' | 'date' | 'snippet'>> & {
type NormalizedEmail = Required<
Pick<EmailLike, "id" | "threadId" | "from" | "subject" | "date" | "snippet">
> & {
labels: string[];
};
function normalizeEmail(raw: any): NormalizedEmail {
const id = String(raw?.id ?? raw?.messageId ?? '').trim();
const id = String(raw?.id ?? raw?.messageId ?? "").trim();
const threadId = String(raw?.threadId ?? raw?.thread_id ?? id).trim();
const from = String(raw?.from ?? raw?.sender ?? '').trim();
const subject = String(raw?.subject ?? '').trim();
const date = String(raw?.date ?? raw?.internalDate ?? raw?.timestamp ?? '').trim();
const snippet = String(raw?.snippet ?? raw?.bodyPreview ?? '').trim();
const from = String(raw?.from ?? raw?.sender ?? "").trim();
const subject = String(raw?.subject ?? "").trim();
const date = String(raw?.date ?? raw?.internalDate ?? raw?.timestamp ?? "").trim();
const snippet = String(raw?.snippet ?? raw?.bodyPreview ?? "").trim();
const labels = Array.isArray(raw?.labels) ? raw.labels.map((x: any) => String(x)) : [];
return {
@ -35,10 +37,10 @@ function normalizeEmail(raw: any): NormalizedEmail {
function isLikelyNoReply(from: string) {
const f = from.toLowerCase();
return (
f.includes('no-reply') ||
f.includes('noreply') ||
f.includes('do-not-reply') ||
f.includes('donotreply')
f.includes("no-reply") ||
f.includes("noreply") ||
f.includes("do-not-reply") ||
f.includes("donotreply")
);
}
@ -47,16 +49,16 @@ function extractEmailAddress(from: string): string {
if (m?.[1]) return m[1].trim();
// fallback: find first email-ish token
const m2 = String(from).match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return (m2?.[0] ?? '').trim();
return (m2?.[0] ?? "").trim();
}
function ensureRe(subject: string) {
const s = String(subject ?? '').trim();
if (!s) return 'Re:';
const s = String(subject ?? "").trim();
if (!s) return "Re:";
return /^re:\s*/i.test(s) ? s : `Re: ${s}`;
}
type TriageCategory = 'needs_reply' | 'needs_action' | 'fyi';
type TriageCategory = "needs_reply" | "needs_action" | "fyi";
type TriageDecision = {
id: string;
@ -78,7 +80,7 @@ type EmailTriageReport = {
emails: NormalizedEmail[];
decisions?: TriageDecision[];
drafts?: { to: string; subject: string; body: string; emailId: string }[];
mode: 'deterministic' | 'llm';
mode: "deterministic" | "llm";
};
function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport {
@ -90,9 +92,9 @@ function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport
for (const e of emails) {
const subjLower = e.subject.toLowerCase();
const unread = e.labels.some((l) => l.toUpperCase() === 'UNREAD');
const unread = e.labels.some((l) => l.toUpperCase() === "UNREAD");
if (subjLower.includes('action required') || subjLower.includes('urgent')) {
if (subjLower.includes("action required") || subjLower.includes("urgent")) {
buckets.needsAction.push(e);
continue;
}
@ -115,7 +117,7 @@ function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport
fyi: buckets.fyi.map((x) => x.id),
},
emails,
mode: 'deterministic',
mode: "deterministic",
};
}
@ -145,52 +147,66 @@ function triagePrompt(emails: NormalizedEmail[]) {
}
const TRIAGE_OUTPUT_SCHEMA = {
type: 'object',
type: "object",
properties: {
decisions: {
type: 'array',
type: "array",
items: {
type: 'object',
type: "object",
properties: {
id: { type: 'string' },
category: { type: 'string', enum: ['needs_reply', 'needs_action', 'fyi'] },
rationale: { type: 'string' },
id: { type: "string" },
category: { type: "string", enum: ["needs_reply", "needs_action", "fyi"] },
rationale: { type: "string" },
reply: {
type: 'object',
type: "object",
properties: {
subject: { type: 'string' },
body: { type: 'string' },
subject: { type: "string" },
body: { type: "string" },
},
required: ['body'],
required: ["body"],
additionalProperties: false,
},
},
required: ['id', 'category'],
required: ["id", "category"],
additionalProperties: false,
},
},
},
required: ['decisions'],
required: ["decisions"],
additionalProperties: false,
};
export const emailTriageCommand = {
name: 'email.triage',
name: "email.triage",
meta: {
description: 'Email triage (deterministic by default, optionally LLM-assisted via llm.invoke)',
description: "Email triage (deterministic by default, optionally LLM-assisted via llm.invoke)",
argsSchema: {
type: 'object',
type: "object",
properties: {
limit: { type: 'number', description: 'Maximum items to consume from input stream', default: 20 },
llm: { type: 'boolean', description: 'Use llm.invoke for categorization + draft replies' },
model: { type: 'string', description: 'Model for llm.invoke (optional; adapter defaults may apply)' },
url: { type: 'string', description: 'Reserved for compatibility (ignored in OpenClaw mode)' },
token: { type: 'string', description: 'Bearer token (or OPENCLAW_TOKEN/CLAWD_TOKEN)' },
temperature: { type: 'number', description: 'LLM temperature' },
'max-output-tokens': { type: 'number', description: 'Max completion tokens' },
emit: { type: 'string', description: "Output mode: 'report' (default) or 'drafts'", default: 'report' },
'state-key': { type: 'string', description: 'Run-state key forwarded to llm.invoke' },
_: { type: 'array', items: { type: 'string' } },
limit: {
type: "number",
description: "Maximum items to consume from input stream",
default: 20,
},
llm: { type: "boolean", description: "Use llm.invoke for categorization + draft replies" },
model: {
type: "string",
description: "Model for llm.invoke (optional; adapter defaults may apply)",
},
url: {
type: "string",
description: "Reserved for compatibility (ignored in OpenClaw mode)",
},
token: { type: "string", description: "Bearer token (or OPENCLAW_TOKEN/CLAWD_TOKEN)" },
temperature: { type: "number", description: "LLM temperature" },
"max-output-tokens": { type: "number", description: "Max completion tokens" },
emit: {
type: "string",
description: "Output mode: 'report' (default) or 'drafts'",
default: "report",
},
"state-key": { type: "string", description: "Run-state key forwarded to llm.invoke" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -212,7 +228,7 @@ export const emailTriageCommand = {
},
async run({ input, args, ctx }) {
const limit = Number(args.limit ?? 20);
const emit = String(args.emit ?? 'report').trim() || 'report';
const emit = String(args.emit ?? "report").trim() || "report";
const emails: NormalizedEmail[] = [];
for await (const item of input) {
@ -223,25 +239,25 @@ export const emailTriageCommand = {
const wantLlm = Boolean(args.llm ?? false);
const env = ctx?.env ?? process.env;
const hasLlmProvider = Boolean(
String(env.LOBSTER_LLM_PROVIDER ?? '').trim() ||
String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? '').trim() ||
String(env.LOBSTER_LLM_ADAPTER_URL ?? '').trim() ||
String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? '').trim(),
String(env.LOBSTER_LLM_PROVIDER ?? "").trim() ||
String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim() ||
String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim() ||
String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim(),
);
if (!wantLlm || !hasLlmProvider) {
const report = buildDeterministicReport(emails);
if (emit === 'drafts') {
if (emit === "drafts") {
return { output: streamOf([]) };
}
return { output: streamOf([report]) };
}
const model = String(args.model ?? '').trim();
const model = String(args.model ?? "").trim();
if (!ctx?.registry) throw new Error('email.triage (LLM mode) requires ctx.registry');
const llmCmd = ctx.registry.get('llm.invoke') ?? ctx.registry.get('llm_task.invoke');
if (!llmCmd) throw new Error('email.triage requires llm.invoke to be registered');
if (!ctx?.registry) throw new Error("email.triage (LLM mode) requires ctx.registry");
const llmCmd = ctx.registry.get("llm.invoke") ?? ctx.registry.get("llm_task.invoke");
if (!llmCmd) throw new Error("email.triage requires llm.invoke to be registered");
const llmRes = await llmCmd.run({
input: streamOf(emails),
@ -251,11 +267,11 @@ export const emailTriageCommand = {
token: args.token,
...(model ? { model } : null),
prompt: triagePrompt(emails),
'output-schema': JSON.stringify(TRIAGE_OUTPUT_SCHEMA),
'schema-version': 'email_triage.v1',
"output-schema": JSON.stringify(TRIAGE_OUTPUT_SCHEMA),
"schema-version": "email_triage.v1",
temperature: args.temperature,
'max-output-tokens': args['max-output-tokens'],
'state-key': args['state-key'] ?? env.LOBSTER_RUN_STATE_KEY,
"max-output-tokens": args["max-output-tokens"],
"state-key": args["state-key"] ?? env.LOBSTER_RUN_STATE_KEY,
},
ctx,
} as any);
@ -265,12 +281,17 @@ export const emailTriageCommand = {
const first = llmItems[0];
const data = first?.output?.data;
const decisionsRaw = Array.isArray(data?.decisions) ? data.decisions : [];
const decisions: TriageDecision[] = decisionsRaw.map((d: any) => ({
id: String(d?.id ?? '').trim(),
category: String(d?.category ?? 'fyi') as TriageCategory,
rationale: d?.rationale ? String(d.rationale) : undefined,
reply: d?.reply && typeof d.reply === 'object' ? { subject: d.reply.subject, body: String(d.reply.body ?? '') } : undefined,
})).filter((d: any) => d.id);
const decisions: TriageDecision[] = decisionsRaw
.map((d: any) => ({
id: String(d?.id ?? "").trim(),
category: String(d?.category ?? "fyi") as TriageCategory,
rationale: d?.rationale ? String(d.rationale) : undefined,
reply:
d?.reply && typeof d.reply === "object"
? { subject: d.reply.subject, body: String(d.reply.body ?? "") }
: undefined,
}))
.filter((d: any) => d.id);
const byId = new Map(emails.map((e) => [e.id, e] as const));
const buckets = {
@ -282,18 +303,18 @@ export const emailTriageCommand = {
const drafts: { to: string; subject: string; body: string; emailId: string }[] = [];
for (const d of decisions) {
if (d.category === 'needs_reply') buckets.needsReply.push(d.id);
else if (d.category === 'needs_action') buckets.needsAction.push(d.id);
if (d.category === "needs_reply") buckets.needsReply.push(d.id);
else if (d.category === "needs_action") buckets.needsAction.push(d.id);
else buckets.fyi.push(d.id);
if (d.category === 'needs_reply' && d.reply?.body) {
if (d.category === "needs_reply" && d.reply?.body) {
const email = byId.get(d.id);
const to = email ? extractEmailAddress(email.from) : '';
if (to && !isLikelyNoReply(email?.from ?? '')) {
const to = email ? extractEmailAddress(email.from) : "";
if (to && !isLikelyNoReply(email?.from ?? "")) {
drafts.push({
emailId: d.id,
to,
subject: d.reply.subject ? String(d.reply.subject) : ensureRe(email?.subject ?? ''),
subject: d.reply.subject ? String(d.reply.subject) : ensureRe(email?.subject ?? ""),
body: String(d.reply.body),
});
}
@ -302,7 +323,7 @@ export const emailTriageCommand = {
const summary = `${buckets.needsReply.length} need replies, ${buckets.needsAction.length} need action, ${buckets.fyi.length} FYI`;
if (emit === 'drafts') {
if (emit === "drafts") {
return {
output: (async function* () {
for (const d of drafts) {
@ -319,7 +340,7 @@ export const emailTriageCommand = {
emails,
decisions,
drafts,
mode: 'llm',
mode: "llm",
};
return { output: streamOf([report]) };

View File

@ -1,23 +1,24 @@
import { spawn } from 'node:child_process';
import { resolveInlineShellCommand } from '../../shell.js';
import { spawn } from "node:child_process";
import { resolveInlineShellCommand } from "../../shell.js";
export const execCommand = {
name: 'exec',
name: "exec",
meta: {
description: 'Run an OS command',
description: "Run an OS command",
argsSchema: {
type: 'object',
type: "object",
properties: {
json: { type: 'boolean', description: 'Parse stdout as JSON (single value).' },
shell: { type: 'string', description: 'Run via the system shell with this command line.' },
_: { type: 'array', items: { type: 'string' }, description: 'Command + args.' },
json: { type: "boolean", description: "Parse stdout as JSON (single value)." },
shell: { type: "string", description: "Run via the system shell with this command line." },
_: { type: "array", items: { type: "string" }, description: "Command + args." },
},
required: ['_'],
required: ["_"],
},
sideEffects: ['local_exec'],
sideEffects: ["local_exec"],
},
help() {
return `exec — run an OS command\n\n` +
return (
`exec — run an OS command\n\n` +
`Usage:\n` +
` exec <command...>\n` +
` exec --stdin raw|json|jsonl <command...>\n` +
@ -26,17 +27,18 @@ export const execCommand = {
`Notes:\n` +
` - With --json, parses stdout as JSON (single value).\n` +
` - With --stdin, writes pipeline input to stdin.\n` +
` - With --shell (or a single arg containing spaces), runs via the system shell.\n`;
` - With --shell (or a single arg containing spaces), runs via the system shell.\n`
);
},
async run({ input, args, ctx }) {
const cmd = args._;
const cwd = ctx?.cwd ?? process.cwd();
const shellLine = typeof args.shell === 'string' ? args.shell : null;
const shellLine = typeof args.shell === "string" ? args.shell : null;
const useShell = Boolean(args.shell) || (cmd.length === 1 && /\s/.test(cmd[0]));
const stdinMode = typeof args.stdin === 'string' ? String(args.stdin).toLowerCase() : null;
const stdinMode = typeof args.stdin === "string" ? String(args.stdin).toLowerCase() : null;
if (!cmd.length && !shellLine) throw new Error('exec requires a command');
if (!cmd.length && !shellLine) throw new Error("exec requires a command");
let stdinPayload = null;
if (stdinMode) {
@ -51,15 +53,27 @@ export const execCommand = {
}
const result = useShell
? await runShellLine(shellLine ?? cmd[0] ?? '', { env: ctx.env, cwd, stdin: stdinPayload, signal: ctx.signal })
: await runProcess(cmd[0], cmd.slice(1), { env: ctx.env, cwd, stdin: stdinPayload, signal: ctx.signal });
? await runShellLine(shellLine ?? cmd[0] ?? "", {
env: ctx.env,
cwd,
stdin: stdinPayload,
signal: ctx.signal,
})
: await runProcess(cmd[0], cmd.slice(1), {
env: ctx.env,
cwd,
stdin: stdinPayload,
signal: ctx.signal,
});
if (args.json) {
let parsed;
try {
parsed = JSON.parse(result.stdout.trim() || 'null');
parsed = JSON.parse(result.stdout.trim() || "null");
} catch (err) {
throw new Error(`exec --json could not parse stdout as JSON: ${err?.message ?? String(err)}`);
throw new Error(
`exec --json could not parse stdout as JSON: ${err?.message ?? String(err)}`,
);
}
return {
@ -78,26 +92,30 @@ function runProcess(command, argv, { env, cwd, stdin, signal }) {
env,
cwd,
signal,
stdio: ['pipe', 'pipe', 'pipe'],
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = '';
let stderr = '';
let stdout = "";
let stderr = "";
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
if (typeof stdin === 'string') {
child.stdin.setDefaultEncoding('utf8');
if (typeof stdin === "string") {
child.stdin.setDefaultEncoding("utf8");
child.stdin.write(stdin);
}
child.stdin.end();
child.on('error', reject);
child.on('close', (code) => {
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) return resolve({ stdout, stderr });
reject(new Error(`exec failed (${code}): ${stderr.trim() || stdout.trim() || command}`));
});
@ -110,12 +128,12 @@ function runShellLine(commandLine, { env, cwd, stdin, signal }) {
}
function encodeStdin(items, mode) {
if (mode === 'json') return JSON.stringify(items);
if (mode === 'jsonl') {
return items.map((item) => JSON.stringify(item)).join('\n') + (items.length ? '\n' : '');
if (mode === "json") return JSON.stringify(items);
if (mode === "jsonl") {
return items.map((item) => JSON.stringify(item)).join("\n") + (items.length ? "\n" : "");
}
if (mode === 'raw') {
return items.map((item) => (typeof item === 'string' ? item : JSON.stringify(item))).join('\n');
if (mode === "raw") {
return items.map((item) => (typeof item === "string" ? item : JSON.stringify(item))).join("\n");
}
throw new Error(`exec --stdin must be raw, json, or jsonl (got ${mode})`);
}

View File

@ -41,7 +41,7 @@ export const gogGmailSearchCommand = {
},
required: [],
},
sideEffects: ['reads_email'],
sideEffects: ["reads_email"],
},
help() {
return (

View File

@ -57,7 +57,7 @@ export const gogGmailSendCommand = {
},
required: [],
},
sideEffects: ['sends_email'],
sideEffects: ["sends_email"],
},
help() {
return (

View File

@ -1,5 +1,5 @@
function getByPath(obj: any, path: string): any {
const parts = path.split('.').filter(Boolean);
const parts = path.split(".").filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -9,16 +9,16 @@ function getByPath(obj: any, path: string): any {
}
export const groupByCommand = {
name: 'groupBy',
name: "groupBy",
meta: {
description: 'Group items by a key (stable group order)',
description: "Group items by a key (stable group order)",
argsSchema: {
type: 'object',
type: "object",
properties: {
key: { type: 'string', description: 'Dot-path key to group by (required)' },
_: { type: 'array', items: { type: 'string' } },
key: { type: "string", description: "Dot-path key to group by (required)" },
_: { type: "array", items: { type: "string" } },
},
required: ['key'],
required: ["key"],
},
sideEffects: [],
},
@ -34,8 +34,8 @@ export const groupByCommand = {
);
},
async run({ input, args }: any) {
const keyPath = String(args.key ?? '').trim();
if (!keyPath) throw new Error('groupBy requires --key');
const keyPath = String(args.key ?? "").trim();
if (!keyPath) throw new Error("groupBy requires --key");
const groups = new Map<string, { key: any; items: any[] }>();
const order: string[] = [];

View File

@ -1,12 +1,12 @@
export const headCommand = {
name: 'head',
name: "head",
meta: {
description: 'Take first N items',
description: "Take first N items",
argsSchema: {
type: 'object',
type: "object",
properties: {
n: { type: 'number', description: 'Number of items to take', default: 10 },
_: { type: 'array', items: { type: 'string' } },
n: { type: "number", description: "Number of items to take", default: 10 },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -17,7 +17,7 @@ export const headCommand = {
},
async run({ input, args }) {
const n = args.n === undefined ? 10 : Number(args.n);
if (!Number.isFinite(n) || n < 0) throw new Error('head --n must be a non-negative number');
if (!Number.isFinite(n) || n < 0) throw new Error("head --n must be a non-negative number");
return {
output: (async function* () {

View File

@ -1,8 +1,8 @@
export const jsonCommand = {
name: 'json',
name: "json",
meta: {
description: 'Render pipeline output as JSON',
argsSchema: { type: 'object', properties: {}, required: [] },
description: "Render pipeline output as JSON",
argsSchema: { type: "object", properties: {}, required: [] },
sideEffects: [],
},
help() {

View File

@ -1,93 +1,93 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { createHash } from 'node:crypto';
import { Ajv } from 'ajv';
import type { ErrorObject } from 'ajv';
import path from "node:path";
import { promises as fsp } from "node:fs";
import { createHash } from "node:crypto";
import { Ajv } from "ajv";
import type { ErrorObject } from "ajv";
import { readStateJson, writeStateJson, stableStringify } from '../../state/store.js';
import type { LobsterCommand } from '../types.js';
import { readStateJson, writeStateJson, stableStringify } from "../../state/store.js";
import type { LobsterCommand } from "../types.js";
const ajv = new Ajv({ allErrors: true, strict: false });
const artifactSchema = {
type: 'object',
type: "object",
properties: {
kind: { type: 'string' },
role: { type: 'string' },
name: { type: 'string' },
mimeType: { type: 'string' },
text: { type: 'string' },
kind: { type: "string" },
role: { type: "string" },
name: { type: "string" },
mimeType: { type: "string" },
text: { type: "string" },
data: {},
uri: { type: 'string' },
uri: { type: "string" },
},
additionalProperties: true,
};
const payloadSchema = {
type: 'object',
type: "object",
properties: {
prompt: { type: 'string', minLength: 1 },
model: { type: 'string', minLength: 1 },
artifacts: { type: 'array', items: artifactSchema },
artifactHashes: { type: 'array', items: { type: 'string', minLength: 10 } },
schemaVersion: { type: 'string' },
metadata: { type: 'object', additionalProperties: true },
outputSchema: { type: 'object', additionalProperties: true },
temperature: { type: 'number' },
maxOutputTokens: { type: 'number' },
prompt: { type: "string", minLength: 1 },
model: { type: "string", minLength: 1 },
artifacts: { type: "array", items: artifactSchema },
artifactHashes: { type: "array", items: { type: "string", minLength: 10 } },
schemaVersion: { type: "string" },
metadata: { type: "object", additionalProperties: true },
outputSchema: { type: "object", additionalProperties: true },
temperature: { type: "number" },
maxOutputTokens: { type: "number" },
retryContext: {
type: 'object',
type: "object",
properties: {
attempt: { type: 'number' },
validationErrors: { type: 'array', items: { type: 'string' } },
attempt: { type: "number" },
validationErrors: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
},
required: ['prompt', 'artifacts', 'artifactHashes'],
required: ["prompt", "artifacts", "artifactHashes"],
additionalProperties: false,
};
const responseSchema = {
type: 'object',
type: "object",
properties: {
ok: { type: 'boolean' },
ok: { type: "boolean" },
result: {
type: 'object',
type: "object",
properties: {
runId: { type: 'string' },
model: { type: 'string' },
prompt: { type: 'string' },
status: { type: 'string' },
runId: { type: "string" },
model: { type: "string" },
prompt: { type: "string" },
status: { type: "string" },
output: {
type: 'object',
type: "object",
properties: {
text: { type: 'string' },
text: { type: "string" },
data: {},
format: { type: 'string' },
format: { type: "string" },
},
required: [],
additionalProperties: true,
},
usage: {
type: 'object',
type: "object",
properties: {
inputTokens: { type: 'number' },
outputTokens: { type: 'number' },
totalTokens: { type: 'number' },
inputTokens: { type: "number" },
outputTokens: { type: "number" },
totalTokens: { type: "number" },
},
additionalProperties: true,
},
warnings: { type: 'array', items: { type: 'string' } },
metadata: { type: 'object', additionalProperties: true },
diagnostics: { type: 'object', additionalProperties: true },
warnings: { type: "array", items: { type: "string" } },
metadata: { type: "object", additionalProperties: true },
diagnostics: { type: "object", additionalProperties: true },
},
required: ['output'],
required: ["output"],
additionalProperties: true,
},
error: { type: 'object', additionalProperties: true },
error: { type: "object", additionalProperties: true },
},
required: ['ok'],
required: ["ok"],
additionalProperties: true,
};
@ -97,7 +97,7 @@ const validateResponseEnvelope = ajv.compile(responseSchema);
const DEFAULT_MAX_VALIDATION_RETRIES = 1;
const STATE_VERSION = 1;
type BuiltInProvider = 'openclaw' | 'pi' | 'http';
type BuiltInProvider = "openclaw" | "pi" | "http";
type SupportedProvider = BuiltInProvider | string;
type LlmResponseEnvelope = {
@ -165,30 +165,44 @@ type CommandConfig = {
type Adapter = {
provider: SupportedProvider;
source: string;
invoke: (params: { env: any; args: any; payload: Record<string, any> }) => Promise<LlmResponseEnvelope>;
invoke: (params: {
env: any;
args: any;
payload: Record<string, any>;
}) => Promise<LlmResponseEnvelope>;
};
type DirectAdapter =
| ((params: { env: any; args: any; payload: Record<string, any>; ctx: any }) => Promise<LlmResponseEnvelope>)
| ((params: {
env: any;
args: any;
payload: Record<string, any>;
ctx: any;
}) => Promise<LlmResponseEnvelope>)
| {
source?: string;
invoke: (params: { env: any; args: any; payload: Record<string, any>; ctx: any }) => Promise<LlmResponseEnvelope>;
};
source?: string;
invoke: (params: {
env: any;
args: any;
payload: Record<string, any>;
ctx: any;
}) => Promise<LlmResponseEnvelope>;
};
export const llmInvokeCommand = createLlmInvokeCommand({
name: 'llm.invoke',
itemKind: 'llm.invoke',
stateType: 'llm.invoke',
cacheNamespace: 'llm.invoke',
name: "llm.invoke",
itemKind: "llm.invoke",
stateType: "llm.invoke",
cacheNamespace: "llm.invoke",
defaultProvider: null,
description: 'Call a configured LLM adapter with typed payloads and caching',
helpTitle: 'llm.invoke — call a configured LLM adapter with caching and schema validation',
description: "Call a configured LLM adapter with typed payloads and caching",
helpTitle: "llm.invoke — call a configured LLM adapter with caching and schema validation",
helpConfig: [
'Provider resolution order: --provider, LOBSTER_LLM_PROVIDER, then environment auto-detect.',
'Built-in providers: openclaw, pi, http.',
'OpenClaw provider uses OPENCLAW_URL (CLAWD_URL also supported) and OPENCLAW_TOKEN.',
'Pi provider uses LOBSTER_PI_LLM_ADAPTER_URL and is intended to be supplied by a Pi extension.',
'Generic http provider uses LOBSTER_LLM_ADAPTER_URL and optional LOBSTER_LLM_ADAPTER_TOKEN.',
"Provider resolution order: --provider, LOBSTER_LLM_PROVIDER, then environment auto-detect.",
"Built-in providers: openclaw, pi, http.",
"OpenClaw provider uses OPENCLAW_URL (CLAWD_URL also supported) and OPENCLAW_TOKEN.",
"Pi provider uses LOBSTER_PI_LLM_ADAPTER_URL and is intended to be supplied by a Pi extension.",
"Generic http provider uses LOBSTER_LLM_ADAPTER_URL and optional LOBSTER_LLM_ADAPTER_TOKEN.",
],
helpExamples: [
"llm.invoke --prompt 'Write summary'",
@ -203,16 +217,16 @@ export const llmInvokeCommand = createLlmInvokeCommand({
});
export const llmTaskInvokeCommand = createLlmInvokeCommand({
name: 'llm_task.invoke',
itemKind: 'llm_task.invoke',
stateType: 'llm_task.invoke',
cacheNamespace: 'llm_task.invoke',
defaultProvider: 'openclaw',
description: 'Backward-compatible alias for llm.invoke using the OpenClaw adapter',
helpTitle: 'llm_task.invoke — backward-compatible alias for llm.invoke using OpenClaw',
name: "llm_task.invoke",
itemKind: "llm_task.invoke",
stateType: "llm_task.invoke",
cacheNamespace: "llm_task.invoke",
defaultProvider: "openclaw",
description: "Backward-compatible alias for llm.invoke using the OpenClaw adapter",
helpTitle: "llm_task.invoke — backward-compatible alias for llm.invoke using OpenClaw",
helpConfig: [
'Requires OPENCLAW_URL (or CLAWD_URL) and optionally OPENCLAW_TOKEN.',
'Use llm.invoke for new workflows and non-OpenClaw adapters.',
"Requires OPENCLAW_URL (or CLAWD_URL) and optionally OPENCLAW_TOKEN.",
"Use llm.invoke for new workflows and non-OpenClaw adapters.",
],
helpExamples: [
"llm_task.invoke --prompt 'Write summary'",
@ -220,7 +234,7 @@ export const llmTaskInvokeCommand = createLlmInvokeCommand({
"cat artifacts.json | llm_task.invoke --prompt 'Score each item'",
],
sourceForProvider() {
return 'clawd';
return "clawd";
},
legacyEnvCompat: true,
});
@ -231,53 +245,59 @@ export function createLlmInvokeCommand(config: CommandConfig): LobsterCommand {
meta: {
description: config.description,
argsSchema: {
type: 'object',
type: "object",
properties: {
provider: {
type: 'string',
description: 'LLM adapter provider (openclaw, pi, http). Optional if auto-detected.',
type: "string",
description: "LLM adapter provider (openclaw, pi, http). Optional if auto-detected.",
},
token: {
type: 'string',
description: 'Optional bearer token for providers that support it.',
type: "string",
description: "Optional bearer token for providers that support it.",
},
prompt: { type: 'string', description: 'Primary prompt / instructions' },
prompt: { type: "string", description: "Primary prompt / instructions" },
model: {
type: 'string',
description: 'Model identifier. Optional; adapter defaults may apply if omitted.',
type: "string",
description: "Model identifier. Optional; adapter defaults may apply if omitted.",
},
'artifacts-json': { type: 'string', description: 'JSON array of artifacts to send' },
'metadata-json': { type: 'string', description: 'JSON object of metadata to include' },
'output-schema': { type: 'string', description: 'JSON schema LLM output must satisfy' },
'schema-version': { type: 'string', description: 'Logical schema version for caching' },
'max-validation-retries': { type: 'number', description: 'Retries when schema validation fails' },
temperature: { type: 'number', description: 'Sampling temperature' },
'max-output-tokens': { type: 'number', description: 'Max completion tokens' },
'state-key': { type: 'string', description: 'Run-state key override (else LOBSTER_RUN_STATE_KEY)' },
refresh: { type: 'boolean', description: 'Bypass run-state + cache' },
'disable-cache': { type: 'boolean', description: 'Skip persistent cache' },
_: { type: 'array', items: { type: 'string' } },
"artifacts-json": { type: "string", description: "JSON array of artifacts to send" },
"metadata-json": { type: "string", description: "JSON object of metadata to include" },
"output-schema": { type: "string", description: "JSON schema LLM output must satisfy" },
"schema-version": { type: "string", description: "Logical schema version for caching" },
"max-validation-retries": {
type: "number",
description: "Retries when schema validation fails",
},
temperature: { type: "number", description: "Sampling temperature" },
"max-output-tokens": { type: "number", description: "Max completion tokens" },
"state-key": {
type: "string",
description: "Run-state key override (else LOBSTER_RUN_STATE_KEY)",
},
refresh: { type: "boolean", description: "Bypass run-state + cache" },
"disable-cache": { type: "boolean", description: "Skip persistent cache" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
sideEffects: ['calls_llm'],
sideEffects: ["calls_llm"],
},
help() {
const lines = [
config.helpTitle,
'',
'Usage:',
"",
"Usage:",
...config.helpExamples.map((example) => ` ${example}`),
'',
'Features:',
' - Typed payload validation before invoking the adapter.',
' - Run-state + file cache so resumes do not re-call the LLM.',
' - Optional JSON-schema enforcement with bounded retries.',
'',
'Config:',
"",
"Features:",
" - Typed payload validation before invoking the adapter.",
" - Run-state + file cache so resumes do not re-call the LLM.",
" - Optional JSON-schema enforcement with bounded retries.",
"",
"Config:",
...config.helpConfig.map((line) => ` - ${line}`),
];
return `${lines.join('\n')}\n`;
return `${lines.join("\n")}\n`;
},
async run({ input, args, ctx }) {
return runLlmInvoke({ input, args, ctx, config });
@ -285,7 +305,17 @@ export function createLlmInvokeCommand(config: CommandConfig): LobsterCommand {
} satisfies LobsterCommand;
}
async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable<any>; args: any; ctx: any; config: CommandConfig }) {
async function runLlmInvoke({
input,
args,
ctx,
config,
}: {
input: AsyncIterable<any>;
args: any;
ctx: any;
config: CommandConfig;
}) {
const env = ctx.env ?? process.env;
const provider = resolveProvider(args, env, config.defaultProvider, ctx);
const adapter = resolveAdapter({ provider, env, args, config, ctx });
@ -294,31 +324,42 @@ async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable
const model = resolveModel(args, env, config.legacyEnvCompat);
const schemaVersion = resolveEnvString(
args['schema-version'],
['LOBSTER_LLM_SCHEMA_VERSION', ...(config.legacyEnvCompat ? ['LLM_TASK_SCHEMA_VERSION'] : [])],
args["schema-version"],
["LOBSTER_LLM_SCHEMA_VERSION", ...(config.legacyEnvCompat ? ["LLM_TASK_SCHEMA_VERSION"] : [])],
env,
'v1',
"v1",
);
const maxOutputTokens = parseOptionalNumber(args['max-output-tokens']);
const maxOutputTokens = parseOptionalNumber(args["max-output-tokens"]);
const temperature = parseOptionalNumber(args.temperature);
const providedArtifacts = parseJsonArray(args['artifacts-json'], `${config.name} --artifacts-json`);
const metadataObject = parseJsonObject(args['metadata-json'], `${config.name} --metadata-json`);
const userOutputSchema = parseJsonObject(args['output-schema'], `${config.name} --output-schema`);
const providedArtifacts = parseJsonArray(
args["artifacts-json"],
`${config.name} --artifacts-json`,
);
const metadataObject = parseJsonObject(args["metadata-json"], `${config.name} --metadata-json`);
const userOutputSchema = parseJsonObject(args["output-schema"], `${config.name} --output-schema`);
const maxValidationRetriesRaw =
args['max-validation-retries'] ??
getFirstEnv(env, ['LOBSTER_LLM_VALIDATION_RETRIES', ...(config.legacyEnvCompat ? ['LLM_TASK_VALIDATION_RETRIES'] : [])]);
args["max-validation-retries"] ??
getFirstEnv(env, [
"LOBSTER_LLM_VALIDATION_RETRIES",
...(config.legacyEnvCompat ? ["LLM_TASK_VALIDATION_RETRIES"] : []),
]);
const maxValidationRetries = userOutputSchema
? Math.max(
0,
Number.isFinite(Number(maxValidationRetriesRaw)) ? Number(maxValidationRetriesRaw) : DEFAULT_MAX_VALIDATION_RETRIES,
Number.isFinite(Number(maxValidationRetriesRaw))
? Number(maxValidationRetriesRaw)
: DEFAULT_MAX_VALIDATION_RETRIES,
)
: 0;
const disableCache = flag(args['disable-cache']);
const disableCache = flag(args["disable-cache"]);
const forceRefresh = flag(
args.refresh ??
getFirstEnv(env, ['LOBSTER_LLM_FORCE_REFRESH', ...(config.legacyEnvCompat ? ['LLM_TASK_FORCE_REFRESH'] : [])]),
getFirstEnv(env, [
"LOBSTER_LLM_FORCE_REFRESH",
...(config.legacyEnvCompat ? ["LLM_TASK_FORCE_REFRESH"] : []),
]),
);
const stateKey = String(args['state-key'] ?? env.LOBSTER_RUN_STATE_KEY ?? '').trim() || null;
const stateKey = String(args["state-key"] ?? env.LOBSTER_RUN_STATE_KEY ?? "").trim() || null;
const inputArtifacts: any[] = [];
for await (const item of input) inputArtifacts.push(item);
@ -339,7 +380,9 @@ async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable
const reused = pickReusableState(stored, cacheKey, config.stateType);
if (reused) {
return {
output: streamOf(reused.items.map((item) => ({ ...item, source: 'run_state', cached: true }))),
output: streamOf(
reused.items.map((item) => ({ ...item, source: "run_state", cached: true })),
),
};
}
}
@ -348,7 +391,7 @@ async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable
const cache = await readCacheEntry(env, cacheKey, config.cacheNamespace);
if (cache) {
return {
output: streamOf(cache.items.map((item) => ({ ...item, source: 'cache', cached: true }))),
output: streamOf(cache.items.map((item) => ({ ...item, source: "cache", cached: true }))),
};
}
}
@ -396,7 +439,7 @@ async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable
}
if (responseEnvelope.ok !== true) {
const message = responseEnvelope.error?.message ?? 'llm adapter returned an error';
const message = responseEnvelope.error?.message ?? "llm adapter returned an error";
throw new Error(`${config.name} remote error: ${message}`);
}
@ -411,29 +454,50 @@ async function runLlmInvoke({ input, args, ctx, config }: { input: AsyncIterable
});
if (!validator) {
await persistOutputs({ env, stateKey, cacheKey, items: normalized, stateType: config.stateType });
await persistOutputs({
env,
stateKey,
cacheKey,
items: normalized,
stateType: config.stateType,
});
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized, config.cacheNamespace);
return { output: streamOf(normalized) };
}
const structured = normalized[0]?.output?.data ?? null;
if (validator(structured)) {
await persistOutputs({ env, stateKey, cacheKey, items: normalized, stateType: config.stateType });
await persistOutputs({
env,
stateKey,
cacheKey,
items: normalized,
stateType: config.stateType,
});
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized, config.cacheNamespace);
return { output: streamOf(normalized) };
}
lastValidationErrors = collectAjvErrors(validator.errors);
if (attempt > maxValidationRetries + 1) {
throw new Error(`${config.name} output failed schema validation: ${lastValidationErrors.join('; ')}`);
throw new Error(
`${config.name} output failed schema validation: ${lastValidationErrors.join("; ")}`,
);
}
}
}
function resolveProvider(args: any, env: any, defaultProvider?: SupportedProvider | null, ctx?: any): SupportedProvider {
const explicit = String(args.provider ?? env.LOBSTER_LLM_PROVIDER ?? '').trim().toLowerCase();
function resolveProvider(
args: any,
env: any,
defaultProvider?: SupportedProvider | null,
ctx?: any,
): SupportedProvider {
const explicit = String(args.provider ?? env.LOBSTER_LLM_PROVIDER ?? "")
.trim()
.toLowerCase();
if (explicit) {
if (explicit === 'openclaw' || explicit === 'pi' || explicit === 'http') {
if (explicit === "openclaw" || explicit === "pi" || explicit === "http") {
return explicit;
}
if (getDirectAdapter(ctx, explicit)) {
@ -442,14 +506,17 @@ function resolveProvider(args: any, env: any, defaultProvider?: SupportedProvide
throw new Error(`Unsupported llm provider: ${explicit}`);
}
if (defaultProvider) return defaultProvider;
const directAdapters = ctx?.llmAdapters && typeof ctx.llmAdapters === 'object'
? Object.keys(ctx.llmAdapters).filter((key) => getDirectAdapter(ctx, key))
: [];
const directAdapters =
ctx?.llmAdapters && typeof ctx.llmAdapters === "object"
? Object.keys(ctx.llmAdapters).filter((key) => getDirectAdapter(ctx, key))
: [];
if (directAdapters.length === 1) return directAdapters[0];
if (String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? '').trim()) return 'pi';
if (String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? '').trim()) return 'openclaw';
if (String(env.LOBSTER_LLM_ADAPTER_URL ?? '').trim()) return 'http';
throw new Error('llm.invoke could not resolve a provider. Set --provider or LOBSTER_LLM_PROVIDER');
if (String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim()) return "pi";
if (String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim()) return "openclaw";
if (String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim()) return "http";
throw new Error(
"llm.invoke could not resolve a provider. Set --provider or LOBSTER_LLM_PROVIDER",
);
}
function resolveAdapter({
@ -467,55 +534,55 @@ function resolveAdapter({
}): Adapter {
const direct = getDirectAdapter(ctx, provider);
if (direct) {
const invoke = typeof direct === 'function' ? direct : direct.invoke;
const invoke = typeof direct === "function" ? direct : direct.invoke;
return {
provider,
source: typeof direct === 'function' ? provider : direct.source ?? provider,
source: typeof direct === "function" ? provider : (direct.source ?? provider),
async invoke({ payload }) {
return invoke({ env, args, payload, ctx });
},
};
}
if (provider === 'openclaw') {
const openclawUrl = String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? '').trim();
if (provider === "openclaw") {
const openclawUrl = String(env.OPENCLAW_URL ?? env.CLAWD_URL ?? "").trim();
if (!openclawUrl) {
throw new Error(`${config.name} requires OPENCLAW_URL (or CLAWD_URL) for provider=openclaw`);
}
const endpoint = new URL('/tools/invoke', openclawUrl);
const token = String(args.token ?? env.OPENCLAW_TOKEN ?? env.CLAWD_TOKEN ?? '').trim();
const endpoint = new URL("/tools/invoke", openclawUrl);
const token = String(args.token ?? env.OPENCLAW_TOKEN ?? env.CLAWD_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? 'openclaw',
source: config.sourceForProvider?.(provider) ?? "openclaw",
async invoke({ payload }) {
return invokeOpenClawAdapter({ endpoint, token, payload });
},
};
}
if (provider === 'pi') {
const adapterUrl = String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? '').trim();
if (provider === "pi") {
const adapterUrl = String(env.LOBSTER_PI_LLM_ADAPTER_URL ?? "").trim();
if (!adapterUrl) {
throw new Error(`${config.name} requires LOBSTER_PI_LLM_ADAPTER_URL for provider=pi`);
}
const token = String(args.token ?? env.LOBSTER_PI_LLM_ADAPTER_TOKEN ?? '').trim();
const token = String(args.token ?? env.LOBSTER_PI_LLM_ADAPTER_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? 'pi',
source: config.sourceForProvider?.(provider) ?? "pi",
async invoke({ payload }) {
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
},
};
}
const adapterUrl = String(env.LOBSTER_LLM_ADAPTER_URL ?? '').trim();
const adapterUrl = String(env.LOBSTER_LLM_ADAPTER_URL ?? "").trim();
if (!adapterUrl) {
throw new Error(`${config.name} requires LOBSTER_LLM_ADAPTER_URL for provider=http`);
}
const token = String(args.token ?? env.LOBSTER_LLM_ADAPTER_TOKEN ?? '').trim();
const token = String(args.token ?? env.LOBSTER_LLM_ADAPTER_TOKEN ?? "").trim();
return {
provider,
source: config.sourceForProvider?.(provider) ?? 'http',
source: config.sourceForProvider?.(provider) ?? "http",
async invoke({ payload }) {
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
},
@ -524,10 +591,10 @@ function resolveAdapter({
function getDirectAdapter(ctx: any, provider: string): DirectAdapter | null {
const adapters = ctx?.llmAdapters;
if (!adapters || typeof adapters !== 'object') return null;
if (!adapters || typeof adapters !== "object") return null;
const adapter = adapters[provider];
if (typeof adapter === 'function') return adapter as DirectAdapter;
if (adapter && typeof adapter === 'object' && typeof adapter.invoke === 'function') {
if (typeof adapter === "function") return adapter as DirectAdapter;
if (adapter && typeof adapter === "object" && typeof adapter.invoke === "function") {
return adapter as DirectAdapter;
}
return null;
@ -535,22 +602,30 @@ function getDirectAdapter(ctx: any, provider: string): DirectAdapter | null {
function buildAdapterEndpoint(rawUrl: string) {
const endpoint = new URL(rawUrl);
if (endpoint.pathname === '/' || endpoint.pathname === '') {
endpoint.pathname = '/invoke';
if (endpoint.pathname === "/" || endpoint.pathname === "") {
endpoint.pathname = "/invoke";
}
return endpoint;
}
async function invokeOpenClawAdapter({ endpoint, token, payload }: { endpoint: URL; token: string; payload: any }) {
async function invokeOpenClawAdapter({
endpoint,
token,
payload,
}: {
endpoint: URL;
token: string;
payload: any;
}) {
const res = await fetch(endpoint, {
method: 'POST',
method: "POST",
headers: {
'content-type': 'application/json',
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
tool: 'llm-task',
action: 'invoke',
tool: "llm-task",
action: "invoke",
args: payload,
}),
});
@ -564,16 +639,16 @@ async function invokeOpenClawAdapter({ endpoint, token, payload }: { endpoint: U
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new Error('Response was not JSON');
throw new Error("Response was not JSON");
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? 'Unknown error';
const msg = parsed?.error?.message ?? "Unknown error";
throw new Error(`openclaw adapter error: ${msg}`);
}
const inner = parsed.result;
if (inner && typeof inner === 'object' && !Array.isArray(inner) && 'ok' in inner) {
if (inner && typeof inner === "object" && !Array.isArray(inner) && "ok" in inner) {
return inner as LlmResponseEnvelope;
}
return { ok: true, result: inner } as LlmResponseEnvelope;
@ -582,11 +657,19 @@ async function invokeOpenClawAdapter({ endpoint, token, payload }: { endpoint: U
return { ok: true, result: parsed } as LlmResponseEnvelope;
}
async function invokeHttpAdapter({ endpoint, token, payload }: { endpoint: URL; token: string; payload: any }) {
async function invokeHttpAdapter({
endpoint,
token,
payload,
}: {
endpoint: URL;
token: string;
payload: any;
}) {
const res = await fetch(endpoint, {
method: 'POST',
method: "POST",
headers: {
'content-type': 'application/json',
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify(payload),
@ -601,10 +684,10 @@ async function invokeHttpAdapter({ endpoint, token, payload }: { endpoint: URL;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
throw new Error('Response was not JSON');
throw new Error("Response was not JSON");
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
return parsed as LlmResponseEnvelope;
}
return { ok: true, result: parsed } as LlmResponseEnvelope;
@ -613,9 +696,9 @@ async function invokeHttpAdapter({ endpoint, token, payload }: { endpoint: URL;
function resolveModel(args: any, env: any, legacyEnvCompat: boolean | undefined) {
return resolveEnvString(
args.model,
['LOBSTER_LLM_MODEL', ...(legacyEnvCompat ? ['LLM_TASK_MODEL'] : [])],
["LOBSTER_LLM_MODEL", ...(legacyEnvCompat ? ["LLM_TASK_MODEL"] : [])],
env,
'',
"",
);
}
@ -638,16 +721,16 @@ function getFirstEnv(env: any, keys: string[]) {
function extractPrompt(args: any) {
if (args.prompt) return String(args.prompt);
if (Array.isArray(args._) && args._.length) {
return args._.join(' ');
return args._.join(" ");
}
return '';
return "";
}
function parseJsonArray(raw: any, label: string) {
if (!raw) return [];
try {
const parsed = JSON.parse(String(raw));
if (!Array.isArray(parsed)) throw new Error('must be array');
if (!Array.isArray(parsed)) throw new Error("must be array");
return parsed;
} catch {
throw new Error(`${label} must be a JSON array`);
@ -658,8 +741,8 @@ function parseJsonObject(raw: any, label: string) {
if (!raw) return null;
try {
const parsed = JSON.parse(String(raw));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('must be an object');
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("must be an object");
}
return parsed;
} catch {
@ -675,31 +758,31 @@ function parseOptionalNumber(value: any) {
function flag(value: any) {
if (value === undefined || value === null) return false;
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no'].includes(normalized)) return false;
if (['true', '1', 'yes'].includes(normalized)) return true;
if (["false", "0", "no"].includes(normalized)) return false;
if (["true", "1", "yes"].includes(normalized)) return true;
}
return Boolean(value);
}
function normalizeArtifact(raw: any) {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
return raw;
}
if (typeof raw === 'string') {
return { kind: 'text', text: raw };
if (typeof raw === "string") {
return { kind: "text", text: raw };
}
if (typeof raw === 'number' || typeof raw === 'boolean') {
return { kind: 'text', text: String(raw) };
if (typeof raw === "number" || typeof raw === "boolean") {
return { kind: "text", text: String(raw) };
}
return { kind: 'json', data: raw };
return { kind: "json", data: raw };
}
function hashArtifact(artifact: any) {
const stable = stableStringify(artifact);
return createHash('sha256').update(stable).digest('hex');
return createHash("sha256").update(stable).digest("hex");
}
function computeCacheKey({
@ -725,7 +808,7 @@ function computeCacheKey({
artifactHashes,
outputSchema: outputSchema ?? null,
};
return createHash('sha256').update(stableStringify(payload)).digest('hex');
return createHash("sha256").update(stableStringify(payload)).digest("hex");
}
function normalizeResult({
@ -753,11 +836,11 @@ function normalizeResult({
prompt: (result.prompt ?? null) as any,
model: (result.model ?? null) as any,
schemaVersion,
status: String(result.status ?? 'completed'),
status: String(result.status ?? "completed"),
cacheKey,
artifactHashes,
output: {
format: (output.format ?? (output.data ? 'json' : 'text')) as any,
format: (output.format ?? (output.data ? "json" : "text")) as any,
text: (output.text ?? null) as any,
data: (output.data ?? null) as any,
},
@ -767,7 +850,12 @@ function normalizeResult({
diagnostics: (result.diagnostics ?? null) as any,
createdAt: new Date().toISOString(),
source,
cached: source !== 'remote' && source !== 'openclaw' && source !== 'clawd' && source !== 'pi' && source !== 'http',
cached:
source !== "remote" &&
source !== "openclaw" &&
source !== "clawd" &&
source !== "pi" &&
source !== "http",
attemptCount: attempt,
};
return [item];
@ -798,7 +886,7 @@ async function persistOutputs({
}
function pickReusableState(stored: any, cacheKey: string, stateType: string) {
if (!stored || typeof stored !== 'object') return null;
if (!stored || typeof stored !== "object") return null;
if (stored.type !== stateType) return null;
if (stored.cacheKey !== cacheKey) return null;
if (!Array.isArray(stored.items)) return null;
@ -807,21 +895,30 @@ function pickReusableState(stored: any, cacheKey: string, stateType: string) {
function collectAjvErrors(errors: ErrorObject[] | null | undefined) {
if (!errors?.length) return [];
return errors.map((err) => `${err.instancePath || '/'} ${err.message ?? ''}`.trim());
return errors.map((err) => `${err.instancePath || "/"} ${err.message ?? ""}`.trim());
}
async function readCacheEntry(env: any, key: string, cacheNamespace: string): Promise<CacheEntry | null> {
async function readCacheEntry(
env: any,
key: string,
cacheNamespace: string,
): Promise<CacheEntry | null> {
const filePath = path.join(getCacheDir(env), cacheNamespace, `${key}.json`);
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
return JSON.parse(text) as CacheEntry;
} catch (err: any) {
if (err?.code === 'ENOENT') return null;
if (err?.code === "ENOENT") return null;
throw err;
}
}
async function writeCacheEntry(env: any, key: string, items: NormalizedInvocationItem[], cacheNamespace: string) {
async function writeCacheEntry(
env: any,
key: string,
items: NormalizedInvocationItem[],
cacheNamespace: string,
) {
const dir = path.join(getCacheDir(env), cacheNamespace);
await fsp.mkdir(dir, { recursive: true });
const filePath = path.join(dir, `${key}.json`);
@ -833,7 +930,7 @@ async function writeCacheEntry(env: any, key: string, items: NormalizedInvocatio
function getCacheDir(env: any) {
if (env?.LOBSTER_CACHE_DIR) return String(env.LOBSTER_CACHE_DIR);
return path.join(process.cwd(), '.lobster-cache');
return path.join(process.cwd(), ".lobster-cache");
}
async function* streamOf(items: any[]) {

View File

@ -1 +1 @@
export { llmTaskInvokeCommand } from './llm_invoke.js';
export { llmTaskInvokeCommand } from "./llm_invoke.js";

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (path === '.' || path === 'this') return obj;
const parts = path.split('.').filter(Boolean);
if (path === "." || path === "this") return obj;
const parts = path.split(".").filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -11,10 +11,10 @@ function getByPath(obj: any, path: string): any {
function renderTemplate(tpl: string, ctx: any): string {
return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
const key = String(expr ?? '').trim();
const key = String(expr ?? "").trim();
const val = getByPath(ctx, key);
if (val === undefined || val === null) return '';
if (typeof val === 'string') return val;
if (val === undefined || val === null) return "";
if (typeof val === "string") return val;
return JSON.stringify(val);
});
}
@ -23,7 +23,7 @@ function parseAssignments(tokens: any[]): Array<{ key: string; value: string }>
const out: Array<{ key: string; value: string }> = [];
for (const tok of tokens ?? []) {
const s = String(tok);
const idx = s.indexOf('=');
const idx = s.indexOf("=");
if (idx === -1) continue;
const key = s.slice(0, idx).trim();
const value = s.slice(idx + 1);
@ -34,15 +34,19 @@ function parseAssignments(tokens: any[]): Array<{ key: string; value: string }>
}
export const mapCommand = {
name: 'map',
name: "map",
meta: {
description: 'Transform items (wrap/unwrap/add fields)',
description: "Transform items (wrap/unwrap/add fields)",
argsSchema: {
type: 'object',
type: "object",
properties: {
wrap: { type: 'string', description: 'Wrap each item as {wrap: item}' },
unwrap: { type: 'string', description: 'Unwrap a field (yield item[unwrap])' },
_: { type: 'array', items: { type: 'string' }, description: 'Optional assignments like key=value (value supports {{path}})' },
wrap: { type: "string", description: "Wrap each item as {wrap: item}" },
unwrap: { type: "string", description: "Unwrap a field (yield item[unwrap])" },
_: {
type: "array",
items: { type: "string" },
description: "Optional assignments like key=value (value supports {{path}})",
},
},
required: [],
},
@ -61,11 +65,11 @@ export const mapCommand = {
);
},
async run({ input, args }: any) {
const wrap = typeof args.wrap === 'string' ? args.wrap : undefined;
const unwrap = typeof args.unwrap === 'string' ? args.unwrap : undefined;
const wrap = typeof args.wrap === "string" ? args.wrap : undefined;
const unwrap = typeof args.unwrap === "string" ? args.unwrap : undefined;
const assignments = parseAssignments(Array.isArray(args._) ? args._ : []);
if (wrap && unwrap) throw new Error('map cannot use both --wrap and --unwrap');
if (wrap && unwrap) throw new Error("map cannot use both --wrap and --unwrap");
return {
output: (async function* () {
@ -73,7 +77,7 @@ export const mapCommand = {
let cur: any = item;
if (unwrap) {
if (cur && typeof cur === 'object') cur = cur[unwrap];
if (cur && typeof cur === "object") cur = cur[unwrap];
else cur = undefined;
yield cur;
continue;
@ -84,7 +88,7 @@ export const mapCommand = {
}
if (assignments.length > 0) {
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) {
if (cur === null || typeof cur !== "object" || Array.isArray(cur)) {
// If current is not an object, turn it into one so we can attach fields.
cur = { value: cur };
}

View File

@ -2,30 +2,33 @@ function createInvokeCommand(commandName: string) {
return {
name: commandName,
meta: {
description: 'Call a local OpenClaw tool endpoint',
description: "Call a local OpenClaw tool endpoint",
argsSchema: {
type: 'object',
type: "object",
properties: {
url: {
type: 'string',
description: 'OpenClaw control URL (or OPENCLAW_URL / CLAWD_URL)',
type: "string",
description: "OpenClaw control URL (or OPENCLAW_URL / CLAWD_URL)",
},
token: { type: 'string', description: 'Bearer token (or OPENCLAW_TOKEN / CLAWD_TOKEN)' },
tool: { type: 'string', description: 'Tool name (e.g. message, cron, github, etc.)' },
action: { type: 'string', description: 'Tool action' },
'args-json': { type: 'string', description: 'JSON string of tool args' },
sessionKey: { type: 'string', description: 'Optional session key attribution' },
'session-key': { type: 'string', description: 'Alias for sessionKey' },
dryRun: { type: 'boolean', description: 'Dry run' },
'dry-run': { type: 'boolean', description: 'Alias for dryRun' },
each: { type: 'boolean', description: 'Map each pipeline item into tool args' },
itemKey: { type: 'string', description: 'Key to set from the pipeline item (default: item)' },
'item-key': { type: 'string', description: 'Alias for itemKey' },
_: { type: 'array', items: { type: 'string' } },
token: { type: "string", description: "Bearer token (or OPENCLAW_TOKEN / CLAWD_TOKEN)" },
tool: { type: "string", description: "Tool name (e.g. message, cron, github, etc.)" },
action: { type: "string", description: "Tool action" },
"args-json": { type: "string", description: "JSON string of tool args" },
sessionKey: { type: "string", description: "Optional session key attribution" },
"session-key": { type: "string", description: "Alias for sessionKey" },
dryRun: { type: "boolean", description: "Dry run" },
"dry-run": { type: "boolean", description: "Alias for dryRun" },
each: { type: "boolean", description: "Map each pipeline item into tool args" },
itemKey: {
type: "string",
description: "Key to set from the pipeline item (default: item)",
},
"item-key": { type: "string", description: "Alias for itemKey" },
_: { type: "array", items: { type: "string" } },
},
required: ['tool', 'action'],
required: ["tool", "action"],
},
sideEffects: ['calls_clawd_tool'],
sideEffects: ["calls_clawd_tool"],
},
help() {
return (
@ -46,39 +49,41 @@ function createInvokeCommand(commandName: string) {
},
async run({ input, args, ctx }) {
const each = Boolean(args.each);
const itemKey = String(args.itemKey ?? args['item-key'] ?? 'item');
const itemKey = String(args.itemKey ?? args["item-key"] ?? "item");
const url = String(args.url ?? ctx.env.OPENCLAW_URL ?? ctx.env.CLAWD_URL ?? '').trim();
const url = String(args.url ?? ctx.env.OPENCLAW_URL ?? ctx.env.CLAWD_URL ?? "").trim();
if (!url) throw new Error(`${commandName} requires --url or OPENCLAW_URL`);
const tool = args.tool;
const action = args.action;
if (!tool || !action) throw new Error(`${commandName} requires --tool and --action`);
const token = String(args.token ?? ctx.env.OPENCLAW_TOKEN ?? ctx.env.CLAWD_TOKEN ?? '').trim();
const token = String(
args.token ?? ctx.env.OPENCLAW_TOKEN ?? ctx.env.CLAWD_TOKEN ?? "",
).trim();
let toolArgs: any = {};
if (args['args-json']) {
if (args["args-json"]) {
try {
toolArgs = JSON.parse(String(args['args-json']));
toolArgs = JSON.parse(String(args["args-json"]));
} catch (_err) {
throw new Error(`${commandName} --args-json must be valid JSON`);
}
}
if (each && (toolArgs === null || typeof toolArgs !== 'object' || Array.isArray(toolArgs))) {
if (each && (toolArgs === null || typeof toolArgs !== "object" || Array.isArray(toolArgs))) {
throw new Error(`${commandName} --each requires --args-json to be an object`);
}
const endpoint = new URL('/tools/invoke', url);
const sessionKey = args.sessionKey ?? args['session-key'] ?? null;
const dryRun = args.dryRun ?? args['dry-run'] ?? null;
const endpoint = new URL("/tools/invoke", url);
const sessionKey = args.sessionKey ?? args["session-key"] ?? null;
const dryRun = args.dryRun ?? args["dry-run"] ?? null;
const invokeOnce = async (argsValue: unknown) => {
const res = await fetch(endpoint, {
method: 'POST',
method: "POST",
headers: {
'content-type': 'application/json',
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : null),
},
body: JSON.stringify({
@ -103,9 +108,9 @@ function createInvokeCommand(commandName: string) {
}
// Preferred: { ok: true, result: ... }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
if (parsed.ok !== true) {
const msg = parsed?.error?.message ?? 'Unknown error';
const msg = parsed?.error?.message ?? "Unknown error";
throw new Error(`${commandName} tool error: ${msg}`);
}
const result = parsed.result;
@ -141,5 +146,5 @@ async function* asStream(items: any[]) {
for (const item of items) yield item;
}
export const openclawInvokeCommand = createInvokeCommand('openclaw.invoke');
export const clawdInvokeCommand = createInvokeCommand('clawd.invoke');
export const openclawInvokeCommand = createInvokeCommand("openclaw.invoke");
export const clawdInvokeCommand = createInvokeCommand("clawd.invoke");

View File

@ -1,17 +1,17 @@
export const pickCommand = {
name: 'pick',
name: "pick",
meta: {
description: 'Project fields from objects',
description: "Project fields from objects",
argsSchema: {
type: 'object',
type: "object",
properties: {
_: {
type: 'array',
items: { type: 'string' },
description: 'First positional arg is a comma-separated list of fields',
type: "array",
items: { type: "string" },
description: "First positional arg is a comma-separated list of fields",
},
},
required: ['_'],
required: ["_"],
},
sideEffects: [],
},
@ -20,13 +20,16 @@ export const pickCommand = {
},
async run({ input, args }) {
const spec = args._[0];
if (!spec) throw new Error('pick requires a comma-separated field list');
const fields = spec.split(',').map((s) => s.trim()).filter(Boolean);
if (!spec) throw new Error("pick requires a comma-separated field list");
const fields = spec
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
output: (async function* () {
for await (const item of input) {
if (item === null || typeof item !== 'object') {
if (item === null || typeof item !== "object") {
yield item;
continue;
}

View File

@ -1,6 +1,6 @@
function getByPath(obj: any, path: string): any {
if (!path) return undefined;
const parts = path.split('.').filter(Boolean);
const parts = path.split(".").filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -18,7 +18,7 @@ function defaultCompare(a: any, b: any): number {
if (bU) return -1;
// number compare if both numbers
if (typeof a === 'number' && typeof b === 'number') return a - b;
if (typeof a === "number" && typeof b === "number") return a - b;
// Deterministic lexical compare independent of process locale.
const aStr = String(a);
@ -29,15 +29,15 @@ function defaultCompare(a: any, b: any): number {
}
export const sortCommand = {
name: 'sort',
name: "sort",
meta: {
description: 'Sort items (stable) by a key or by stringified value',
description: "Sort items (stable) by a key or by stringified value",
argsSchema: {
type: 'object',
type: "object",
properties: {
key: { type: 'string', description: 'Dot-path key to sort by (e.g. updatedAt, pr.number)' },
desc: { type: 'boolean', description: 'Sort descending' },
_: { type: 'array', items: { type: 'string' } },
key: { type: "string", description: "Dot-path key to sort by (e.g. updatedAt, pr.number)" },
desc: { type: "boolean", description: "Sort descending" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -56,7 +56,7 @@ export const sortCommand = {
);
},
async run({ input, args }: any) {
const key = typeof args.key === 'string' ? args.key : undefined;
const key = typeof args.key === "string" ? args.key : undefined;
const desc = Boolean(args.desc);
const items: any[] = [];

View File

@ -1,36 +1,36 @@
import { promises as fsp } from 'node:fs';
import { promises as fsp } from "node:fs";
import { defaultStateDir, keyToPath } from '../../state/store.js';
import { defaultStateDir, keyToPath } from "../../state/store.js";
export const stateGetCommand = {
name: 'state.get',
name: "state.get",
meta: {
description: 'Read a JSON value from Lobster state',
description: "Read a JSON value from Lobster state",
argsSchema: {
type: 'object',
type: "object",
properties: {
_: { type: 'array', items: { type: 'string' }, description: 'Key' },
_: { type: "array", items: { type: "string" }, description: "Key" },
},
required: ['_'],
required: ["_"],
},
sideEffects: ['reads_state'],
sideEffects: ["reads_state"],
},
help() {
return `state.get — read a JSON value from Lobster state\n\nUsage:\n state.get <key>\n\nEnv:\n LOBSTER_STATE_DIR overrides storage directory\n`;
},
async run({ args, ctx }) {
const key = args._[0];
if (!key) throw new Error('state.get requires a key');
if (!key) throw new Error("state.get requires a key");
const stateDir = defaultStateDir(ctx.env);
const filePath = keyToPath(stateDir, key);
let value = null;
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
value = JSON.parse(text);
} catch (err) {
if (err?.code === 'ENOENT') {
if (err?.code === "ENOENT") {
value = null;
} else {
throw err;
@ -42,24 +42,24 @@ export const stateGetCommand = {
};
export const stateSetCommand = {
name: 'state.set',
name: "state.set",
meta: {
description: 'Write a JSON value to Lobster state',
description: "Write a JSON value to Lobster state",
argsSchema: {
type: 'object',
type: "object",
properties: {
_: { type: 'array', items: { type: 'string' }, description: 'Key' },
_: { type: "array", items: { type: "string" }, description: "Key" },
},
required: ['_'],
required: ["_"],
},
sideEffects: ['writes_state'],
sideEffects: ["writes_state"],
},
help() {
return `state.set — write a JSON value to Lobster state\n\nUsage:\n <value> | state.set <key>\n\nNotes:\n - Consumes the entire input stream; stores a single JSON value.\n`;
},
async run({ input, args, ctx }) {
const key = args._[0];
if (!key) throw new Error('state.set requires a key');
if (!key) throw new Error("state.set requires a key");
const items = [];
for await (const item of input) items.push(item);
@ -70,7 +70,7 @@ export const stateSetCommand = {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
return { output: asStream([value]) };
},

View File

@ -1,15 +1,15 @@
function stringifyCell(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v === null || v === undefined) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
export const tableCommand = {
name: 'table',
name: "table",
meta: {
description: 'Render items as a simple table',
argsSchema: { type: 'object', properties: {}, required: [] },
description: "Render items as a simple table",
argsSchema: { type: "object", properties: {}, required: [] },
sideEffects: [],
},
help() {
@ -20,12 +20,12 @@ export const tableCommand = {
for await (const item of input) items.push(item);
if (items.length === 0) {
ctx.stdout.write('(no results)\n');
ctx.stdout.write("(no results)\n");
return { output: emptyStream(), rendered: true };
}
const sample = items.slice(0, 20);
const objectItems = sample.filter((x) => x && typeof x === 'object' && !Array.isArray(x));
const objectItems = sample.filter((x) => x && typeof x === "object" && !Array.isArray(x));
if (objectItems.length === sample.length) {
const cols = [];
@ -39,21 +39,22 @@ export const tableCommand = {
}
}
const rows = [cols, ...items.map((it) => cols.map((c) => stringifyCell(it?.[c])))]
.map((row) => row.map((cell) => cell.replace(/\n/g, ' ')));
const rows = [cols, ...items.map((it) => cols.map((c) => stringifyCell(it?.[c])))].map(
(row) => row.map((cell) => cell.replace(/\n/g, " ")),
);
const widths = cols.map((_, i) => Math.max(...rows.map((r) => r[i].length), 3));
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(' ');
ctx.stdout.write(renderRow(rows[0]) + '\n');
ctx.stdout.write(widths.map((w) => '-'.repeat(w)).join(' ') + '\n');
for (const row of rows.slice(1)) ctx.stdout.write(renderRow(row) + '\n');
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
ctx.stdout.write(renderRow(rows[0]) + "\n");
ctx.stdout.write(widths.map((w) => "-".repeat(w)).join(" ") + "\n");
for (const row of rows.slice(1)) ctx.stdout.write(renderRow(row) + "\n");
return { output: emptyStream(), rendered: true };
}
// Fallback: render each item on a line.
for (const item of items) ctx.stdout.write(stringifyCell(item) + '\n');
for (const item of items) ctx.stdout.write(stringifyCell(item) + "\n");
return { output: emptyStream(), rendered: true };
},
};

View File

@ -1,8 +1,9 @@
import fs from 'node:fs/promises';
import fs from "node:fs/promises";
import { applyFilters } from "../../core/filters.js";
function getByPath(obj: any, path: string): any {
if (path === '.' || path === 'this') return obj;
const parts = path.split('.').filter(Boolean);
if (path === "." || path === "this") return obj;
const parts = path.split(".").filter(Boolean);
let cur: any = obj;
for (const p of parts) {
if (cur == null) return undefined;
@ -11,26 +12,72 @@ function getByPath(obj: any, path: string): any {
return cur;
}
function splitFilterChain(expr: string): string[] {
const parts: string[] = [];
let current = "";
let i = 0;
while (i < expr.length) {
const ch = expr[i];
if (ch === '"' || ch === "'") {
const quote = ch;
current += ch;
i += 1;
while (i < expr.length && expr[i] !== quote) {
if (expr[i] === "\\" && i + 1 < expr.length) {
current += expr[i] + expr[i + 1];
i += 2;
} else {
current += expr[i];
i += 1;
}
}
if (i < expr.length) {
current += expr[i];
i += 1;
}
} else if (ch === "|") {
parts.push(current.trim());
current = "";
i += 1;
} else {
current += ch;
i += 1;
}
}
parts.push(current.trim());
return parts;
}
function renderTemplate(tpl: string, ctx: any): string {
return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
const key = String(expr ?? '').trim();
const val = getByPath(ctx, key);
if (val === undefined || val === null) return '';
if (typeof val === 'string') return val;
const rawExpr = String(expr ?? "").trim();
const parts = splitFilterChain(rawExpr);
const key = parts[0];
let val: unknown = getByPath(ctx, key);
if (parts.length > 1) {
val = applyFilters(val, parts.slice(1));
}
if (val === undefined || val === null) return "";
if (typeof val === "string") return val;
if (typeof val === "number" || typeof val === "boolean") return String(val);
return JSON.stringify(val);
});
}
export const templateCommand = {
name: 'template',
name: "template",
meta: {
description: 'Render a simple {{path}} template against each input item',
description: "Render a simple {{path}} template against each input item",
argsSchema: {
type: 'object',
type: "object",
properties: {
text: { type: 'string', description: 'Template text (supports {{path}}; {{.}} for the whole item)' },
file: { type: 'string', description: 'Template file path' },
_: { type: 'array', items: { type: 'string' } },
text: {
type: "string",
description:
"Template text (supports {{path}}, {{path | filter}}, {{.}} for the whole item)",
},
file: { type: "string", description: "Template file path" },
_: { type: "array", items: { type: "string" } },
},
required: [],
},
@ -45,23 +92,28 @@ export const templateCommand = {
`Template syntax:\n` +
` - {{field}} or {{nested.field}}\n` +
` - {{.}} for the whole item\n` +
` - Missing values render as empty string\n`
` - {{field | filter}} with pipe-based filters\n` +
` - Missing values render as empty string\n\n` +
`Filters:\n` +
` upper, lower, trim, truncate N, replace "from" "to", split sep\n` +
` first, last, length, join sep\n` +
` json, string, default val, round N, date fmt\n`
);
},
async run({ input, args }: any) {
let tpl = typeof args.text === 'string' ? args.text : undefined;
const file = typeof args.file === 'string' ? args.file : undefined;
let tpl = typeof args.text === "string" ? args.text : undefined;
const file = typeof args.file === "string" ? args.file : undefined;
if (!tpl && file) {
tpl = await fs.readFile(file, 'utf8');
tpl = await fs.readFile(file, "utf8");
}
if (!tpl) {
const positional = Array.isArray(args._) ? args._ : [];
if (positional.length) tpl = positional.join(' ');
if (positional.length) tpl = positional.join(" ");
}
if (!tpl) throw new Error('template requires --text or --file (or positional text)');
if (!tpl) throw new Error("template requires --text or --file (or positional text)");
return {
output: (async function* () {

View File

@ -4,19 +4,19 @@ function parsePredicate(expr) {
const [, path, op, rawValue] = m;
let value = rawValue;
if (rawValue === 'true') value = true;
else if (rawValue === 'false') value = false;
else if (rawValue === 'null') value = null;
else if (!Number.isNaN(Number(rawValue)) && rawValue.trim() !== '') value = Number(rawValue);
if (rawValue === "true") value = true;
else if (rawValue === "false") value = false;
else if (rawValue === "null") value = null;
else if (!Number.isNaN(Number(rawValue)) && rawValue.trim() !== "") value = Number(rawValue);
return { path, op: op === '=' ? '==' : op, value };
return { path, op: op === "=" ? "==" : op, value };
}
function getPath(obj, path) {
const parts = path.split('.');
const parts = path.split(".");
let cur = obj;
for (const p of parts) {
if (cur === null || typeof cur !== 'object') return undefined;
if (cur === null || typeof cur !== "object") return undefined;
cur = cur[p];
}
return cur;
@ -24,30 +24,37 @@ function getPath(obj, path) {
function compare(left, op, right) {
switch (op) {
case '==': return left == right; // intentional loose equality for convenience
case '!=': return left != right;
case '<': return left < right;
case '<=': return left <= right;
case '>': return left > right;
case '>=': return left >= right;
default: throw new Error(`Unsupported operator: ${op}`);
case "==":
return left == right; // intentional loose equality for convenience
case "!=":
return left != right;
case "<":
return left < right;
case "<=":
return left <= right;
case ">":
return left > right;
case ">=":
return left >= right;
default:
throw new Error(`Unsupported operator: ${op}`);
}
}
export const whereCommand = {
name: 'where',
name: "where",
meta: {
description: 'Filter objects by a simple predicate',
description: "Filter objects by a simple predicate",
argsSchema: {
type: 'object',
type: "object",
properties: {
_: {
type: 'array',
items: { type: 'string' },
description: 'First positional arg is an expression like field=value or minutes>=30',
type: "array",
items: { type: "string" },
description: "First positional arg is an expression like field=value or minutes>=30",
},
},
required: ['_'],
required: ["_"],
},
sideEffects: [],
},
@ -56,7 +63,7 @@ export const whereCommand = {
},
async run({ input, args }) {
const expr = args._[0];
if (!expr) throw new Error('where requires an expression (e.g. field=value)');
if (!expr) throw new Error("where requires an expression (e.g. field=value)");
const pred = parsePredicate(expr);
return {

View File

@ -1,10 +1,10 @@
import { listWorkflows } from '../../workflows/registry.js';
import { listWorkflows } from "../../workflows/registry.js";
export const workflowsListCommand = {
name: 'workflows.list',
name: "workflows.list",
meta: {
description: 'List available Lobster workflows',
argsSchema: { type: 'object', properties: {}, required: [] },
description: "List available Lobster workflows",
argsSchema: { type: "object", properties: {}, required: [] },
sideEffects: [],
},
help() {

View File

@ -1,27 +1,29 @@
import { workflowRegistry } from '../../workflows/registry.js';
import { runGithubPrMonitorWorkflow, runGithubPrMonitorNotifyWorkflow } from '../../workflows/github_pr_monitor.js';
import { workflowRegistry } from "../../workflows/registry.js";
import {
runGithubPrMonitorWorkflow,
runGithubPrMonitorNotifyWorkflow,
} from "../../workflows/github_pr_monitor.js";
const runners = {
'github.pr.monitor': runGithubPrMonitorWorkflow,
'github.pr.monitor.notify': runGithubPrMonitorNotifyWorkflow,
"github.pr.monitor": runGithubPrMonitorWorkflow,
"github.pr.monitor.notify": runGithubPrMonitorNotifyWorkflow,
};
// Recipe runners - adapt SDK recipes to workflow runner interface
const recipeRunners = {};
export const workflowsRunCommand = {
name: 'workflows.run',
name: "workflows.run",
meta: {
description: 'Run a named Lobster workflow',
description: "Run a named Lobster workflow",
argsSchema: {
type: 'object',
type: "object",
properties: {
name: { type: 'string', description: 'Workflow name' },
'args-json': { type: 'string', description: 'JSON string of workflow args' },
_: { type: 'array', items: { type: 'string' } },
name: { type: "string", description: "Workflow name" },
"args-json": { type: "string", description: "JSON string of workflow args" },
_: { type: "array", items: { type: "string" } },
},
required: ['name'],
required: ["name"],
},
sideEffects: [],
},
@ -35,17 +37,17 @@ export const workflowsRunCommand = {
}
const name = args.name ?? args._[0];
if (!name) throw new Error('workflows.run requires --name');
if (!name) throw new Error("workflows.run requires --name");
// Check for recipe-based workflow first
const recipeRunner = recipeRunners[name];
if (recipeRunner) {
let workflowArgs = {};
if (args['args-json']) {
if (args["args-json"]) {
try {
workflowArgs = JSON.parse(String(args['args-json']));
workflowArgs = JSON.parse(String(args["args-json"]));
} catch {
throw new Error('workflows.run --args-json must be valid JSON');
throw new Error("workflows.run --args-json must be valid JSON");
}
}
const result = await recipeRunner({ args: workflowArgs, ctx });
@ -60,11 +62,11 @@ export const workflowsRunCommand = {
if (!runner) throw new Error(`Workflow runner not implemented: ${name}`);
let workflowArgs = {};
if (args['args-json']) {
if (args["args-json"]) {
try {
workflowArgs = JSON.parse(String(args['args-json']));
workflowArgs = JSON.parse(String(args["args-json"]));
} catch {
throw new Error('workflows.run --args-json must be valid JSON');
throw new Error("workflows.run --args-json must be valid JSON");
}
}

111
src/core/cost_tracker.ts Normal file
View File

@ -0,0 +1,111 @@
export type StepCost = {
stepId: string;
model: string | null;
inputTokens: number;
outputTokens: number;
costUsd: number;
};
export type CostSummary = {
totalInputTokens: number;
totalOutputTokens: number;
estimatedCostUsd: number;
byStep: StepCost[];
};
export type CostLimit = {
max_usd: number;
action?: "warn" | "stop";
};
const DEFAULT_PRICING: Record<string, { input: number; output: number }> = {
"gpt-4o": { input: 2.5, output: 10.0 },
"gpt-4o-mini": { input: 0.15, output: 0.6 },
"gpt-4-turbo": { input: 10.0, output: 30.0 },
"gpt-3.5-turbo": { input: 0.5, output: 1.5 },
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
"claude-sonnet-4-5-20250514": { input: 3.0, output: 15.0 },
"claude-haiku-3-5": { input: 0.8, output: 4.0 },
"gemini-1.5-pro": { input: 1.25, output: 5.0 },
"gemini-1.5-flash": { input: 0.075, output: 0.3 },
};
function toTokenCount(value: unknown): number {
const parsed = Number(value ?? 0);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.floor(parsed);
}
export class CostTracker {
private steps: StepCost[] = [];
private pricing: Record<string, { input: number; output: number }>;
constructor(customPricing?: Record<string, { input: number; output: number }>) {
this.pricing = { ...DEFAULT_PRICING, ...(customPricing ?? {}) };
}
recordUsage(stepId: string, model: string | null, usage: Record<string, unknown>) {
const inputTokens = toTokenCount(
usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens,
);
const outputTokens = toTokenCount(
usage.outputTokens ?? usage.output_tokens ?? usage.completion_tokens,
);
const pricing = this.pricing[model ?? ""] ?? { input: 0, output: 0 };
const costUsd = (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
this.steps.push({ stepId, model, inputTokens, outputTokens, costUsd });
}
getSummary(): CostSummary {
let totalInputTokens = 0;
let totalOutputTokens = 0;
let estimatedCostUsd = 0;
for (const step of this.steps) {
totalInputTokens += step.inputTokens;
totalOutputTokens += step.outputTokens;
estimatedCostUsd += step.costUsd;
}
return {
totalInputTokens,
totalOutputTokens,
estimatedCostUsd: Math.round(estimatedCostUsd * 1_000_000) / 1_000_000,
byStep: [...this.steps],
};
}
hasUsage() {
return this.steps.length > 0;
}
checkLimit(limit: CostLimit, stderr?: NodeJS.WritableStream) {
const summary = this.getSummary();
if (summary.estimatedCostUsd <= limit.max_usd) return;
if (limit.action === "stop") {
throw new Error(
`Cost limit exceeded: $${summary.estimatedCostUsd.toFixed(4)} > $${limit.max_usd.toFixed(2)} limit`,
);
}
if (stderr) {
stderr.write(
`[WARN] Cost $${summary.estimatedCostUsd.toFixed(4)} exceeds limit $${limit.max_usd.toFixed(2)}\n`,
);
}
}
static parsePricingFromEnv(
env: Record<string, string | undefined>,
): Record<string, { input: number; output: number }> | undefined {
const raw = env.LOBSTER_LLM_PRICING_JSON;
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
}

101
src/core/filters.ts Normal file
View File

@ -0,0 +1,101 @@
export type FilterFn = (value: unknown, ...args: string[]) => unknown;
const FILTERS = new Map<string, FilterFn>();
FILTERS.set("upper", (v) => String(v ?? "").toUpperCase());
FILTERS.set("lower", (v) => String(v ?? "").toLowerCase());
FILTERS.set("trim", (v) => String(v ?? "").trim());
FILTERS.set("truncate", (v, n) => {
const s = String(v ?? "");
const parsed = parseInt(n ?? "", 10);
const len = Number.isNaN(parsed) ? 80 : parsed;
return s.length > len ? `${s.slice(0, len)}...` : s;
});
FILTERS.set("replace", (v, from, to) => String(v ?? "").replaceAll(from ?? "", to ?? ""));
FILTERS.set("split", (v, sep) => String(v ?? "").split(sep ?? ","));
FILTERS.set("first", (v) => (Array.isArray(v) ? v[0] : v));
FILTERS.set("last", (v) => (Array.isArray(v) ? v[v.length - 1] : v));
FILTERS.set("length", (v) => {
if (Array.isArray(v)) return v.length;
if (typeof v === "string") return v.length;
return 0;
});
FILTERS.set("join", (v, sep) => (Array.isArray(v) ? v.join(sep ?? ", ") : String(v ?? "")));
FILTERS.set("json", (v) => JSON.stringify(v, null, 2));
FILTERS.set("string", (v) => String(v ?? ""));
FILTERS.set("default", (v, def) => (v == null || v === "" ? def : v));
FILTERS.set("round", (v, n) => {
const num = Number(v);
const dec = parseInt(n ?? "", 10) || 0;
return Number.isNaN(num) ? v : Number(num.toFixed(dec));
});
FILTERS.set("date", (v, fmt) => {
const d =
typeof v === "number" || (typeof v === "string" && /^\d+$/.test(v.trim()))
? new Date(Number(v))
: new Date(String(v));
if (Number.isNaN(d.getTime())) return String(v);
if (!fmt) return d.toISOString();
return fmt
.replace("YYYY", String(d.getUTCFullYear()))
.replace("MM", String(d.getUTCMonth() + 1).padStart(2, "0"))
.replace("DD", String(d.getUTCDate()).padStart(2, "0"))
.replace("HH", String(d.getUTCHours()).padStart(2, "0"))
.replace("mm", String(d.getUTCMinutes()).padStart(2, "0"))
.replace("ss", String(d.getUTCSeconds()).padStart(2, "0"));
});
export function getFilter(name: string): FilterFn | undefined {
return FILTERS.get(name);
}
export function parseFilterExpression(expr: string): [string, ...string[]] {
const trimmed = expr.trim();
const parts: string[] = [];
let i = 0;
while (i < trimmed.length) {
while (i < trimmed.length && trimmed[i] === " ") i += 1;
if (i >= trimmed.length) break;
if (trimmed[i] === '"' || trimmed[i] === "'") {
const quote = trimmed[i];
i += 1;
let arg = "";
while (i < trimmed.length && trimmed[i] !== quote) {
if (trimmed[i] === "\\" && i + 1 < trimmed.length) {
arg += trimmed[i + 1];
i += 2;
} else {
arg += trimmed[i];
i += 1;
}
}
if (i < trimmed.length) i += 1;
parts.push(arg);
} else {
let arg = "";
while (i < trimmed.length && trimmed[i] !== " ") {
arg += trimmed[i];
i += 1;
}
parts.push(arg);
}
}
if (parts.length === 0) {
return [trimmed];
}
return parts as [string, ...string[]];
}
export function applyFilters(value: unknown, filterChain: string[]): unknown {
let result = value;
for (const filterExpr of filterChain) {
const [name, ...args] = parseFilterExpression(filterExpr);
const fn = FILTERS.get(name);
if (!fn) throw new Error(`Unknown template filter: ${name}`);
result = fn(result, ...args);
}
return result;
}

View File

@ -1,6 +1,6 @@
export { createDefaultRegistry } from '../commands/registry.js';
export { parsePipeline } from '../parser.js';
export { runPipeline } from '../runtime.js';
export { runWorkflowFile } from '../workflows/file.js';
export { decodeResumeToken } from '../resume.js';
export { runToolRequest, resumeToolRequest, createToolContext } from './tool_runtime.js';
export { createDefaultRegistry } from "../commands/registry.js";
export { parsePipeline } from "../parser.js";
export { runPipeline } from "../runtime.js";
export { runWorkflowFile } from "../workflows/file.js";
export { decodeResumeToken } from "../resume.js";
export { runToolRequest, resumeToolRequest, createToolContext } from "./tool_runtime.js";

100
src/core/retry.ts Normal file
View File

@ -0,0 +1,100 @@
export type RetryConfig = {
max?: number;
backoff?: "fixed" | "exponential";
delay_ms?: number;
max_delay_ms?: number;
jitter?: boolean;
};
const DEFAULTS = {
max: 1,
backoff: "fixed" as const,
delay_ms: 1000,
max_delay_ms: 30000,
jitter: false,
};
export function resolveRetryConfig(raw: RetryConfig | undefined): Required<RetryConfig> {
if (!raw) return { ...DEFAULTS };
return {
max: raw.max ?? DEFAULTS.max,
backoff: raw.backoff ?? DEFAULTS.backoff,
delay_ms: raw.delay_ms ?? DEFAULTS.delay_ms,
max_delay_ms: raw.max_delay_ms ?? DEFAULTS.max_delay_ms,
jitter: raw.jitter ?? DEFAULTS.jitter,
};
}
function computeDelay(config: Required<RetryConfig>, attempt: number): number {
let delay: number;
if (config.backoff === "exponential") {
delay = Math.min(config.delay_ms * Math.pow(2, attempt), config.max_delay_ms);
} else {
delay = config.delay_ms;
}
if (config.jitter) {
// +/- 10% randomization, clamped to max_delay_ms
const jitterRange = delay * 0.1;
delay += (Math.random() * 2 - 1) * jitterRange;
delay = Math.min(delay, config.max_delay_ms);
}
return Math.max(0, Math.round(delay));
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((r) => setTimeout(r, ms));
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
return;
}
let timer: ReturnType<typeof setTimeout>;
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
};
timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Execute `fn` with retries according to the given config.
* Abort errors always propagate immediately (no retry).
* Returns the result of the first successful call, or throws
* the last error after all retries are exhausted.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
config: Required<RetryConfig>,
options?: {
signal?: AbortSignal;
shouldRetry?: (error: any, attempt: number) => boolean;
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
},
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt < config.max; attempt++) {
try {
return await fn();
} catch (err: any) {
// Never retry abort/cancellation errors
if (err?.name === "AbortError" || err?.code === "ABORT_ERR") {
throw err;
}
lastError = err;
if (attempt + 1 < config.max) {
if (options?.shouldRetry && !options.shouldRetry(err, attempt + 1)) {
throw err;
}
const delay = computeDelay(config, attempt);
options?.onRetry?.(attempt + 1, err, delay);
await abortableSleep(delay, options?.signal);
}
}
}
throw lastError;
}

View File

@ -1,23 +1,28 @@
import { Writable } from 'node:stream';
import path from 'node:path';
import { Writable } from "node:stream";
import path from "node:path";
import { createDefaultRegistry } from '../commands/registry.js';
import { parsePipeline } from '../parser.js';
import { decodeResumeToken, kindFromStateKey } from '../resume.js';
import { runPipeline } from '../runtime.js';
import { encodeToken } from '../token.js';
import { deleteStateJson, deleteApprovalId, findStateKeyByApprovalId, cleanupApprovalIndexByStateKey } from '../state/store.js';
import { WorkflowResumeArgumentError, runWorkflowFile } from '../workflows/file.js';
import { createDefaultRegistry } from "../commands/registry.js";
import { parsePipeline } from "../parser.js";
import { decodeResumeToken, kindFromStateKey } from "../resume.js";
import { runPipeline } from "../runtime.js";
import { encodeToken } from "../token.js";
import {
deleteStateJson,
deleteApprovalId,
findStateKeyByApprovalId,
cleanupApprovalIndexByStateKey,
} from "../state/store.js";
import { WorkflowResumeArgumentError, runWorkflowFile } from "../workflows/file.js";
import {
finalizePipelineToolRun,
loadPipelineResumeState,
validatePipelineInputResponse,
} from '../pipeline_resume_state.js';
} from "../pipeline_resume_state.js";
type ToolRunContext = {
cwd?: string;
env?: Record<string, string | undefined>;
mode?: 'tool' | 'human' | 'sdk';
mode?: "tool" | "human" | "sdk";
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
stderr?: NodeJS.WritableStream;
@ -29,10 +34,10 @@ type ToolRunContext = {
type ToolEnvelope = {
protocolVersion: 1;
ok: boolean;
status?: 'ok' | 'needs_approval' | 'needs_input' | 'cancelled';
status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
output?: unknown[];
requiresApproval?: {
type?: 'approval_request';
type?: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
@ -40,7 +45,7 @@ type ToolEnvelope = {
approvalId?: string;
} | null;
requiresInput?: {
type?: 'input_request';
type?: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
@ -65,14 +70,14 @@ export async function runToolRequest({
ctx?: ToolRunContext;
}): Promise<ToolEnvelope> {
const runtime = createToolContext(ctx);
const hasPipeline = typeof pipeline === 'string' && pipeline.trim().length > 0;
const hasFile = typeof filePath === 'string' && filePath.trim().length > 0;
const hasPipeline = typeof pipeline === "string" && pipeline.trim().length > 0;
const hasFile = typeof filePath === "string" && filePath.trim().length > 0;
if (!hasPipeline && !hasFile) {
return errorEnvelope('parse_error', 'run requires either pipeline or filePath');
return errorEnvelope("parse_error", "run requires either pipeline or filePath");
}
if (hasPipeline && hasFile) {
return errorEnvelope('parse_error', 'run accepts either pipeline or filePath, not both');
return errorEnvelope("parse_error", "run accepts either pipeline or filePath, not both");
}
if (hasFile) {
@ -80,7 +85,7 @@ export async function runToolRequest({
try {
resolvedFilePath = await resolveWorkflowFile(filePath!, runtime.cwd);
} catch (err: any) {
return errorEnvelope('parse_error', err?.message ?? String(err));
return errorEnvelope("parse_error", err?.message ?? String(err));
}
try {
@ -90,18 +95,18 @@ export async function runToolRequest({
ctx: runtime,
});
if (output.status === 'needs_approval') {
return okEnvelope('needs_approval', [], output.requiresApproval ?? null, null);
if (output.status === "needs_approval") {
return okEnvelope("needs_approval", [], output.requiresApproval ?? null, null);
}
if (output.status === 'needs_input') {
return okEnvelope('needs_input', [], null, output.requiresInput ?? null);
if (output.status === "needs_input") {
return okEnvelope("needs_input", [], null, output.requiresInput ?? null);
}
if (output.status === 'cancelled') {
return okEnvelope('cancelled', [], null, null);
if (output.status === "cancelled") {
return okEnvelope("cancelled", [], null, null);
}
return okEnvelope('ok', output.output, null, null);
return okEnvelope("ok", output.output, null, null);
} catch (err: any) {
return errorEnvelope('runtime_error', err?.message ?? String(err));
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
@ -109,7 +114,7 @@ export async function runToolRequest({
try {
parsed = parsePipeline(String(pipeline));
} catch (err: any) {
return errorEnvelope('parse_error', err?.message ?? String(err));
return errorEnvelope("parse_error", err?.message ?? String(err));
}
try {
@ -121,7 +126,7 @@ export async function runToolRequest({
stdout: runtime.stdout,
stderr: runtime.stderr,
env: runtime.env,
mode: 'tool',
mode: "tool",
cwd: runtime.cwd,
llmAdapters: runtime.llmAdapters,
signal: runtime.signal,
@ -132,9 +137,14 @@ export async function runToolRequest({
pipeline: parsed,
output,
});
return okEnvelope(finalized.status, finalized.output, finalized.requiresApproval, finalized.requiresInput);
return okEnvelope(
finalized.status,
finalized.output,
finalized.requiresApproval,
finalized.requiresInput,
);
} catch (err: any) {
return errorEnvelope('runtime_error', err?.message ?? String(err));
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
@ -163,7 +173,7 @@ export async function resumeToolRequest({
if (approvalId) {
const stateKey = await findStateKeyByApprovalId({ env: runtime.env, approvalId });
if (!stateKey) {
return errorEnvelope('parse_error', `Approval ID "${approvalId}" not found or expired`);
return errorEnvelope("parse_error", `Approval ID "${approvalId}" not found or expired`);
}
const kind = kindFromStateKey(stateKey);
resolvedToken = encodeToken({
@ -175,11 +185,11 @@ export async function resumeToolRequest({
} else if (token) {
resolvedToken = token;
} else {
return errorEnvelope('parse_error', 'resume requires token or approvalId');
return errorEnvelope("parse_error", "resume requires token or approvalId");
}
payload = decodeResumeToken(resolvedToken);
} catch (err: any) {
return errorEnvelope('parse_error', err?.message ?? String(err));
return errorEnvelope("parse_error", err?.message ?? String(err));
}
// Helper: clean up approval ID index after successful use
@ -193,16 +203,16 @@ export async function resumeToolRequest({
if (cancel === true) {
await cleanupIndex();
if (payload.kind === 'workflow-file' && payload.stateKey) {
if (payload.kind === "workflow-file" && payload.stateKey) {
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
}
if (payload.kind === 'pipeline-resume' && payload.stateKey) {
if (payload.kind === "pipeline-resume" && payload.stateKey) {
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
}
return okEnvelope('cancelled', [], null, null);
return okEnvelope("cancelled", [], null, null);
}
if (payload.kind === 'workflow-file') {
if (payload.kind === "workflow-file") {
try {
const output = await runWorkflowFile({
filePath: payload.filePath,
@ -213,24 +223,24 @@ export async function resumeToolRequest({
cancel,
});
if (output.status === 'needs_approval') {
if (output.status === "needs_approval") {
// Don't clean up index — next gate will issue a new approvalId
return okEnvelope('needs_approval', [], output.requiresApproval ?? null, null);
return okEnvelope("needs_approval", [], output.requiresApproval ?? null, null);
}
if (output.status === 'needs_input') {
return okEnvelope('needs_input', [], null, output.requiresInput ?? null);
if (output.status === "needs_input") {
return okEnvelope("needs_input", [], null, output.requiresInput ?? null);
}
await cleanupIndex();
if (output.status === 'cancelled') {
return okEnvelope('cancelled', [], null, null);
if (output.status === "cancelled") {
return okEnvelope("cancelled", [], null, null);
}
return okEnvelope('ok', output.output, null, null);
return okEnvelope("ok", output.output, null, null);
} catch (err: any) {
if (err instanceof WorkflowResumeArgumentError) {
return errorEnvelope('parse_error', err.message);
return errorEnvelope("parse_error", err.message);
}
// Don't clean up index on error — allow retry by --id
return errorEnvelope('runtime_error', err?.message ?? String(err));
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
@ -238,34 +248,39 @@ export async function resumeToolRequest({
try {
resumeState = await loadPipelineResumeState(runtime.env, payload.stateKey);
} catch (err: any) {
return errorEnvelope('runtime_error', err?.message ?? String(err));
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
if (resumeState.haltType === 'input_request') {
if (resumeState.haltType === "input_request") {
if (approved !== undefined) {
return errorEnvelope('parse_error', 'pipeline input resumes require response');
return errorEnvelope("parse_error", "pipeline input resumes require response");
}
if (response === undefined) {
return errorEnvelope('parse_error', 'pipeline input resumes require response');
return errorEnvelope("parse_error", "pipeline input resumes require response");
}
try {
validatePipelineInputResponse(resumeState.inputSchema, response);
} catch (err: any) {
return errorEnvelope('parse_error', err?.message ?? String(err));
return errorEnvelope("parse_error", err?.message ?? String(err));
}
} else {
if (response !== undefined) {
return errorEnvelope('parse_error', 'approval resumes require approved=true|false, not response');
return errorEnvelope(
"parse_error",
"approval resumes require approved=true|false, not response",
);
}
if (approved !== true) {
await cleanupIndex();
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
return okEnvelope('cancelled', [], null, null);
return okEnvelope("cancelled", [], null, null);
}
}
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
const input = streamFromItems(resumeState.haltType === 'input_request' ? [response] : resumeState.items);
const input = streamFromItems(
resumeState.haltType === "input_request" ? [response] : resumeState.items,
);
try {
const output = await runPipeline({
@ -275,7 +290,7 @@ export async function resumeToolRequest({
stdout: runtime.stdout,
stderr: runtime.stderr,
env: runtime.env,
mode: 'tool',
mode: "tool",
cwd: runtime.cwd,
llmAdapters: runtime.llmAdapters,
signal: runtime.signal,
@ -289,10 +304,15 @@ export async function resumeToolRequest({
output,
previousStateKey: payload.stateKey,
});
return okEnvelope(finalized.status, finalized.output, finalized.requiresApproval, finalized.requiresInput);
return okEnvelope(
finalized.status,
finalized.output,
finalized.requiresApproval,
finalized.requiresInput,
);
} catch (err: any) {
// Don't clean up index on error — allow retry by --id
return errorEnvelope('runtime_error', err?.message ?? String(err));
return errorEnvelope("runtime_error", err?.message ?? String(err));
}
}
@ -300,7 +320,7 @@ export function createToolContext(ctx: ToolRunContext = {}) {
return {
cwd: ctx.cwd ?? process.cwd(),
env: { ...process.env, ...ctx.env },
mode: 'tool' as const,
mode: "tool" as const,
stdin: ctx.stdin ?? process.stdin,
stdout: ctx.stdout ?? createCaptureStream(),
stderr: ctx.stderr ?? createCaptureStream(),
@ -319,10 +339,10 @@ export function createCaptureStream() {
}
function okEnvelope(
status: 'ok' | 'needs_approval' | 'needs_input' | 'cancelled',
status: "ok" | "needs_approval" | "needs_input" | "cancelled",
output: unknown[],
requiresApproval: ToolEnvelope['requiresApproval'],
requiresInput: ToolEnvelope['requiresInput'],
requiresApproval: ToolEnvelope["requiresApproval"],
requiresInput: ToolEnvelope["requiresInput"],
) {
return {
protocolVersion: 1 as const,
@ -351,13 +371,13 @@ function streamFromItems(items: unknown[]) {
}
async function resolveWorkflowFile(candidate: string, cwd: string) {
const { stat } = await import('node:fs/promises');
const { stat } = await import("node:fs/promises");
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
const fileStat = await stat(resolved);
if (!fileStat.isFile()) throw new Error('Workflow path is not a file');
if (!fileStat.isFile()) throw new Error("Workflow path is not a file");
const ext = path.extname(resolved).toLowerCase();
if (!['.lobster', '.yaml', '.yml', '.json'].includes(ext)) {
throw new Error('Workflow file must end in .lobster, .yaml, .yml, or .json');
if (![".lobster", ".yaml", ".yml", ".json"].includes(ext)) {
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
}
return resolved;
}

View File

@ -1,17 +1,17 @@
function isWhitespace(ch) {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
}
function splitPipes(input) {
const parts = [];
let current = '';
let current = "";
let quote = null;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (quote) {
if (ch === '\\') {
if (ch === "\\") {
const next = input[i + 1];
if (next) {
current += ch + next;
@ -32,28 +32,28 @@ function splitPipes(input) {
continue;
}
if (ch === '|') {
if (ch === "|") {
parts.push(current.trim());
current = '';
current = "";
continue;
}
current += ch;
}
if (quote) throw new Error('Unclosed quote');
if (quote) throw new Error("Unclosed quote");
if (current.trim().length > 0) parts.push(current.trim());
return parts;
}
function tokenizeCommand(input) {
const tokens = [];
let current = '';
let current = "";
let quote = null;
const push = () => {
if (current.length > 0) tokens.push(current);
current = '';
current = "";
};
for (let i = 0; i < input.length; i++) {
@ -61,7 +61,7 @@ function tokenizeCommand(input) {
if (quote) {
if (quote === "'") {
if (ch === '\\' && input[i + 1] === quote) {
if (ch === "\\" && input[i + 1] === quote) {
current += quote;
i++;
continue;
@ -76,14 +76,14 @@ function tokenizeCommand(input) {
// Double-quoted mode: preserve unknown escapes (\n, \t, etc) while
// unescaping only shell-like quote/backslash escapes.
if (ch === '\\') {
if (ch === "\\") {
const next = input[i + 1];
if (next === '"' || next === '\\' || next === '$' || next === '`') {
if (next === '"' || next === "\\" || next === "$" || next === "`") {
current += next;
i++;
continue;
}
if (next === '\n') {
if (next === "\n") {
i++;
continue;
}
@ -113,7 +113,7 @@ function tokenizeCommand(input) {
current += ch;
}
if (quote) throw new Error('Unclosed quote');
if (quote) throw new Error("Unclosed quote");
push();
return tokens;
}
@ -124,8 +124,8 @@ function parseArgs(tokens) {
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
if (tok.startsWith('--')) {
const eq = tok.indexOf('=');
if (tok.startsWith("--")) {
const eq = tok.indexOf("=");
if (eq !== -1) {
const key = tok.slice(2, eq);
const value = tok.slice(eq + 1);
@ -135,7 +135,7 @@ function parseArgs(tokens) {
const key = tok.slice(2);
const next = tokens[i + 1];
if (!next || next.startsWith('--')) {
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
@ -152,11 +152,11 @@ function parseArgs(tokens) {
export function parsePipeline(input) {
const stages = splitPipes(input);
if (stages.length === 0) throw new Error('Empty pipeline');
if (stages.length === 0) throw new Error("Empty pipeline");
return stages.map((stage) => {
const tokens = tokenizeCommand(stage);
if (tokens.length === 0) throw new Error('Empty command stage');
if (tokens.length === 0) throw new Error("Empty command stage");
const name = tokens[0];
const args = parseArgs(tokens.slice(1));
return { name, args, raw: stage };

View File

@ -1,34 +1,34 @@
import { randomUUID } from 'node:crypto';
import { randomUUID } from "node:crypto";
import { encodeToken } from './token.js';
import { encodeToken } from "./token.js";
import {
cleanupApprovalIndexByStateKey,
createApprovalIndex,
deleteStateJson,
readStateJson,
writeStateJson,
} from './state/store.js';
import { sharedAjv } from './validation.js';
} from "./state/store.js";
import { sharedAjv } from "./validation.js";
export type PipelineResumeState = {
pipeline: Array<{ name: string; args: Record<string, unknown>; raw: string }>;
resumeAtIndex: number;
items: unknown[];
haltType?: 'approval_request' | 'input_request';
haltType?: "approval_request" | "input_request";
inputSchema?: unknown;
prompt?: string;
createdAt: string;
};
export type PipelineApprovalRequest = {
type: 'approval_request';
type: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
};
export type PipelineInputRequest = {
type: 'input_request';
type: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
@ -44,57 +44,53 @@ export type PipelineRunOutput = {
export type PipelineToolRunResolution =
| {
status: 'needs_approval';
output: [];
requiresApproval: {
type: 'approval_request';
prompt: string;
items: unknown[];
preview?: string;
resumeToken: string;
approvalId: string;
};
requiresInput: null;
}
status: "needs_approval";
output: [];
requiresApproval: {
type: "approval_request";
prompt: string;
items: unknown[];
preview?: string;
resumeToken: string;
approvalId: string;
};
requiresInput: null;
}
| {
status: 'needs_input';
output: [];
requiresApproval: null;
requiresInput: {
type: 'input_request';
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
resumeToken: string;
};
}
status: "needs_input";
output: [];
requiresApproval: null;
requiresInput: {
type: "input_request";
prompt: string;
responseSchema: unknown;
defaults?: unknown;
subject?: unknown;
resumeToken: string;
};
}
| {
status: 'ok';
output: unknown[];
requiresApproval: null;
requiresInput: null;
};
status: "ok";
output: unknown[];
requiresApproval: null;
requiresInput: null;
};
export function extractPipelineHalt(output: {
halted?: boolean;
items: unknown[];
}) {
const halted = output.halted && output.items.length === 1
? output.items[0] as Record<string, unknown>
: null;
const approval = halted?.type === 'approval_request'
? halted as unknown as PipelineApprovalRequest
: null;
const inputRequest = halted?.type === 'input_request'
? halted as unknown as PipelineInputRequest
: null;
export function extractPipelineHalt(output: { halted?: boolean; items: unknown[] }) {
const halted =
output.halted && output.items.length === 1
? (output.items[0] as Record<string, unknown>)
: null;
const approval =
halted?.type === "approval_request" ? (halted as unknown as PipelineApprovalRequest) : null;
const inputRequest =
halted?.type === "input_request" ? (halted as unknown as PipelineInputRequest) : null;
return { approval, inputRequest };
}
export async function finalizePipelineToolRun(params: {
env: Record<string, string | undefined>;
pipeline: PipelineResumeState['pipeline'];
pipeline: PipelineResumeState["pipeline"];
output: PipelineRunOutput;
previousStateKey?: string;
}): Promise<PipelineToolRunResolution> {
@ -104,7 +100,7 @@ export async function finalizePipelineToolRun(params: {
pipeline: params.pipeline,
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
items: approval.items,
haltType: 'approval_request',
haltType: "approval_request",
prompt: approval.prompt,
createdAt: new Date().toISOString(),
});
@ -122,11 +118,11 @@ export async function finalizePipelineToolRun(params: {
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
kind: 'pipeline-resume',
kind: "pipeline-resume",
stateKey: nextStateKey,
});
return {
status: 'needs_approval',
status: "needs_approval",
output: [],
requiresApproval: {
...approval,
@ -142,7 +138,7 @@ export async function finalizePipelineToolRun(params: {
pipeline: params.pipeline,
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
items: [],
haltType: 'input_request',
haltType: "input_request",
inputSchema: inputRequest.responseSchema,
prompt: inputRequest.prompt,
createdAt: new Date().toISOString(),
@ -154,15 +150,15 @@ export async function finalizePipelineToolRun(params: {
const resumeToken = encodeToken({
protocolVersion: 1,
v: 1,
kind: 'pipeline-resume',
kind: "pipeline-resume",
stateKey: nextStateKey,
});
return {
status: 'needs_input',
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
type: 'input_request',
type: "input_request",
prompt: inputRequest.prompt,
responseSchema: inputRequest.responseSchema,
...(inputRequest.defaults !== undefined ? { defaults: inputRequest.defaults } : null),
@ -177,7 +173,7 @@ export async function finalizePipelineToolRun(params: {
await deleteStateJson({ env: params.env, key: params.previousStateKey });
}
return {
status: 'ok',
status: "ok",
output: params.output.items,
requiresApproval: null,
requiresInput: null,
@ -198,33 +194,36 @@ export async function loadPipelineResumeState(
stateKey: string,
) {
const stored = await readStateJson({ env, key: stateKey });
if (!stored || typeof stored !== 'object') {
throw new Error('Pipeline resume state not found');
if (!stored || typeof stored !== "object") {
throw new Error("Pipeline resume state not found");
}
const data = stored as Partial<PipelineResumeState>;
if (!Array.isArray(data.pipeline)) throw new Error('Invalid pipeline resume state');
if (typeof data.resumeAtIndex !== 'number') throw new Error('Invalid pipeline resume state');
if (!Array.isArray(data.items)) throw new Error('Invalid pipeline resume state');
if (data.haltType !== undefined && !['approval_request', 'input_request'].includes(data.haltType)) {
throw new Error('Invalid pipeline resume state');
if (!Array.isArray(data.pipeline)) throw new Error("Invalid pipeline resume state");
if (typeof data.resumeAtIndex !== "number") throw new Error("Invalid pipeline resume state");
if (!Array.isArray(data.items)) throw new Error("Invalid pipeline resume state");
if (
data.haltType !== undefined &&
!["approval_request", "input_request"].includes(data.haltType)
) {
throw new Error("Invalid pipeline resume state");
}
return data as PipelineResumeState;
}
export function validatePipelineInputResponse(schema: unknown, response: unknown) {
if (schema === undefined) {
throw new Error('pipeline input response schema is missing');
throw new Error("pipeline input response schema is missing");
}
let validator;
try {
validator = sharedAjv.compile(schema as any);
} catch {
throw new Error('pipeline input response schema is invalid');
throw new Error("pipeline input response schema is invalid");
}
const ok = validator(response);
if (ok) return;
const first = validator.errors?.[0];
const pathValue = first?.instancePath || '/';
const reason = first?.message ? ` ${first.message}` : '';
const pathValue = first?.instancePath || "/";
const reason = first?.message ? ` ${first.message}` : "";
throw new Error(`pipeline input response failed schema validation at ${pathValue}:${reason}`);
}

View File

@ -1,19 +1,16 @@
export function readLineFromStream(
stream: NodeJS.ReadableStream,
opts?: { timeoutMs?: number },
) {
export function readLineFromStream(stream: NodeJS.ReadableStream, opts?: { timeoutMs?: number }) {
const timeoutMs = Number(opts?.timeoutMs ?? 0);
return new Promise<string>((resolve, reject) => {
let settled = false;
let buf = '';
let buf = "";
let timer: NodeJS.Timeout | null = null;
const cleanup = () => {
stream.off('data', onData);
stream.off('end', onEnd);
stream.off('close', onClose);
stream.off('error', onError);
stream.off("data", onData);
stream.off("end", onEnd);
stream.off("close", onClose);
stream.off("error", onError);
if (timer) clearTimeout(timer);
};
@ -32,8 +29,8 @@ export function readLineFromStream(
};
const onData = (chunk: Buffer | string) => {
buf += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
const idx = buf.indexOf('\n');
buf += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
const idx = buf.indexOf("\n");
if (idx !== -1) {
finish(buf.slice(0, idx));
}
@ -49,9 +46,9 @@ export function readLineFromStream(
}, timeoutMs);
}
stream.on('data', onData);
stream.on('end', onEnd);
stream.on('close', onClose);
stream.on('error', onError);
stream.on("data", onData);
stream.on("end", onEnd);
stream.on("close", onClose);
stream.on("error", onError);
});
}

View File

@ -7,12 +7,12 @@
* const result = await prMonitor({ repo: 'owner/repo', pr: 123 }).run();
*/
export { prMonitor, prMonitorNotify } from './pr-monitor.js';
export { ghPrView } from './stages/pr-view.js';
export { prMonitor, prMonitorNotify } from "./pr-monitor.js";
export { ghPrView } from "./stages/pr-view.js";
// Register recipes
import { registerRecipe } from '../registry.js';
import { prMonitor, prMonitorNotify } from './pr-monitor.js';
import { registerRecipe } from "../registry.js";
import { prMonitor, prMonitorNotify } from "./pr-monitor.js";
registerRecipe(prMonitor);
registerRecipe(prMonitorNotify);

View File

@ -11,9 +11,9 @@
* const notify = await prMonitorNotify({ repo: 'owner/repo', pr: 123 }).run();
*/
import { Lobster } from '../../sdk/index.js';
import { diffLast } from '../../sdk/primitives/diff.js';
import { ghPrView } from './stages/pr-view.js';
import { Lobster } from "../../sdk/index.js";
import { diffLast } from "../../sdk/primitives/diff.js";
import { ghPrView } from "./stages/pr-view.js";
/**
* Pick a subset of PR fields for comparison
@ -21,7 +21,7 @@ import { ghPrView } from './stages/pr-view.js';
* @returns {Object|null}
*/
function pickSubset(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
if (!snapshot || typeof snapshot !== "object") return null;
return {
number: snapshot.number,
title: snapshot.title,
@ -73,10 +73,10 @@ function buildChangeSummary(before, after) {
* @returns {string}
*/
function formatChangeMessage({ repo, pr, changedFields, prInfo }) {
const fields = changedFields.length ? ` (${changedFields.join(', ')})` : '';
const title = prInfo?.title ? `: ${prInfo.title}` : '';
const url = prInfo?.url ? ` ${prInfo.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
const fields = changedFields.length ? ` (${changedFields.join(", ")})` : "";
const title = prInfo?.title ? `: ${prInfo.title}` : "";
const url = prInfo?.url ? ` ${prInfo.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
}
/**
@ -94,8 +94,8 @@ export function prMonitor(options) {
const { repo, pr, changesOnly = false, summaryOnly = false } = options;
const key = options.key ?? `github.pr:${repo}#${pr}`;
if (!repo) throw new Error('prMonitor requires repo');
if (!pr) throw new Error('prMonitor requires pr');
if (!repo) throw new Error("prMonitor requires repo");
if (!pr) throw new Error("prMonitor requires pr");
const workflow = new Lobster()
.pipe(ghPrView({ repo, pr }))
@ -108,56 +108,62 @@ export function prMonitor(options) {
// If changesOnly and no change, suppress output
if (changesOnly && !changed) {
return [{
kind: 'github.pr.monitor',
repo,
pr: Number(pr),
key,
changed: false,
suppressed: true,
}];
return [
{
kind: "github.pr.monitor",
repo,
pr: Number(pr),
key,
changed: false,
suppressed: true,
},
];
}
const summary = buildChangeSummary(before, current);
if (summaryOnly) {
return [{
kind: 'github.pr.monitor',
return [
{
kind: "github.pr.monitor",
repo,
pr: Number(pr),
key,
changed,
summary,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
updatedAt: current.updatedAt,
},
},
];
}
return [
{
kind: "github.pr.monitor",
repo,
pr: Number(pr),
key,
changed,
summary,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
updatedAt: current.updatedAt,
},
}];
}
return [{
kind: 'github.pr.monitor',
repo,
pr: Number(pr),
key,
changed,
summary,
prSnapshot: current,
}];
prSnapshot: current,
},
];
})
.meta({
name: 'github.pr.monitor',
description: 'Monitor PR state and detect changes',
requires: ['gh'],
name: "github.pr.monitor",
description: "Monitor PR state and detect changes",
requires: ["gh"],
args: {
repo: { type: 'string', required: true, description: 'Repository (owner/repo)' },
pr: { type: 'number', required: true, description: 'PR number' },
key: { type: 'string', description: 'State key override' },
changesOnly: { type: 'boolean', default: false, description: 'Only output when changed' },
summaryOnly: { type: 'boolean', default: false, description: 'Return compact summary' },
repo: { type: "string", required: true, description: "Repository (owner/repo)" },
pr: { type: "number", required: true, description: "PR number" },
key: { type: "string", description: "State key override" },
changesOnly: { type: "boolean", default: false, description: "Only output when changed" },
summaryOnly: { type: "boolean", default: false, description: "Return compact summary" },
},
});
@ -166,15 +172,15 @@ export function prMonitor(options) {
// Attach metadata
prMonitor.meta = {
name: 'github.pr.monitor',
description: 'Monitor PR state and detect changes',
requires: ['gh'],
name: "github.pr.monitor",
description: "Monitor PR state and detect changes",
requires: ["gh"],
args: {
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
changesOnly: { type: 'boolean', default: false },
summaryOnly: { type: 'boolean', default: false },
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
changesOnly: { type: "boolean", default: false },
summaryOnly: { type: "boolean", default: false },
},
};
@ -192,8 +198,8 @@ export function prMonitorNotify(options) {
const { repo, pr } = options;
const key = options.key ?? `github.pr:${repo}#${pr}`;
if (!repo) throw new Error('prMonitorNotify requires repo');
if (!pr) throw new Error('prMonitorNotify requires pr');
if (!repo) throw new Error("prMonitorNotify requires repo");
if (!pr) throw new Error("prMonitorNotify requires pr");
const workflow = new Lobster()
.pipe(ghPrView({ repo, pr }))
@ -202,7 +208,7 @@ export function prMonitorNotify(options) {
const diffResult = results[0];
if (diffResult.suppressed) {
return [{ kind: 'github.pr.monitor.notify', suppressed: true }];
return [{ kind: "github.pr.monitor.notify", suppressed: true }];
}
const current = diffResult.after;
@ -216,29 +222,31 @@ export function prMonitorNotify(options) {
prInfo: current,
});
return [{
kind: 'github.pr.monitor.notify',
changed: true,
repo,
pr: Number(pr),
message,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
return [
{
kind: "github.pr.monitor.notify",
changed: true,
repo,
pr: Number(pr),
message,
prInfo: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
},
summary,
},
summary,
}];
];
})
.meta({
name: 'github.pr.monitor.notify',
description: 'Emit a notification message when PR changes',
requires: ['gh'],
name: "github.pr.monitor.notify",
description: "Emit a notification message when PR changes",
requires: ["gh"],
args: {
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
},
});
@ -247,12 +255,12 @@ export function prMonitorNotify(options) {
// Attach metadata
prMonitorNotify.meta = {
name: 'github.pr.monitor.notify',
description: 'Emit a notification message when PR changes',
requires: ['gh'],
name: "github.pr.monitor.notify",
description: "Emit a notification message when PR changes",
requires: ["gh"],
args: {
repo: { type: 'string', required: true },
pr: { type: 'number', required: true },
key: { type: 'string' },
repo: { type: "string", required: true },
pr: { type: "number", required: true },
key: { type: "string" },
},
};

View File

@ -10,7 +10,7 @@
* .pipe(pr => console.log(pr.state));
*/
import { spawn } from 'node:child_process';
import { spawn } from "node:child_process";
/**
* Run gh command
@ -20,30 +20,34 @@ import { spawn } from 'node:child_process';
*/
function runGh(argv, { env, cwd }) {
return new Promise<any>((resolve, reject) => {
const child = spawn('gh', argv, {
const child = spawn("gh", argv, {
env,
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = '';
let stderr = '';
let stdout = "";
let stderr = "";
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.on('error', (err: any) => {
if (err?.code === 'ENOENT') {
reject(new Error('gh not found on PATH (install GitHub CLI)'));
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gh not found on PATH (install GitHub CLI)"));
return;
}
reject(err);
});
child.on('close', (code) => {
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
@ -65,16 +69,24 @@ function runGh(argv, { env, cwd }) {
export function ghPrView(options) {
const { repo, pr } = options;
const fields = options.fields ?? [
'number', 'title', 'url', 'state', 'isDraft',
'mergeable', 'reviewDecision', 'author',
'baseRefName', 'headRefName', 'updatedAt',
"number",
"title",
"url",
"state",
"isDraft",
"mergeable",
"reviewDecision",
"author",
"baseRefName",
"headRefName",
"updatedAt",
];
if (!repo) throw new Error('ghPrView requires repo');
if (!pr) throw new Error('ghPrView requires pr');
if (!repo) throw new Error("ghPrView requires repo");
if (!pr) throw new Error("ghPrView requires pr");
return {
type: 'github.pr.view',
type: "github.pr.view",
repo,
pr,
@ -84,12 +96,7 @@ export function ghPrView(options) {
// no-op
}
const argv = [
'pr', 'view',
String(pr),
'--repo', String(repo),
'--json', fields.join(','),
];
const argv = ["pr", "view", String(pr), "--repo", String(repo), "--json", fields.join(",")];
const { stdout } = (await runGh(argv, { env: ctx.env, cwd: process.cwd() })) as any;
@ -97,7 +104,7 @@ export function ghPrView(options) {
try {
parsed = JSON.parse(stdout.trim());
} catch {
throw new Error('gh returned non-JSON output');
throw new Error("gh returned non-JSON output");
}
return {

View File

@ -2,10 +2,10 @@ export function createJsonRenderer(stdout) {
return {
json(items) {
stdout.write(JSON.stringify(items, null, 2));
stdout.write('\n');
stdout.write("\n");
},
lines(lines) {
for (const line of lines) stdout.write(String(line) + '\n');
for (const line of lines) stdout.write(String(line) + "\n");
},
};
}

View File

@ -1,22 +1,22 @@
import { decodeToken, encodeToken } from './token.js';
import { decodeWorkflowResumePayload } from './workflows/file.js';
import { findStateKeyByApprovalId } from './state/store.js';
import { decodeToken, encodeToken } from "./token.js";
import { decodeWorkflowResumePayload } from "./workflows/file.js";
import { findStateKeyByApprovalId } from "./state/store.js";
/**
* Determine the resume payload kind from a state key prefix.
* State keys use naming conventions: pipeline_resume_<uuid> or workflow_resume_<uuid>.
*/
export function kindFromStateKey(stateKey: string): 'pipeline-resume' | 'workflow-file' {
if (stateKey.startsWith('pipeline_resume_')) return 'pipeline-resume';
if (stateKey.startsWith('workflow_resume_')) return 'workflow-file';
export function kindFromStateKey(stateKey: string): "pipeline-resume" | "workflow-file" {
if (stateKey.startsWith("pipeline_resume_")) return "pipeline-resume";
if (stateKey.startsWith("workflow_resume_")) return "workflow-file";
// Fallback for unknown prefixes — workflow-file is the original behavior
return 'workflow-file';
return "workflow-file";
}
export type PipelineResumePayload = {
protocolVersion: 1;
v: 1;
kind: 'pipeline-resume';
kind: "pipeline-resume";
stateKey: string;
};
@ -25,39 +25,39 @@ export function parseResumeArgs(argv) {
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === '--token') {
if (tok === "--token") {
args.token = argv[i + 1];
i++;
continue;
}
if (tok.startsWith('--token=')) {
args.token = tok.slice('--token='.length);
if (tok.startsWith("--token=")) {
args.token = tok.slice("--token=".length);
continue;
}
if (tok === '--id') {
if (tok === "--id") {
args.approvalId = argv[i + 1];
i++;
continue;
}
if (tok.startsWith('--id=')) {
args.approvalId = tok.slice('--id='.length);
if (tok.startsWith("--id=")) {
args.approvalId = tok.slice("--id=".length);
continue;
}
if (tok === '--response-json') {
if (tok === "--response-json") {
args.responseJson = argv[i + 1];
i++;
continue;
}
if (tok.startsWith('--response-json=')) {
args.responseJson = tok.slice('--response-json='.length);
if (tok.startsWith("--response-json=")) {
args.responseJson = tok.slice("--response-json=".length);
continue;
}
if (tok === '--cancel') {
if (tok === "--cancel") {
const next = argv[i + 1];
if (typeof next === 'string' && !next.startsWith('--')) {
if (typeof next === "string" && !next.startsWith("--")) {
const parsed = parseBooleanArg(next);
if (parsed === null) {
throw new Error('resume --cancel must be true or false');
throw new Error("resume --cancel must be true or false");
}
args.cancel = parsed;
i++;
@ -66,34 +66,35 @@ export function parseResumeArgs(argv) {
args.cancel = true;
continue;
}
if (tok.startsWith('--cancel=')) {
const parsed = parseBooleanArg(tok.slice('--cancel='.length));
if (parsed === null) throw new Error('resume --cancel must be true or false');
if (tok.startsWith("--cancel=")) {
const parsed = parseBooleanArg(tok.slice("--cancel=".length));
if (parsed === null) throw new Error("resume --cancel must be true or false");
args.cancel = parsed;
continue;
}
if (tok === '--approve' || tok === '--decision') {
if (tok === "--approve" || tok === "--decision") {
args.decision = argv[i + 1];
i++;
continue;
}
if (tok.startsWith('--approve=')) {
args.decision = tok.slice('--approve='.length);
if (tok.startsWith("--approve=")) {
args.decision = tok.slice("--approve=".length);
continue;
}
if (tok.startsWith('--decision=')) {
args.decision = tok.slice('--decision='.length);
if (tok.startsWith("--decision=")) {
args.decision = tok.slice("--decision=".length);
continue;
}
}
if (!args.token && !args.approvalId) throw new Error('resume requires --token or --id');
const intentCount = Number(Boolean(args.decision)) + Number(args.responseJson !== null) + Number(args.cancel);
if (!args.token && !args.approvalId) throw new Error("resume requires --token or --id");
const intentCount =
Number(Boolean(args.decision)) + Number(args.responseJson !== null) + Number(args.cancel);
if (intentCount > 1) {
throw new Error('resume accepts only one of --approve, --response-json, or --cancel');
throw new Error("resume accepts only one of --approve, --response-json, or --cancel");
}
if (intentCount === 0) {
throw new Error('resume requires --approve yes|no, --response-json, or --cancel');
throw new Error("resume requires --approve yes|no, --response-json, or --cancel");
}
if (args.cancel) {
@ -112,23 +113,26 @@ export function parseResumeArgs(argv) {
response: JSON.parse(String(args.responseJson)),
};
} catch {
throw new Error('resume --response-json must be valid JSON');
throw new Error("resume --response-json must be valid JSON");
}
}
const decision = String(args.decision).toLowerCase();
if (!['yes', 'y', 'no', 'n'].includes(decision)) throw new Error('resume --approve must be yes or no');
if (!["yes", "y", "no", "n"].includes(decision))
throw new Error("resume --approve must be yes or no");
return {
token: args.token ? String(args.token) : null,
approvalId: args.approvalId ? String(args.approvalId) : null,
approved: decision === 'yes' || decision === 'y',
approved: decision === "yes" || decision === "y",
};
}
function parseBooleanArg(value: string): boolean | null {
const raw = String(value ?? '').trim().toLowerCase();
if (['1', 'true', 'yes', 'y'].includes(raw)) return true;
if (['0', 'false', 'no', 'n'].includes(raw)) return false;
const raw = String(value ?? "")
.trim()
.toLowerCase();
if (["1", "true", "yes", "y"].includes(raw)) return true;
if (["0", "false", "no", "n"].includes(raw)) return false;
return null;
}
@ -136,7 +140,10 @@ function parseBooleanArg(value: string): boolean | null {
* Resolve an approval ID to a resume token by looking up the state key.
* Detects the kind (workflow-file vs pipeline-resume) from the state key prefix.
*/
export async function resolveApprovalId(approvalId: string, env: Record<string, string | undefined>): Promise<string> {
export async function resolveApprovalId(
approvalId: string,
env: Record<string, string | undefined>,
): Promise<string> {
const stateKey = await findStateKeyByApprovalId({ env, approvalId });
if (!stateKey) {
throw new Error(`Approval ID "${approvalId}" not found or expired`);
@ -154,26 +161,26 @@ export async function resolveApprovalId(approvalId: string, env: Record<string,
export function decodeResumeToken(token) {
const payload = decodeToken(token);
if (!payload || typeof payload !== 'object') throw new Error('Invalid token');
if (payload.protocolVersion !== 1) throw new Error('Unsupported protocol version');
if (payload.v !== 1) throw new Error('Unsupported token version');
if (!payload || typeof payload !== "object") throw new Error("Invalid token");
if (payload.protocolVersion !== 1) throw new Error("Unsupported protocol version");
if (payload.v !== 1) throw new Error("Unsupported token version");
const workflowPayload = decodeWorkflowResumePayload(payload);
if (workflowPayload) return workflowPayload;
const pipelinePayload = decodePipelineResumePayload(payload);
if (pipelinePayload) return pipelinePayload;
throw new Error('Invalid token');
throw new Error("Invalid token");
}
function decodePipelineResumePayload(payload: unknown): PipelineResumePayload | null {
if (!payload || typeof payload !== 'object') return null;
if (!payload || typeof payload !== "object") return null;
const data = payload as Partial<PipelineResumePayload>;
if (data.kind !== 'pipeline-resume') return null;
if (data.protocolVersion !== 1 || data.v !== 1) throw new Error('Unsupported token version');
if (!data.stateKey || typeof data.stateKey !== 'string') throw new Error('Invalid token');
if (data.kind !== "pipeline-resume") return null;
if (data.protocolVersion !== 1 || data.v !== 1) throw new Error("Unsupported token version");
if (!data.stateKey || typeof data.stateKey !== "string") throw new Error("Invalid token");
return {
protocolVersion: 1,
v: 1,
kind: 'pipeline-resume',
kind: "pipeline-resume",
stateKey: data.stateKey,
};
}

View File

@ -1,4 +1,4 @@
import { createJsonRenderer } from './renderers/json.js';
import { createJsonRenderer } from "./renderers/json.js";
export async function runPipeline({
pipeline,
@ -7,7 +7,7 @@ export async function runPipeline({
stdout,
stderr,
env,
mode = 'human',
mode = "human",
input,
cwd = undefined,
llmAdapters = undefined,
@ -88,7 +88,7 @@ function dryRunPipeline({
stderr: any;
}) {
const lines: string[] = [];
lines.push(`[DRY RUN] Pipeline (${pipeline.length} stage${pipeline.length !== 1 ? 's' : ''}):`);
lines.push(`[DRY RUN] Pipeline (${pipeline.length} stage${pipeline.length !== 1 ? "s" : ""}):`);
for (let idx = 0; idx < pipeline.length; idx++) {
const stage = pipeline[idx];
@ -96,13 +96,13 @@ function dryRunPipeline({
if (!command) {
throw new Error(`Unknown command: ${stage.name}`);
}
const formattedArgs = stage.args ? formatStageArgs(stage.args) : '';
const argsStr = formattedArgs ? ` args: ${formattedArgs}` : '';
const formattedArgs = stage.args ? formatStageArgs(stage.args) : "";
const argsStr = formattedArgs ? ` args: ${formattedArgs}` : "";
lines.push(` ${idx + 1}. ${stage.name}${argsStr}`);
}
lines.push('');
stderr.write(lines.join('\n'));
lines.push("");
stderr.write(lines.join("\n"));
// Return rendered:true so the CLI does not print an empty JSON array to stdout.
return { items: [], rendered: true, halted: false, haltedAt: null };
}
@ -110,16 +110,16 @@ function dryRunPipeline({
function formatStageArgs(args: Record<string, unknown>) {
const parts: string[] = [];
for (const [key, value] of Object.entries(args)) {
if (key === '_') {
if (key === "_") {
const positional = Array.isArray(value) ? value : [value];
for (const v of positional) {
if (v !== undefined && v !== null) parts.push(String(v));
}
} else {
parts.push(`${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`);
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
}
}
return parts.join(', ');
return parts.join(", ");
}
async function* emptyStream() {}

View File

@ -1,6 +1,6 @@
import { runPipelineInternal } from './runtime.js';
import { encodeToken, decodeToken } from './token.js';
import { sharedAjv } from '../validation.js';
import { runPipelineInternal } from "./runtime.js";
import { encodeToken, decodeToken } from "./token.js";
import { sharedAjv } from "../validation.js";
type SdkResumePayload = {
protocolVersion: 1;
@ -49,8 +49,8 @@ export class Lobster {
}
pipe(stage) {
if (typeof stage !== 'function' && typeof stage?.run !== 'function') {
throw new Error('Stage must be a function or have a run() method');
if (typeof stage !== "function" && typeof stage?.run !== "function") {
throw new Error("Stage must be a function or have a run() method");
}
this.#stages.push(stage);
return this;
@ -69,7 +69,7 @@ export class Lobster {
const ctx = {
env: this.#options.env,
stateDir: this.#options.stateDir,
mode: 'sdk',
mode: "sdk",
};
try {
@ -79,7 +79,11 @@ export class Lobster {
input: initialInput,
});
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
if (
result.halted &&
result.items.length === 1 &&
result.items[0]?.type === "approval_request"
) {
const approval = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -92,7 +96,7 @@ export class Lobster {
return {
ok: true,
status: 'needs_approval',
status: "needs_approval",
output: [],
requiresApproval: {
prompt: approval.prompt,
@ -103,7 +107,7 @@ export class Lobster {
};
}
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'input_request') {
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -117,7 +121,7 @@ export class Lobster {
return {
ok: true,
status: 'needs_input',
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
@ -132,7 +136,7 @@ export class Lobster {
return {
ok: true,
status: 'ok',
status: "ok",
output: result.items,
requiresApproval: null,
requiresInput: null,
@ -140,12 +144,12 @@ export class Lobster {
} catch (err) {
return {
ok: false,
status: 'error',
status: "error",
output: [],
requiresApproval: null,
requiresInput: null,
error: {
type: 'runtime_error',
type: "runtime_error",
message: err?.message ?? String(err),
},
};
@ -157,12 +161,15 @@ export class Lobster {
options: { approved?: boolean; response?: unknown; cancel?: boolean } = {},
) {
const { approved, response, cancel } = options;
const intentCount = Number(typeof approved === 'boolean') + Number(response !== undefined) + Number(cancel === true);
const intentCount =
Number(typeof approved === "boolean") +
Number(response !== undefined) +
Number(cancel === true);
if (intentCount > 1) {
throw new Error('resume accepts only one of approved, response, or cancel');
throw new Error("resume accepts only one of approved, response, or cancel");
}
if (intentCount === 0) {
throw new Error('resume requires approved, response, or cancel');
throw new Error("resume requires approved, response, or cancel");
}
const payload = decodeSdkResumePayload(token);
@ -170,7 +177,7 @@ export class Lobster {
if (cancel === true) {
return {
ok: true,
status: 'cancelled',
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
@ -180,22 +187,22 @@ export class Lobster {
const expectsInput = payload.inputSchema !== undefined;
if (expectsInput) {
if (approved !== undefined) {
throw new Error('resume token expects an input response, not approved');
throw new Error("resume token expects an input response, not approved");
}
if (response === undefined) {
throw new Error('resume token expects response');
throw new Error("resume token expects response");
}
} else {
if (response !== undefined) {
throw new Error('resume token expects approved=true|false, not response');
throw new Error("resume token expects approved=true|false, not response");
}
if (typeof approved !== 'boolean') {
throw new Error('resume token expects approved=true|false');
if (typeof approved !== "boolean") {
throw new Error("resume token expects approved=true|false");
}
if (approved === false) {
return {
ok: true,
status: 'cancelled',
status: "cancelled",
output: [],
requiresApproval: null,
requiresInput: null,
@ -208,18 +215,20 @@ export class Lobster {
if (response !== undefined) {
const schema = payload.inputSchema;
if (schema === undefined) {
throw new Error('resume token does not support input responses');
throw new Error("resume token does not support input responses");
}
let validator;
try {
validator = sharedAjv.compile(schema as any);
} catch {
throw new Error('resume token input schema is invalid');
throw new Error("resume token input schema is invalid");
}
const ok = validator(response);
if (!ok) {
const first = validator.errors?.[0];
throw new Error(`response does not match schema at ${first?.instancePath || '/'}: ${first?.message || 'invalid'}`);
throw new Error(
`response does not match schema at ${first?.instancePath || "/"}: ${first?.message || "invalid"}`,
);
}
resumeItems = [response];
}
@ -228,7 +237,7 @@ export class Lobster {
const ctx = {
env: this.#options.env,
stateDir: this.#options.stateDir,
mode: 'sdk',
mode: "sdk",
};
try {
@ -238,7 +247,11 @@ export class Lobster {
input: resumeItems,
});
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
if (
result.halted &&
result.items.length === 1 &&
result.items[0]?.type === "approval_request"
) {
const approval = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -251,7 +264,7 @@ export class Lobster {
return {
ok: true,
status: 'needs_approval',
status: "needs_approval",
output: [],
requiresApproval: {
prompt: approval.prompt,
@ -262,7 +275,7 @@ export class Lobster {
};
}
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'input_request') {
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
const input = result.items[0];
const resumeToken = encodeToken({
protocolVersion: 1,
@ -276,7 +289,7 @@ export class Lobster {
return {
ok: true,
status: 'needs_input',
status: "needs_input",
output: [],
requiresApproval: null,
requiresInput: {
@ -291,7 +304,7 @@ export class Lobster {
return {
ok: true,
status: 'ok',
status: "ok",
output: result.items,
requiresApproval: null,
requiresInput: null,
@ -299,12 +312,12 @@ export class Lobster {
} catch (err) {
return {
ok: false,
status: 'error',
status: "error",
output: [],
requiresApproval: null,
requiresInput: null,
error: {
type: 'runtime_error',
type: "runtime_error",
message: err?.message ?? String(err),
},
};
@ -321,18 +334,22 @@ export class Lobster {
function decodeSdkResumePayload(token: string): SdkResumePayload {
const payload = decodeToken(token);
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid token');
if (!payload || typeof payload !== "object") {
throw new Error("Invalid token");
}
const data = payload as Record<string, unknown>;
if (data.protocolVersion !== 1 || data.v !== 1) {
throw new Error('Invalid token');
throw new Error("Invalid token");
}
if (typeof data.resumeAtIndex !== 'number' || !Number.isInteger(data.resumeAtIndex) || data.resumeAtIndex < 0) {
throw new Error('Invalid token');
if (
typeof data.resumeAtIndex !== "number" ||
!Number.isInteger(data.resumeAtIndex) ||
data.resumeAtIndex < 0
) {
throw new Error("Invalid token");
}
if (data.items !== undefined && !Array.isArray(data.items)) {
throw new Error('Invalid token');
throw new Error("Invalid token");
}
return data as unknown as SdkResumePayload;
}

View File

@ -17,9 +17,9 @@
* const result = await workflow.run();
*/
export { Lobster } from './Lobster.js';
export { approve } from './primitives/approve.js';
export { exec } from './primitives/exec.js';
export { stateGet, stateSet, state } from './primitives/state.js';
export { diffLast } from './primitives/diff.js';
export { runPipeline } from './runtime.js';
export { Lobster } from "./Lobster.js";
export { approve } from "./primitives/approve.js";
export { exec } from "./primitives/exec.js";
export { stateGet, stateSet, state } from "./primitives/state.js";
export { diffLast } from "./primitives/diff.js";
export { runPipeline } from "./runtime.js";

View File

@ -19,11 +19,11 @@
* @returns {Object} Stage object with run method
*/
export function approve(options: any = {}) {
const prompt = options.prompt ?? 'Approve?';
const prompt = options.prompt ?? "Approve?";
const preview = options.preview !== false;
return {
type: 'approve',
type: "approve",
prompt,
async run({ input, ctx: _ctx }) {
@ -38,7 +38,7 @@ export function approve(options: any = {}) {
halt: true,
output: (async function* () {
yield {
type: 'approval_request',
type: "approval_request",
prompt,
items: preview ? items : [],
itemCount: items.length,

View File

@ -14,9 +14,9 @@
* });
*/
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Get the state directory
@ -27,7 +27,7 @@ function getStateDir(ctx) {
return (
ctx?.stateDir ||
(ctx?.env?.LOBSTER_STATE_DIR && String(ctx.env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), '.lobster', 'state')
path.join(os.homedir(), ".lobster", "state")
);
}
@ -40,10 +40,10 @@ function getStateDir(ctx) {
function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
return path.join(stateDir, `${safe}.json`);
}
@ -54,8 +54,12 @@ function keyToPath(stateDir, key) {
*/
function stableStringify(value) {
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.keys(v)
.sort()
.map((k) => [k, v[k]]),
);
}
return v;
});
@ -73,12 +77,12 @@ function stableStringify(value) {
* @returns {Object} Stage object with run method
*/
export function diffLast(key, options: any = {}) {
if (!key) throw new Error('diffLast requires a key');
if (!key) throw new Error("diffLast requires a key");
const changesOnly = options.changesOnly === true;
return {
type: 'diff.last',
type: "diff.last",
key,
async run({ input, ctx }) {
@ -96,10 +100,10 @@ export function diffLast(key, options: any = {}) {
// Read previous value
let before = null;
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
before = JSON.parse(text);
} catch (err) {
if (err?.code !== 'ENOENT') {
if (err?.code !== "ENOENT") {
throw err;
}
}
@ -109,11 +113,11 @@ export function diffLast(key, options: any = {}) {
// Store new value
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
// Build result
const result = {
kind: 'diff.last',
kind: "diff.last",
key,
changed,
before,
@ -124,7 +128,7 @@ export function diffLast(key, options: any = {}) {
if (changesOnly && !changed) {
return {
output: (async function* () {
yield { kind: 'diff.last', key, changed: false, suppressed: true };
yield { kind: "diff.last", key, changed: false, suppressed: true };
})(),
};
}
@ -152,10 +156,10 @@ export async function diffAndStoreValue(key, value, ctx = {}) {
// Read previous value
let before = null;
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
before = JSON.parse(text);
} catch (err) {
if (err?.code !== 'ENOENT') {
if (err?.code !== "ENOENT") {
throw err;
}
}
@ -165,7 +169,7 @@ export async function diffAndStoreValue(key, value, ctx = {}) {
// Store new value
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
return { before, after: value, changed };
}

View File

@ -9,8 +9,8 @@
* .pipe(items => items.filter(e => e.unread))
*/
import { spawn } from 'node:child_process';
import { resolveInlineShellCommand } from '../../shell.js';
import { spawn } from "node:child_process";
import { resolveInlineShellCommand } from "../../shell.js";
/**
* Run a process and capture output
@ -24,24 +24,28 @@ function runProcess(command, argv, { env, cwd }) {
const child = spawn(command, argv, {
env,
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
shell: false,
});
let stdout = '';
let stderr = '';
let stdout = "";
let stderr = "";
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.on('error', (err) => {
child.on("error", (err) => {
reject(new Error(`Failed to execute ${command}: ${err.message}`));
});
child.on('close', (code) => {
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
@ -59,14 +63,14 @@ function runProcess(command, argv, { env, cwd }) {
*/
function parseCommand(cmdString) {
const tokens = [];
let current = '';
let current = "";
let quote = null;
for (let i = 0; i < cmdString.length; i++) {
const ch = cmdString[i];
if (quote) {
if (ch === '\\' && cmdString[i + 1]) {
if (ch === "\\" && cmdString[i + 1]) {
current += cmdString[i + 1];
i++;
continue;
@ -84,10 +88,10 @@ function parseCommand(cmdString) {
continue;
}
if (ch === ' ' || ch === '\t') {
if (ch === " " || ch === "\t") {
if (current.length > 0) {
tokens.push(current);
current = '';
current = "";
}
continue;
}
@ -119,7 +123,7 @@ export function exec(cmdString, options: any = {}) {
const cwd = options.cwd ?? process.cwd();
return {
type: 'exec',
type: "exec",
command: cmdString,
async run({ input, ctx }) {
@ -148,7 +152,7 @@ export function exec(cmdString, options: any = {}) {
let output;
if (parseJson) {
try {
output = JSON.parse(stdout.trim() || '[]');
output = JSON.parse(stdout.trim() || "[]");
} catch {
throw new Error(`exec output is not valid JSON: ${stdout.slice(0, 100)}`);
}

View File

@ -15,9 +15,9 @@
* .pipe(stateSet('my-key'));
*/
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Get the state directory
@ -28,7 +28,7 @@ function getStateDir(ctx) {
return (
ctx?.stateDir ||
(ctx?.env?.LOBSTER_STATE_DIR && String(ctx.env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), '.lobster', 'state')
path.join(os.homedir(), ".lobster", "state")
);
}
@ -41,10 +41,10 @@ function getStateDir(ctx) {
function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
return path.join(stateDir, `${safe}.json`);
}
@ -55,10 +55,10 @@ function keyToPath(stateDir, key) {
* @returns {Object} Stage object with run method
*/
export function stateGet(key) {
if (!key) throw new Error('stateGet requires a key');
if (!key) throw new Error("stateGet requires a key");
return {
type: 'state.get',
type: "state.get",
key,
async run({ input, ctx }) {
@ -72,10 +72,10 @@ export function stateGet(key) {
let value = null;
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
value = JSON.parse(text);
} catch (err) {
if (err?.code !== 'ENOENT') {
if (err?.code !== "ENOENT") {
throw err;
}
// File doesn't exist, return null
@ -97,10 +97,10 @@ export function stateGet(key) {
* @returns {Object} Stage object with run method
*/
export function stateSet(key) {
if (!key) throw new Error('stateSet requires a key');
if (!key) throw new Error("stateSet requires a key");
return {
type: 'state.set',
type: "state.set",
key,
async run({ input, ctx }) {
@ -116,7 +116,7 @@ export function stateSet(key) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
// Pass through the value
return {
@ -154,10 +154,10 @@ export async function readState(key, ctx = {}) {
const filePath = keyToPath(stateDir, key);
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
return JSON.parse(text);
} catch (err) {
if (err?.code === 'ENOENT') return null;
if (err?.code === "ENOENT") return null;
throw err;
}
}
@ -174,5 +174,5 @@ export async function writeState(key, value, ctx = {}) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
}

View File

@ -35,12 +35,12 @@ async function* toAsyncIterable(input) {
return;
}
if (typeof input[Symbol.asyncIterator] === 'function') {
if (typeof input[Symbol.asyncIterator] === "function") {
yield* input;
return;
}
if (typeof input[Symbol.iterator] === 'function') {
if (typeof input[Symbol.iterator] === "function") {
for (const item of input) {
yield item;
}
@ -83,10 +83,11 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
let result;
if (typeof stage === 'function') {
if (typeof stage === "function") {
// Check if it's a generator function
const isGenerator = stage.constructor?.name === 'AsyncGeneratorFunction' ||
stage.constructor?.name === 'GeneratorFunction';
const isGenerator =
stage.constructor?.name === "AsyncGeneratorFunction" ||
stage.constructor?.name === "GeneratorFunction";
if (isGenerator) {
// Generator function - pass the stream directly
@ -97,7 +98,7 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
const output = await stage(items, ctx);
result = { output: toAsyncIterable(output) };
}
} else if (typeof stage?.run === 'function') {
} else if (typeof stage?.run === "function") {
// Stage object with run method (primitives)
result = await stage.run({ input: stream, ctx });
} else {
@ -124,7 +125,16 @@ export async function runPipelineInternal({ stages, ctx, input = [] }) {
/**
* Re-export for compatibility with CLI runtime
*/
export async function runPipeline({ pipeline, registry, stdin, stdout, stderr, env, mode = 'human', input }) {
export async function runPipeline({
pipeline,
registry,
stdin,
stdout,
stderr,
env,
mode = "human",
input,
}) {
// This wraps the CLI-style pipeline execution
// Convert pipeline stages to functions using registry

View File

@ -4,7 +4,7 @@
* Re-exports from the main token module for consistency
*/
import { encodeToken as mainEncode, decodeToken as mainDecode } from '../token.js';
import { encodeToken as mainEncode, decodeToken as mainDecode } from "../token.js";
export const encodeToken = mainEncode;
export const decodeToken = mainDecode;

View File

@ -7,8 +7,8 @@ export function resolveInlineShellCommand({
env: Record<string, string | undefined>;
platform?: string;
}) {
const shellOverride = String(env?.LOBSTER_SHELL ?? '').trim();
const isWindows = platform === 'win32';
const shellOverride = String(env?.LOBSTER_SHELL ?? "").trim();
const isWindows = platform === "win32";
if (shellOverride) {
return {
@ -18,18 +18,18 @@ export function resolveInlineShellCommand({
}
if (isWindows) {
const comspec = String(env?.ComSpec ?? env?.COMSPEC ?? 'cmd.exe').trim() || 'cmd.exe';
const comspec = String(env?.ComSpec ?? env?.COMSPEC ?? "cmd.exe").trim() || "cmd.exe";
return {
command: comspec,
argv: ['/d', '/s', '/c', command],
argv: ["/d", "/s", "/c", command],
};
}
// Keep default behavior deterministic and POSIX-compatible across environments.
const shell = '/bin/sh';
const shell = "/bin/sh";
return {
command: shell,
argv: ['-lc', command],
argv: ["-lc", command],
};
}
@ -43,18 +43,18 @@ function buildShellArgs({
isWindows: boolean;
}) {
const lowered = shellCommand.toLowerCase();
const looksLikeCmd = lowered.endsWith('cmd') || lowered.endsWith('cmd.exe');
const looksLikeCmd = lowered.endsWith("cmd") || lowered.endsWith("cmd.exe");
const looksLikePowerShell =
lowered.endsWith('powershell') ||
lowered.endsWith('powershell.exe') ||
lowered.endsWith('pwsh') ||
lowered.endsWith('pwsh.exe');
lowered.endsWith("powershell") ||
lowered.endsWith("powershell.exe") ||
lowered.endsWith("pwsh") ||
lowered.endsWith("pwsh.exe");
if (looksLikePowerShell) {
return ['-NoProfile', '-Command', command];
return ["-NoProfile", "-Command", command];
}
if (looksLikeCmd || isWindows) {
return ['/d', '/s', '/c', command];
return ["/d", "/s", "/c", command];
}
return ['-lc', command];
return ["-lc", command];
}

View File

@ -1,29 +1,33 @@
import os from 'node:os';
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { randomBytes } from 'node:crypto';
import os from "node:os";
import path from "node:path";
import { promises as fsp } from "node:fs";
import { randomBytes } from "node:crypto";
export function defaultStateDir(env) {
return (
(env?.LOBSTER_STATE_DIR && String(env.LOBSTER_STATE_DIR).trim()) ||
path.join(os.homedir(), '.lobster', 'state')
path.join(os.homedir(), ".lobster", "state")
);
}
export function keyToPath(stateDir, key) {
const safe = String(key)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!safe) throw new Error('state key is empty/invalid');
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!safe) throw new Error("state key is empty/invalid");
return path.join(stateDir, `${safe}.json`);
}
export function stableStringify(value) {
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]));
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.keys(v)
.sort()
.map((k) => [k, v[k]]),
);
}
return v;
});
@ -34,10 +38,10 @@ export async function readStateJson({ env, key }) {
const filePath = keyToPath(stateDir, key);
try {
const text = await fsp.readFile(filePath, 'utf8');
const text = await fsp.readFile(filePath, "utf8");
return JSON.parse(text);
} catch (err) {
if (err?.code === 'ENOENT') return null;
if (err?.code === "ENOENT") return null;
throw err;
}
}
@ -47,7 +51,7 @@ export async function writeStateJson({ env, key, value }) {
const filePath = keyToPath(stateDir, key);
await fsp.mkdir(stateDir, { recursive: true });
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
}
export async function deleteStateJson({ env, key }) {
@ -56,13 +60,13 @@ export async function deleteStateJson({ env, key }) {
try {
await fsp.unlink(filePath);
} catch (err) {
if (err?.code === 'ENOENT') return;
if (err?.code === "ENOENT") return;
throw err;
}
}
function sanitizeApprovalId(approvalId: string): string {
return approvalId.replace(/[^a-f0-9]/g, '');
return approvalId.replace(/[^a-f0-9]/g, "");
}
/**
@ -71,14 +75,18 @@ function sanitizeApprovalId(approvalId: string): string {
* base64url resume tokens are unwieldy.
*/
export function generateApprovalId(): string {
return randomBytes(4).toString('hex');
return randomBytes(4).toString("hex");
}
/**
* Write a reverse-index file that maps approvalId stateKey.
* Call this after writeStateJson to enable short-ID resume.
*/
export async function writeApprovalIndex({ env, stateKey, approvalId }: {
export async function writeApprovalIndex({
env,
stateKey,
approvalId,
}: {
env: Record<string, string | undefined>;
stateKey: string;
approvalId: string;
@ -90,15 +98,18 @@ export async function writeApprovalIndex({ env, stateKey, approvalId }: {
const indexPath = path.join(stateDir, `approval_${safe}.json`);
await fsp.writeFile(
indexPath,
JSON.stringify({ stateKey, createdAt: new Date().toISOString() }) + '\n',
{ encoding: 'utf8', flag: 'wx', mode: 0o600 },
JSON.stringify({ stateKey, createdAt: new Date().toISOString() }) + "\n",
{ encoding: "utf8", flag: "wx", mode: 0o600 },
);
}
/**
* Create a unique approval ID index without ever overwriting an existing mapping.
*/
export async function createApprovalIndex({ env, stateKey }: {
export async function createApprovalIndex({
env,
stateKey,
}: {
env: Record<string, string | undefined>;
stateKey: string;
}) {
@ -108,18 +119,21 @@ export async function createApprovalIndex({ env, stateKey }: {
await writeApprovalIndex({ env, stateKey, approvalId });
return approvalId;
} catch (err: any) {
if (err?.code === 'EEXIST') continue;
if (err?.code === "EEXIST") continue;
throw err;
}
}
throw new Error('Could not allocate a unique approval ID');
throw new Error("Could not allocate a unique approval ID");
}
/**
* Look up a state key by short approval ID.
* Returns the stateKey string or null if not found.
*/
export async function findStateKeyByApprovalId({ env, approvalId }: {
export async function findStateKeyByApprovalId({
env,
approvalId,
}: {
env: Record<string, string | undefined>;
approvalId: string;
}): Promise<string | null> {
@ -128,11 +142,11 @@ export async function findStateKeyByApprovalId({ env, approvalId }: {
if (!safe) return null;
const indexPath = path.join(stateDir, `approval_${safe}.json`);
try {
const text = await fsp.readFile(indexPath, 'utf8');
const text = await fsp.readFile(indexPath, "utf8");
const data = JSON.parse(text);
return typeof data?.stateKey === 'string' ? data.stateKey : null;
return typeof data?.stateKey === "string" ? data.stateKey : null;
} catch (err: any) {
if (err?.code === 'ENOENT') return null;
if (err?.code === "ENOENT") return null;
throw err;
}
}
@ -140,7 +154,10 @@ export async function findStateKeyByApprovalId({ env, approvalId }: {
/**
* Delete the approval ID index file (cleanup after resume or cancel).
*/
export async function deleteApprovalId({ env, approvalId }: {
export async function deleteApprovalId({
env,
approvalId,
}: {
env: Record<string, string | undefined>;
approvalId: string;
}) {
@ -151,7 +168,7 @@ export async function deleteApprovalId({ env, approvalId }: {
try {
await fsp.unlink(indexPath);
} catch (err: any) {
if (err?.code === 'ENOENT') return;
if (err?.code === "ENOENT") return;
throw err;
}
}
@ -161,7 +178,10 @@ export async function deleteApprovalId({ env, approvalId }: {
* Used when resuming via --token (where we don't know the approvalId).
* Scans index files in the state dir O(n) but n is tiny in practice.
*/
export async function cleanupApprovalIndexByStateKey({ env, stateKey }: {
export async function cleanupApprovalIndexByStateKey({
env,
stateKey,
}: {
env: Record<string, string | undefined>;
stateKey: string;
}) {
@ -170,19 +190,21 @@ export async function cleanupApprovalIndexByStateKey({ env, stateKey }: {
try {
files = await fsp.readdir(stateDir);
} catch (err: any) {
if (err?.code === 'ENOENT') return;
if (err?.code === "ENOENT") return;
throw err;
}
for (const file of files) {
if (!file.startsWith('approval_') || !file.endsWith('.json')) continue;
if (!file.startsWith("approval_") || !file.endsWith(".json")) continue;
try {
const text = await fsp.readFile(path.join(stateDir, file), 'utf8');
const text = await fsp.readFile(path.join(stateDir, file), "utf8");
const data = JSON.parse(text);
if (data?.stateKey === stateKey) {
await fsp.unlink(path.join(stateDir, file)).catch(() => {});
return; // one index per stateKey
}
} catch { /* skip corrupt files */ }
} catch {
/* skip corrupt files */
}
}
}

View File

@ -1,15 +1,15 @@
import { Buffer } from 'node:buffer';
import { Buffer } from "node:buffer";
export function encodeToken(obj) {
const json = JSON.stringify(obj);
return Buffer.from(json, 'utf8').toString('base64url');
return Buffer.from(json, "utf8").toString("base64url");
}
export function decodeToken(token) {
try {
const json = Buffer.from(String(token), 'base64url').toString('utf8');
const json = Buffer.from(String(token), "base64url").toString("utf8");
return JSON.parse(json);
} catch (_err) {
throw new Error('Invalid token');
throw new Error("Invalid token");
}
}

View File

@ -1,4 +1,4 @@
import { Ajv } from 'ajv';
import { Ajv } from "ajv";
export const sharedAjv = new Ajv({
allErrors: false,

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,41 @@
import { spawn } from 'node:child_process';
import { spawn } from "node:child_process";
function runProcess(command, argv, { env, cwd }) {
return new Promise((resolve, reject) => {
const child = spawn(command, argv, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(command, argv, { env, cwd, stdio: ["ignore", "pipe", "pipe"] });
let stdout = '';
let stderr = '';
let stdout = "";
let stderr = "";
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.stdout.on("data", (d) => {
stdout += d;
});
child.stderr.on("data", (d) => {
stderr += d;
});
child.on('error', (err: any) => {
if (err?.code === 'ENOENT') {
reject(new Error('gh not found on PATH (install GitHub CLI)'));
child.on("error", (err: any) => {
if (err?.code === "ENOENT") {
reject(new Error("gh not found on PATH (install GitHub CLI)"));
return;
}
reject(err);
});
child.on('close', (code) => {
child.on("close", (code) => {
if (code === 0) return resolve({ stdout, stderr });
reject(new Error(`gh failed (${code}): ${stderr.trim() || stdout.trim()}`));
});
});
}
import { diffAndStore } from '../state/store.js';
import { diffAndStore } from "../state/store.js";
function pickSubset(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
if (!snapshot || typeof snapshot !== "object") return null;
return {
number: snapshot.number,
title: snapshot.title,
@ -72,45 +76,45 @@ export function buildPrChangeSummary(before, after) {
}
function formatPrChangeMessage({ repo, pr, changedFields, prInfo }) {
const fields = changedFields.length ? ` (${changedFields.join(', ')})` : '';
const title = prInfo?.title ? `: ${prInfo.title}` : '';
const url = prInfo?.url ? ` ${prInfo.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
const fields = changedFields.length ? ` (${changedFields.join(", ")})` : "";
const title = prInfo?.title ? `: ${prInfo.title}` : "";
const url = prInfo?.url ? ` ${prInfo.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
}
export async function runGithubPrMonitorWorkflow({ args, ctx }) {
const repo = args.repo;
const pr = args.pr;
if (!repo || !pr) throw new Error('github.pr.monitor requires args.repo and args.pr');
if (!repo || !pr) throw new Error("github.pr.monitor requires args.repo and args.pr");
const key = args.key ?? `github.pr:${repo}#${pr}`;
const changesOnly = Boolean(args.changesOnly);
const summaryOnly = Boolean(args.summaryOnly);
const argv = [
'pr',
'view',
"pr",
"view",
String(pr),
'--repo',
"--repo",
String(repo),
'--json',
'number,title,url,state,isDraft,mergeable,reviewDecision,author,baseRefName,headRefName,updatedAt',
"--json",
"number,title,url,state,isDraft,mergeable,reviewDecision,author,baseRefName,headRefName,updatedAt",
];
const { stdout } = (await runProcess('gh', argv, { env: ctx.env, cwd: process.cwd() })) as any;
const { stdout } = (await runProcess("gh", argv, { env: ctx.env, cwd: process.cwd() })) as any;
let current;
try {
current = JSON.parse(stdout.trim());
} catch {
throw new Error('gh returned non-JSON output');
throw new Error("gh returned non-JSON output");
}
const { changed, before } = await diffAndStore({ env: ctx.env, key, value: current });
if (changesOnly && !changed) {
return {
kind: 'github.pr.monitor',
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
@ -123,7 +127,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
if (summaryOnly) {
return {
kind: 'github.pr.monitor',
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
@ -140,7 +144,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
}
return {
kind: 'github.pr.monitor',
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
@ -161,14 +165,14 @@ export async function runGithubPrMonitorNotifyWorkflow({ args, ctx }) {
});
if (base.suppressed) {
return { kind: 'github.pr.monitor.notify', suppressed: true };
return { kind: "github.pr.monitor.notify", suppressed: true };
}
const changedFields = base.summary?.changedFields ?? [];
const prInfo = base.pr ?? {};
return {
kind: 'github.pr.monitor.notify',
kind: "github.pr.monitor.notify",
changed: Boolean(base.changed),
repo: args.repo,
prNumber: Number(args.pr),

253
src/workflows/graph.ts Normal file
View File

@ -0,0 +1,253 @@
import type { WorkflowFile, WorkflowStep } from "./file.js";
export type WorkflowGraphFormat = "mermaid" | "dot" | "ascii";
type GraphNode = {
id: string;
type: string;
label: string;
shape: "box" | "diamond";
};
type GraphEdge = {
from: string;
to: string;
label?: string;
};
type RenderGraphParams = {
workflow: WorkflowFile;
format: WorkflowGraphFormat;
args?: Record<string, unknown>;
};
function resolveArgsTemplate(input: string, args: Record<string, unknown>) {
return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
if (key in args) return String(args[key]);
return match;
});
}
function isApprovalStep(step: WorkflowStep) {
if (step.approval === true) return true;
if (typeof step.approval === "string" && step.approval.trim().length > 0) return true;
if (step.approval && typeof step.approval === "object" && !Array.isArray(step.approval))
return true;
return false;
}
function isInputStep(step: WorkflowStep) {
return Boolean(step.input && typeof step.input === "object" && !Array.isArray(step.input));
}
function stepType(step: WorkflowStep) {
if (step.parallel) return "parallel";
if (typeof step.for_each === "string") return "for_each";
if (typeof step.workflow === "string" && step.workflow.trim()) return "workflow";
if (typeof step.pipeline === "string" && step.pipeline.trim()) return "pipeline";
if (typeof step.run === "string" || typeof step.command === "string") return "run";
if (isApprovalStep(step)) return "approval";
if (isInputStep(step)) return "input";
return "step";
}
function stepDetails(step: WorkflowStep, args: Record<string, unknown>) {
if (step.parallel) {
return `parallel (${step.parallel.wait ?? "all"})`;
}
if (typeof step.for_each === "string") {
return `for_each: ${resolveArgsTemplate(step.for_each, args)}`;
}
if (typeof step.workflow === "string" && step.workflow.trim()) {
return `workflow: ${resolveArgsTemplate(step.workflow, args)}`;
}
if (typeof step.pipeline === "string" && step.pipeline.trim()) {
return `pipeline: ${resolveArgsTemplate(step.pipeline, args)}`;
}
const shell = typeof step.run === "string" ? step.run : step.command;
if (typeof shell === "string" && shell.trim()) {
return `run: ${resolveArgsTemplate(shell, args)}`;
}
if (isApprovalStep(step)) return "approval gate";
if (isInputStep(step)) return "input request";
return "";
}
function extractStepRefsFromString(value: string): string[] {
const refs = new Set<string>();
const rx = /\$([A-Za-z0-9_-]+)\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*/g;
for (const m of value.matchAll(rx)) {
if (m[1]) refs.add(m[1]);
}
return [...refs];
}
function extractStepRefs(value: unknown): string[] {
if (typeof value === "string") return extractStepRefsFromString(value);
if (Array.isArray(value)) {
const refs = new Set<string>();
for (const item of value) {
for (const ref of extractStepRefs(item)) refs.add(ref);
}
return [...refs];
}
if (value && typeof value === "object") {
const refs = new Set<string>();
for (const v of Object.values(value as Record<string, unknown>)) {
for (const ref of extractStepRefs(v)) refs.add(ref);
}
return [...refs];
}
return [];
}
function truncate(value: string, max = 80) {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}`;
}
function collectGraph(workflow: WorkflowFile, args: Record<string, unknown>) {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const knownStepIds = new Set(workflow.steps.map((s) => s.id));
let prevStepId: string | null = null;
const seenEdgeKeys = new Set<string>();
const addEdge = (edge: GraphEdge) => {
const key = `${edge.from}|${edge.to}|${edge.label ?? ""}`;
if (seenEdgeKeys.has(key)) return;
seenEdgeKeys.add(key);
edges.push(edge);
};
for (const step of workflow.steps) {
const type = stepType(step);
const details = stepDetails(step, args);
const label = details ? `${step.id}\\n${truncate(details)}` : step.id;
nodes.push({
id: step.id,
type,
label,
shape: isApprovalStep(step) ? "diamond" : "box",
});
if (prevStepId) {
addEdge({ from: prevStepId, to: step.id, label: "next" });
}
prevStepId = step.id;
for (const ref of extractStepRefs(step.stdin)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: "stdin" });
}
if (typeof step.for_each === "string") {
for (const ref of extractStepRefs(step.for_each)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: "for_each" });
}
}
const condition = step.when ?? step.condition;
if (typeof condition === "string" && condition.trim()) {
const labelValue = truncate(`when: ${condition.trim()}`, 70);
for (const ref of extractStepRefs(condition)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: labelValue });
}
}
}
return { nodes, edges };
}
function sanitizeMermaidId(id: string) {
return id.replace(/[^A-Za-z0-9_]/g, "_");
}
function escapeMermaidLabel(value: string) {
return value.replace(/"/g, '\\"');
}
function renderMermaid(nodes: GraphNode[], edges: GraphEdge[]) {
const idMap = new Map<string, string>();
const used = new Set<string>();
for (const node of nodes) {
let key = sanitizeMermaidId(node.id) || "step";
if (/^\d/.test(key)) key = `s_${key}`;
let i = 2;
while (used.has(key)) {
key = `${sanitizeMermaidId(node.id)}_${i}`;
i += 1;
}
used.add(key);
idMap.set(node.id, key);
}
const lines = ["flowchart TD"];
for (const node of nodes) {
const key = idMap.get(node.id)!;
const label = escapeMermaidLabel(node.label);
if (node.shape === "diamond") {
lines.push(` ${key}{"${label}"}`);
} else {
lines.push(` ${key}["${label}"]`);
}
}
if (nodes.length) lines.push("");
for (const edge of edges) {
const from = idMap.get(edge.from);
const to = idMap.get(edge.to);
if (!from || !to) continue;
if (edge.label) {
lines.push(` ${from} -->|${escapeMermaidLabel(edge.label)}| ${to}`);
} else {
lines.push(` ${from} --> ${to}`);
}
}
return lines.join("\n");
}
function escapeDot(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function renderDot(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ["digraph workflow {", " rankdir=TB;"];
for (const node of nodes) {
const shape = node.shape === "diamond" ? "diamond" : "box";
lines.push(` "${escapeDot(node.id)}" [shape=${shape},label="${escapeDot(node.label)}"];`);
}
if (nodes.length) lines.push("");
for (const edge of edges) {
if (edge.label) {
lines.push(
` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}" [label="${escapeDot(edge.label)}"];`,
);
} else {
lines.push(` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}";`);
}
}
lines.push("}");
return lines.join("\n");
}
function renderAscii(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ["Workflow Graph", "", "Nodes:"];
for (const node of nodes) {
lines.push(
`- ${node.id} [${node.type}] ${node.label.includes("\\n") ? `(${node.label.split("\\n")[1]})` : ""}`.trim(),
);
}
lines.push("", "Edges:");
for (const edge of edges) {
lines.push(`- ${edge.from} -> ${edge.to}${edge.label ? ` (${edge.label})` : ""}`);
}
if (edges.length === 0) lines.push("- (none)");
return lines.join("\n");
}
export function renderWorkflowGraph({ workflow, format, args = {} }: RenderGraphParams) {
const { nodes, edges } = collectGraph(workflow, args);
if (format === "dot") return renderDot(nodes, edges);
if (format === "ascii") return renderAscii(nodes, edges);
return renderMermaid(nodes, edges);
}

View File

@ -1,41 +1,44 @@
export const workflowRegistry = {
'github.pr.monitor': {
name: 'github.pr.monitor',
description: 'Fetch PR state via gh, diff against last run, emit only on change.',
"github.pr.monitor": {
name: "github.pr.monitor",
description: "Fetch PR state via gh, diff against last run, emit only on change.",
argsSchema: {
type: 'object',
type: "object",
properties: {
repo: { type: 'string', description: 'owner/repo (e.g. openclaw/openclaw)' },
pr: { type: 'number', description: 'Pull request number' },
key: { type: 'string', description: 'Optional state key override.' },
changesOnly: { type: 'boolean', description: 'If true, suppress output when unchanged.' },
summaryOnly: { type: 'boolean', description: 'If true, return only a compact change summary (smaller output).' },
repo: { type: "string", description: "owner/repo (e.g. openclaw/openclaw)" },
pr: { type: "number", description: "Pull request number" },
key: { type: "string", description: "Optional state key override." },
changesOnly: { type: "boolean", description: "If true, suppress output when unchanged." },
summaryOnly: {
type: "boolean",
description: "If true, return only a compact change summary (smaller output).",
},
},
required: ['repo', 'pr'],
required: ["repo", "pr"],
},
examples: [
{
args: { repo: 'openclaw/openclaw', pr: 1152 },
description: 'Monitor a PR and report when it changes.',
args: { repo: "openclaw/openclaw", pr: 1152 },
description: "Monitor a PR and report when it changes.",
},
],
sideEffects: [],
},
'github.pr.monitor.notify': {
name: 'github.pr.monitor.notify',
description: 'Monitor a PR and emit a single human-friendly message when it changes.',
"github.pr.monitor.notify": {
name: "github.pr.monitor.notify",
description: "Monitor a PR and emit a single human-friendly message when it changes.",
argsSchema: {
type: 'object',
type: "object",
properties: {
repo: { type: 'string', description: 'owner/repo (e.g. openclaw/openclaw)' },
pr: { type: 'number', description: 'Pull request number' },
key: { type: 'string', description: 'Optional state key override.' },
repo: { type: "string", description: "owner/repo (e.g. openclaw/openclaw)" },
pr: { type: "number", description: "Pull request number" },
key: { type: "string", description: "Optional state key override." },
},
required: ['repo', 'pr'],
required: ["repo", "pr"],
},
examples: [
{
args: { repo: 'openclaw/openclaw', pr: 1152 },
args: { repo: "openclaw/openclaw", pr: 1152 },
description: 'Emit "PR updated" message only when changed.',
},
],

View File

@ -1,213 +1,215 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { findStateKeyByApprovalId, writeApprovalIndex } from '../src/state/store.js';
import { findStateKeyByApprovalId, writeApprovalIndex } from "../src/state/store.js";
function runCli(args: string[], env: Record<string, string | undefined>) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
return spawnSync('node', [bin, ...args], {
encoding: 'utf8',
const bin = path.join(process.cwd(), "bin", "lobster.js");
return spawnSync("node", [bin, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
test('approval gate returns approvalId alongside resumeToken', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-'));
const stateDir = path.join(tmpDir, 'state');
test("approval gate returns approvalId alongside resumeToken", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'ok?' | pick a";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const json = JSON.parse(first.stdout);
assert.equal(json.status, 'needs_approval');
assert.ok(json.requiresApproval?.resumeToken, 'should have resumeToken');
assert.ok(json.requiresApproval?.approvalId, 'should have approvalId');
assert.equal(json.requiresApproval.approvalId.length, 8, 'approvalId should be 8 hex chars');
assert.match(json.requiresApproval.approvalId, /^[a-f0-9]{8}$/, 'approvalId should be hex');
assert.equal(json.status, "needs_approval");
assert.ok(json.requiresApproval?.resumeToken, "should have resumeToken");
assert.ok(json.requiresApproval?.approvalId, "should have approvalId");
assert.equal(json.requiresApproval.approvalId.length, 8, "approvalId should be 8 hex chars");
assert.match(json.requiresApproval.approvalId, /^[a-f0-9]{8}$/, "approvalId should be hex");
// Verify index file was written
const files = await fsp.readdir(stateDir);
const indexFiles = files.filter((name) => name.startsWith('approval_'));
assert.equal(indexFiles.length, 1, 'should have one approval index file');
const indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 1, "should have one approval index file");
});
test('resume with --id works as alternative to --token', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-resume-'));
const stateDir = path.join(tmpDir, 'state');
test("resume with --id works as alternative to --token", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-resume-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{b:2}]))'\" | approve --prompt 'ok?' | pick b";
// Step 1: Run pipeline, get approval ID
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const firstJson = JSON.parse(first.stdout);
assert.equal(firstJson.status, 'needs_approval');
assert.equal(firstJson.status, "needs_approval");
const approvalId = firstJson.requiresApproval.approvalId;
assert.ok(approvalId);
// Step 2: Resume using --id instead of --token
const resumed = runCli(
['resume', '--id', approvalId, '--approve', 'yes'],
{ LOBSTER_STATE_DIR: stateDir },
);
const resumed = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(resumed.status, 0, `stderr: ${resumed.stderr}`);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ b: 2 }]);
// Step 3: Verify cleanup — approval index should be deleted
const files = await fsp.readdir(stateDir);
const indexFiles = files.filter((name) => name.startsWith('approval_'));
assert.equal(indexFiles.length, 0, 'approval index should be cleaned up after resume');
const indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 0, "approval index should be cleaned up after resume");
});
test('resume with --id cancellation works', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-cancel-'));
const stateDir = path.join(tmpDir, 'state');
test("resume with --id cancellation works", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-cancel-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{c:3}]))'\" | approve --prompt 'ok?' | pick c";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
const approvalId = firstJson.requiresApproval.approvalId;
const cancelled = runCli(
['resume', '--id', approvalId, '--approve', 'no'],
{ LOBSTER_STATE_DIR: stateDir },
);
const cancelled = runCli(["resume", "--id", approvalId, "--approve", "no"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(cancelled.status, 0);
const cancelledJson = JSON.parse(cancelled.stdout);
assert.equal(cancelledJson.status, 'cancelled');
assert.equal(cancelledJson.status, "cancelled");
});
test('resume with invalid --id returns clear error', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-invalid-'));
const stateDir = path.join(tmpDir, 'state');
test("resume with invalid --id returns clear error", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-invalid-"));
const stateDir = path.join(tmpDir, "state");
const result = runCli(
['resume', '--id', 'deadbeef', '--approve', 'yes'],
{ LOBSTER_STATE_DIR: stateDir },
);
const result = runCli(["resume", "--id", "deadbeef", "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
// Should fail with a clear error message
const json = JSON.parse(result.stdout);
assert.equal(json.ok, false);
assert.ok(json.error?.message?.includes('not found'), `Error should mention not found: ${json.error?.message}`);
assert.ok(
json.error?.message?.includes("not found"),
`Error should mention not found: ${json.error?.message}`,
);
});
test('--token resume cleans up orphaned approval index', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-orphan-'));
const stateDir = path.join(tmpDir, 'state');
test("--token resume cleans up orphaned approval index", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-orphan-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{e:5}]))'\" | approve --prompt 'ok?' | pick e";
// Step 1: Run pipeline, get both approvalId and resumeToken
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
assert.ok(firstJson.requiresApproval?.approvalId);
assert.ok(firstJson.requiresApproval?.resumeToken);
// Verify index file exists
let files = await fsp.readdir(stateDir);
let indexFiles = files.filter((name) => name.startsWith('approval_'));
assert.equal(indexFiles.length, 1, 'approval index should exist before resume');
let indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 1, "approval index should exist before resume");
// Step 2: Resume using --token (NOT --id)
const resumed = runCli(
['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'yes'],
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "yes"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.equal(resumedJson.status, "ok");
// Step 3: Verify approval index was cleaned up despite using --token
files = await fsp.readdir(stateDir);
indexFiles = files.filter((name) => name.startsWith('approval_'));
assert.equal(indexFiles.length, 0, 'approval index should be cleaned up even when using --token');
indexFiles = files.filter((name) => name.startsWith("approval_"));
assert.equal(indexFiles.length, 0, "approval index should be cleaned up even when using --token");
});
test('double-resume with same --id returns clear error', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-double-'));
const stateDir = path.join(tmpDir, 'state');
test("double-resume with same --id returns clear error", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-double-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{f:6}]))'\" | approve --prompt 'ok?' | pick f";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
const approvalId = firstJson.requiresApproval.approvalId;
// First resume — should succeed
const resumed = runCli(
['resume', '--id', approvalId, '--approve', 'yes'],
{ LOBSTER_STATE_DIR: stateDir },
);
const resumed = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.equal(resumedJson.status, "ok");
// Second resume with same ID — should fail cleanly, not crash
const second = runCli(
['resume', '--id', approvalId, '--approve', 'yes'],
{ LOBSTER_STATE_DIR: stateDir },
);
const second = runCli(["resume", "--id", approvalId, "--approve", "yes"], {
LOBSTER_STATE_DIR: stateDir,
});
const secondJson = JSON.parse(second.stdout);
assert.equal(secondJson.ok, false);
assert.ok(secondJson.error?.message?.includes('not found'), `Should report not found: ${secondJson.error?.message}`);
assert.ok(
secondJson.error?.message?.includes("not found"),
`Should report not found: ${secondJson.error?.message}`,
);
});
test('backward compat: --token still works when approvalId is present', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-compat-'));
const stateDir = path.join(tmpDir, 'state');
test("backward compat: --token still works when approvalId is present", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-compat-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{d:4}]))'\" | approve --prompt 'ok?' | pick d";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
const firstJson = JSON.parse(first.stdout);
assert.ok(firstJson.requiresApproval?.approvalId, 'approvalId present');
assert.ok(firstJson.requiresApproval?.resumeToken, 'resumeToken present');
assert.ok(firstJson.requiresApproval?.approvalId, "approvalId present");
assert.ok(firstJson.requiresApproval?.resumeToken, "resumeToken present");
// Resume using the old --token approach — should still work
const resumed = runCli(
['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'yes'],
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "yes"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ d: 4 }]);
});
test('approval index writes never overwrite an existing approval ID mapping', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-aid-collision-'));
const stateDir = path.join(tmpDir, 'state');
test("approval index writes never overwrite an existing approval ID mapping", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-aid-collision-"));
const stateDir = path.join(tmpDir, "state");
const env = { LOBSTER_STATE_DIR: stateDir };
await writeApprovalIndex({
env,
stateKey: 'workflow_resume_original',
approvalId: 'deadbeef',
stateKey: "workflow_resume_original",
approvalId: "deadbeef",
});
await assert.rejects(
() => writeApprovalIndex({
env,
stateKey: 'workflow_resume_replacement',
approvalId: 'deadbeef',
}),
(err: NodeJS.ErrnoException) => err?.code === 'EEXIST',
() =>
writeApprovalIndex({
env,
stateKey: "workflow_resume_replacement",
approvalId: "deadbeef",
}),
(err: NodeJS.ErrnoException) => err?.code === "EEXIST",
);
const resolved = await findStateKeyByApprovalId({ env, approvalId: 'deadbeef' });
assert.equal(resolved, 'workflow_resume_original');
const resolved = await findStateKeyByApprovalId({ env, approvalId: "deadbeef" });
assert.equal(resolved, "workflow_resume_original");
});

View File

@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -8,17 +8,17 @@ function streamOf(items) {
})();
}
test('approve preview includes stdin sample when requested', async () => {
test("approve preview includes stdin sample when requested", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('approve');
const cmd = registry.get("approve");
const result = await cmd.run({
input: streamOf([{ a: 1 }, { a: 2 }]),
args: {
_: [],
emit: true,
prompt: 'ok?',
'preview-from-stdin': true,
prompt: "ok?",
"preview-from-stdin": true,
limit: 1,
},
ctx: {
@ -27,13 +27,13 @@ test('approve preview includes stdin sample when requested', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const item of result.output) items.push(item);
assert.equal(items[0].type, 'approval_request');
assert.equal(items[0].type, "approval_request");
assert.ok(String(items[0].preview).includes('"a": 1'));
});

View File

@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -9,22 +9,22 @@ function streamOf(items) {
})();
}
test('openclaw.invoke posts to /tools/invoke and returns JSON', async () => {
test("openclaw.invoke posts to /tools/invoke and returns JSON", async () => {
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
const parsed = JSON.parse(body);
assert.equal(parsed.tool, 'demo');
assert.equal(parsed.action, 'ping');
res.writeHead(200, { 'content-type': 'application/json' });
assert.equal(parsed.tool, "demo");
assert.equal(parsed.action, "ping");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, result: [{ ok: true, echo: parsed.args }] }));
});
});
@ -35,16 +35,16 @@ test('openclaw.invoke posts to /tools/invoke and returns JSON', async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get('openclaw.invoke');
const cmd = registry.get("openclaw.invoke");
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: 'demo',
action: 'ping',
'args-json': '{"hello":"world"}',
tool: "demo",
action: "ping",
"args-json": '{"hello":"world"}',
},
ctx: {
stdin: process.stdin,
@ -52,35 +52,35 @@ test('openclaw.invoke posts to /tools/invoke and returns JSON', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [{ ok: true, echo: { hello: 'world' } }]);
assert.deepEqual(items, [{ ok: true, echo: { hello: "world" } }]);
} finally {
server.close();
}
});
test('openclaw.invoke --each maps input items into tool args', async () => {
test("openclaw.invoke --each maps input items into tool args", async () => {
const seen: Array<{ call: number; args: unknown }> = [];
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
const parsed = JSON.parse(body);
seen.push({ call: seen.length + 1, args: parsed.args });
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, result: [{ ok: true, call: seen.length }] }));
});
});
@ -91,18 +91,18 @@ test('openclaw.invoke --each maps input items into tool args', async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get('openclaw.invoke');
const cmd = registry.get("openclaw.invoke");
const result = await cmd.run({
input: streamOf(['a', 'b']),
input: streamOf(["a", "b"]),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: 'demo',
action: 'ping',
tool: "demo",
action: "ping",
each: true,
'item-key': 'message',
'args-json': '{"channel":"test"}',
"item-key": "message",
"args-json": '{"channel":"test"}',
},
ctx: {
stdin: process.stdin,
@ -110,17 +110,20 @@ test('openclaw.invoke --each maps input items into tool args', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [{ ok: true, call: 1 }, { ok: true, call: 2 }]);
assert.deepEqual(items, [
{ ok: true, call: 1 },
{ ok: true, call: 2 },
]);
assert.deepEqual(seen, [
{ call: 1, args: { channel: 'test', message: 'a' } },
{ call: 2, args: { channel: 'test', message: 'b' } },
{ call: 1, args: { channel: "test", message: "a" } },
{ call: 2, args: { channel: "test", message: "b" } },
]);
} finally {
server.close();

View File

@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -9,22 +9,22 @@ function streamOf(items) {
})();
}
test('openclaw.invoke accepts legacy raw JSON response', async () => {
test("openclaw.invoke accepts legacy raw JSON response", async () => {
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => (body += d));
req.on('end', () => {
let body = "";
req.setEncoding("utf8");
req.on("data", (d) => (body += d));
req.on("end", () => {
const parsed = JSON.parse(body);
assert.equal(parsed.tool, 'demo');
assert.equal(parsed.action, 'ping');
res.writeHead(200, { 'content-type': 'application/json' });
assert.equal(parsed.tool, "demo");
assert.equal(parsed.action, "ping");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify([{ ok: true, legacy: true, echo: parsed.args }]));
});
});
@ -35,16 +35,16 @@ test('openclaw.invoke accepts legacy raw JSON response', async () => {
try {
const registry = createDefaultRegistry();
const cmd = registry.get('openclaw.invoke');
const cmd = registry.get("openclaw.invoke");
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
url: `http://127.0.0.1:${port}`,
tool: 'demo',
action: 'ping',
'args-json': '{"hello":"world"}',
tool: "demo",
action: "ping",
"args-json": '{"hello":"world"}',
},
ctx: {
stdin: process.stdin,
@ -52,14 +52,14 @@ test('openclaw.invoke accepts legacy raw JSON response', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
const items = [];
for await (const it of result.output) items.push(it);
assert.deepEqual(items, [{ ok: true, legacy: true, echo: { hello: 'world' } }]);
assert.deepEqual(items, [{ ok: true, legacy: true, echo: { hello: "world" } }]);
} finally {
server.close();
}

View File

@ -1,39 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
function runLobster(args: string[], opts?: { env?: Record<string, string | undefined> }) {
const res = spawnSync(process.execPath, [path.join('bin', 'lobster.js'), ...args], {
cwd: path.resolve('.'),
const res = spawnSync(process.execPath, [path.join("bin", "lobster.js"), ...args], {
cwd: path.resolve("."),
env: { ...process.env, ...(opts?.env ?? undefined) },
encoding: 'utf8',
encoding: "utf8",
});
return res;
}
test('cli: run --file passes --args-json into workflow args', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-cli-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
test("cli: run --file passes --args-json into workflow args", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cli-"));
const filePath = path.join(tmpDir, "workflow.lobster");
// Print both template-substituted arg and env-injected arg (LOBSTER_ARG_TASK)
// so we catch regressions in either path.
const workflow = `name: test\nargs:\n task:\n default: ""\nsteps:\n - id: s\n command: >\n node -e "process.stdout.write(JSON.stringify({task: '\${task}', env: process.env.LOBSTER_ARG_TASK}))"\n`;
await fsp.writeFile(filePath, workflow, 'utf8');
await fsp.writeFile(filePath, workflow, "utf8");
const res = runLobster([
'run',
'--file',
filePath,
'--args-json',
'{"task":"test"}',
]);
const res = runLobster(["run", "--file", filePath, "--args-json", '{"task":"test"}']);
assert.equal(res.status, 0, `expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
const parsed = JSON.parse(String(res.stdout).trim());
assert.deepEqual(parsed, [{ task: 'test', env: 'test' }]);
assert.deepEqual(parsed, [{ task: "test", env: "test" }]);
});

View File

@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -8,10 +8,10 @@ function streamOf(items) {
})();
}
test('commands.list returns command inventory including stdlib + workflows', async () => {
test("commands.list returns command inventory including stdlib + workflows", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('commands.list');
assert.ok(cmd, 'commands.list should be registered');
const cmd = registry.get("commands.list");
assert.ok(cmd, "commands.list should be registered");
const res = await cmd.run({
input: streamOf([]),
@ -22,7 +22,7 @@ test('commands.list returns command inventory including stdlib + workflows', asy
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
@ -33,15 +33,15 @@ test('commands.list returns command inventory including stdlib + workflows', asy
const names = items.map((x) => x.name).sort();
// A couple representative commands we always expect.
assert.ok(names.includes('exec'));
assert.ok(names.includes('json'));
assert.ok(names.includes('llm.invoke'));
assert.ok(names.includes('workflows.list'));
assert.ok(names.includes('commands.list'));
assert.ok(names.includes("exec"));
assert.ok(names.includes("json"));
assert.ok(names.includes("llm.invoke"));
assert.ok(names.includes("workflows.list"));
assert.ok(names.includes("commands.list"));
const self = items.find((x) => x.name === 'commands.list');
const self = items.find((x) => x.name === "commands.list");
assert.ok(self);
assert.equal(typeof self.description, 'string');
assert.equal(typeof self.description, "string");
assert.ok(self.description.length > 0);
// Schema should be present for commands that declare it.
assert.ok(self.argsSchema);

View File

@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cond-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
},
});
}
test("condition > works with numbers", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({count:5}))"' },
{ id: "check", command: 'echo "big"', when: "$data.json.count > 3" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["big\n"]);
});
test("condition > skips when false", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({count:1}))"' },
{ id: "check", command: 'echo "big"', when: "$data.json.count > 3" },
{ id: "fallback", command: 'echo "small"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["small\n"]);
});
test("condition < works", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:2}))"' },
{ id: "check", command: 'echo "low"', when: "$data.json.val < 10" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["low\n"]);
});
test("condition >= works at boundary", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val >= 5" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["yes\n"]);
});
test("condition <= works at boundary", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val <= 5" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["yes\n"]);
});
test("comparison operators combine with boolean operators", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({a:5,b:20}))"' },
{ id: "check", command: 'echo "in range"', when: "$data.json.a >= 1 && $data.json.b < 100" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["in range\n"]);
});
test("comparison with non-numeric string returns false", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:\\"hello\\"}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val > 3" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("comparison rejects boolean as non-numeric", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:true}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val > 0" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("comparison rejects null as non-numeric", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({val:null}))"' },
{ id: "check", command: 'echo "yes"', when: "$data.json.val >= 0" },
{ id: "fallback", command: 'echo "no"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["no\n"]);
});
test("existing == and != still work with new operators", async () => {
const result = await runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({status:\\"ok\\"}))"' },
{ id: "check", command: 'echo "good"', when: '$data.json.status == "ok"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["good\n"]);
});

View File

@ -1,28 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { resumeToolRequest, runToolRequest } from '../src/core/index.js';
import { resumeToolRequest, runToolRequest } from "../src/core/index.js";
function createDirectAdapter(resultText: string) {
const calls: Array<Record<string, unknown>> = [];
return {
calls,
adapter: {
source: 'test',
source: "test",
async invoke({ payload }: { payload: Record<string, unknown> }) {
calls.push(payload);
return {
ok: true,
result: {
runId: 'adapter_1',
model: 'test/model',
runId: "adapter_1",
model: "test/model",
prompt: payload.prompt,
status: 'completed',
status: "completed",
output: {
format: 'json',
format: "json",
text: resultText,
data: JSON.parse(resultText),
},
@ -33,7 +33,7 @@ function createDirectAdapter(resultText: string) {
};
}
test('runToolRequest executes pipeline with injected llm adapter', async () => {
test("runToolRequest executes pipeline with injected llm adapter", async () => {
const { adapter, calls } = createDirectAdapter('{"recommendation":"no jacket"}');
const envelope = await runToolRequest({
pipeline:
@ -41,8 +41,8 @@ test('runToolRequest executes pipeline with injected llm adapter', async () => {
ctx: {
env: {
...process.env,
LOBSTER_LLM_PROVIDER: 'pi',
LOBSTER_LLM_MODEL: 'test/model',
LOBSTER_LLM_PROVIDER: "pi",
LOBSTER_LLM_MODEL: "test/model",
},
llmAdapters: {
pi: adapter,
@ -51,17 +51,17 @@ test('runToolRequest executes pipeline with injected llm adapter', async () => {
});
assert.equal(envelope.ok, true);
assert.equal(envelope.status, 'ok');
assert.equal(envelope.status, "ok");
assert.equal(envelope.output?.length, 1);
assert.equal((envelope.output![0] as any).output.data.recommendation, 'no jacket');
assert.equal((envelope.output![0] as any).output.data.recommendation, "no jacket");
assert.equal(calls.length, 1);
assert.equal((calls[0] as any).model, 'test/model');
assert.equal((calls[0] as any).model, "test/model");
});
test('resumeToolRequest completes approval-gated workflow with injected llm adapter', async () => {
test("resumeToolRequest completes approval-gated workflow with injected llm adapter", async () => {
const { adapter, calls } = createDirectAdapter('{"recommendation":"no","reason":"warm"}');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-core-tool-runtime-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-core-tool-runtime-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
@ -69,33 +69,33 @@ test('resumeToolRequest completes approval-gated workflow with injected llm adap
{
steps: [
{
id: 'fetch',
run: 'node -e "process.stdout.write(JSON.stringify({location:\'Phoenix\',temp_f:73.8}))"',
id: "fetch",
run: "node -e \"process.stdout.write(JSON.stringify({location:'Phoenix',temp_f:73.8}))\"",
},
{
id: 'confirm',
approval: 'Want jacket advice?',
stdin: '$fetch.json',
id: "confirm",
approval: "Want jacket advice?",
stdin: "$fetch.json",
},
{
id: 'advice',
id: "advice",
pipeline: 'llm.invoke --provider pi --prompt "Return JSON." --disable-cache',
stdin: '$fetch.json',
when: '$confirm.approved',
stdin: "$fetch.json",
when: "$confirm.approved",
},
],
},
null,
2,
),
'utf8',
"utf8",
);
const env = {
...process.env,
LOBSTER_STATE_DIR: path.join(tmpDir, 'state'),
LOBSTER_LLM_PROVIDER: 'pi',
LOBSTER_LLM_MODEL: 'test/model',
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
LOBSTER_LLM_PROVIDER: "pi",
LOBSTER_LLM_MODEL: "test/model",
};
const first = await runToolRequest({
@ -108,11 +108,11 @@ test('resumeToolRequest completes approval-gated workflow with injected llm adap
});
assert.equal(first.ok, true);
assert.equal(first.status, 'needs_approval');
assert.equal(first.status, "needs_approval");
assert.ok(first.requiresApproval?.resumeToken);
const resumed = await resumeToolRequest({
token: first.requiresApproval?.resumeToken ?? '',
token: first.requiresApproval?.resumeToken ?? "",
approved: true,
ctx: {
cwd: tmpDir,
@ -122,14 +122,14 @@ test('resumeToolRequest completes approval-gated workflow with injected llm adap
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, 'ok');
assert.equal((resumed.output![0] as any).output.data.reason, 'warm');
assert.equal(resumed.status, "ok");
assert.equal((resumed.output![0] as any).output.data.reason, "warm");
assert.equal(calls.length, 1);
});
test('runToolRequest/resumeToolRequest handles needs_input workflow pauses', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-core-tool-input-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
test("runToolRequest/resumeToolRequest handles needs_input workflow pauses", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-core-tool-input-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
@ -137,26 +137,26 @@ test('runToolRequest/resumeToolRequest handles needs_input workflow pauses', asy
{
steps: [
{
id: 'draft',
run: 'node -e "process.stdout.write(JSON.stringify({text:\'hello\'}))"',
id: "draft",
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
},
{
id: 'review',
id: "review",
input: {
prompt: 'Review draft?',
prompt: "Review draft?",
responseSchema: {
type: 'object',
properties: { decision: { type: 'string' } },
required: ['decision'],
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: 'finish',
id: "finish",
run: 'node -e "process.stdout.write(JSON.stringify({decision:process.env.DECISION,subject:process.env.SUBJECT}))"',
env: {
DECISION: '$review.response.decision',
SUBJECT: '$review.subject.text',
DECISION: "$review.response.decision",
SUBJECT: "$review.subject.text",
},
},
],
@ -164,12 +164,12 @@ test('runToolRequest/resumeToolRequest handles needs_input workflow pauses', asy
null,
2,
),
'utf8',
"utf8",
);
const env = {
...process.env,
LOBSTER_STATE_DIR: path.join(tmpDir, 'state'),
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
};
const first = await runToolRequest({
@ -178,17 +178,17 @@ test('runToolRequest/resumeToolRequest handles needs_input workflow pauses', asy
});
assert.equal(first.ok, true);
assert.equal(first.status, 'needs_input');
assert.deepEqual(first.requiresInput?.subject, { text: 'hello' });
assert.equal(first.status, "needs_input");
assert.deepEqual(first.requiresInput?.subject, { text: "hello" });
assert.ok(first.requiresInput?.resumeToken);
const resumed = await resumeToolRequest({
token: first.requiresInput?.resumeToken ?? '',
response: { decision: 'approve' },
token: first.requiresInput?.resumeToken ?? "",
response: { decision: "approve" },
ctx: { cwd: tmpDir, env },
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, 'ok');
assert.deepEqual(resumed.output, [{ decision: 'approve', subject: 'hello' }]);
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
});

147
test/cost_tracker.test.ts Normal file
View File

@ -0,0 +1,147 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { CostTracker } from "../src/core/cost_tracker.js";
import { runWorkflowFile } from "../src/workflows/file.js";
test("CostTracker records usage and computes totals", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 1000, outputTokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.totalInputTokens, 1000);
assert.equal(summary.totalOutputTokens, 500);
assert.equal(summary.estimatedCostUsd, 0.0075);
assert.equal(summary.byStep.length, 1);
assert.equal(summary.byStep[0].stepId, "step1");
});
test("CostTracker handles OpenAI token field names", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { prompt_tokens: 1000, completion_tokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.totalInputTokens, 1000);
assert.equal(summary.totalOutputTokens, 500);
});
test("CostTracker uses zero cost for unknown models", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "unknown-model", { inputTokens: 1000, outputTokens: 500 });
const summary = tracker.getSummary();
assert.equal(summary.estimatedCostUsd, 0);
});
test("CostTracker supports custom pricing from env json", () => {
const pricing = CostTracker.parsePricingFromEnv({
LOBSTER_LLM_PRICING_JSON: '{"my-model":{"input":1.0,"output":2.0}}',
});
const tracker = new CostTracker(pricing);
tracker.recordUsage("step1", "my-model", { inputTokens: 1_000_000, outputTokens: 1_000_000 });
assert.equal(tracker.getSummary().estimatedCostUsd, 3);
});
test("CostTracker checkLimit throws when action=stop and limit exceeded", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 10_000_000, outputTokens: 10_000_000 });
assert.throws(() => tracker.checkLimit({ max_usd: 0.01, action: "stop" }), /Cost limit exceeded/);
});
test("CostTracker checkLimit warns when action=warn and limit exceeded", () => {
const tracker = new CostTracker();
tracker.recordUsage("step1", "gpt-4o", { inputTokens: 10_000_000, outputTokens: 10_000_000 });
const stderr = new PassThrough();
let out = "";
stderr.on("data", (d: Buffer | string) => {
out += String(d);
});
tracker.checkLimit({ max_usd: 0.01, action: "warn" }, stderr);
assert.match(out, /\[WARN\] Cost/);
});
async function runWorkflow(workflow: unknown, envOverride?: Record<string, string>) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderr = new PassThrough();
let stderrOutput = "";
stderr.on("data", (d: Buffer | string) => {
stderrOutput += String(d);
});
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir, ...(envOverride ?? {}) },
mode: "tool",
},
});
return { result, stderrOutput };
}
test("workflow result includes _meta.cost when usage is present", async () => {
const { result } = await runWorkflow({
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:100,outputTokens:50},output:{text:'hi'}}))\"",
},
],
});
assert.equal(result.status, "ok");
assert.ok(result._meta?.cost);
assert.equal(result._meta!.cost!.totalInputTokens, 100);
assert.equal(result._meta!.cost!.totalOutputTokens, 50);
assert.equal(result._meta!.cost!.byStep[0].model, "gpt-4o");
});
test("workflow result omits _meta.cost when no usage exists", async () => {
const { result } = await runWorkflow({
steps: [{ id: "plain", command: 'echo "hello"' }],
});
assert.equal(result.status, "ok");
assert.equal(result._meta, undefined);
});
test("cost_limit warn logs warning and continues", async () => {
const { result, stderrOutput } = await runWorkflow({
cost_limit: { max_usd: 0.00001, action: "warn" },
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:1000,outputTokens:1000}}))\"",
},
{ id: "after", command: "echo done" },
],
});
assert.equal(result.status, "ok");
assert.match(stderrOutput, /\[WARN\] Cost/);
assert.deepEqual(result.output, ["done\n"]);
});
test("cost_limit stop throws when exceeded", async () => {
await assert.rejects(
() =>
runWorkflow({
cost_limit: { max_usd: 0.00001, action: "stop" },
steps: [
{
id: "llm",
command:
"node -e \"process.stdout.write(JSON.stringify({model:'gpt-4o',usage:{inputTokens:1000,outputTokens:1000}}))\"",
},
],
}).then((x) => x.result),
/Cost limit exceeded/,
);
});

View File

@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,34 +15,36 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test('dedupe removes duplicate primitives (stable)', async () => {
const out = await run('dedupe', [1, 2, 1, 3, 2]);
test("dedupe removes duplicate primitives (stable)", async () => {
const out = await run("dedupe", [1, 2, 1, 3, 2]);
assert.deepEqual(out, [1, 2, 3]);
});
test('dedupe supports --key', async () => {
test("dedupe supports --key", async () => {
const input = [
{ id: 'a', v: 1 },
{ id: 'b', v: 2 },
{ id: 'a', v: 3 },
{ id: "a", v: 1 },
{ id: "b", v: 2 },
{ id: "a", v: 3 },
];
const out = await run('dedupe --key id', input);
const out = await run("dedupe --key id", input);
assert.deepEqual(out, [input[0], input[1]]);
});
test('dedupe treats undefined keys as a key value', async () => {
test("dedupe treats undefined keys as a key value", async () => {
const input = [
{ id: undefined, v: 1 },
{ id: undefined, v: 2 },
{ id: 'x', v: 3 },
{ id: "x", v: 3 },
];
const out = await run('dedupe --key id', input);
const out = await run("dedupe --key id", input);
assert.equal(out.length, 2);
assert.equal(out[0].v, 1);
assert.equal(out[1].v, 3);

View File

@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { mkdtempSync } from 'node:fs';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtempSync } from "node:fs";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -11,16 +11,24 @@ function streamOf(items) {
})();
}
test('diff.last reports changed on first run and not changed on same input', async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-diff-'));
test("diff.last reports changed on first run and not changed on same input", async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-"));
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
const registry = createDefaultRegistry();
const cmd = registry.get('diff.last');
const cmd = registry.get("diff.last");
const first = await cmd.run({
input: streamOf([{ a: 1 }]),
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
});
const out1 = [];
for await (const it of first.output) out1.push(it);
@ -28,8 +36,16 @@ test('diff.last reports changed on first run and not changed on same input', asy
const second = await cmd.run({
input: streamOf([{ a: 1 }]),
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
});
const out2 = [];
for await (const it of second.output) out2.push(it);
@ -37,8 +53,16 @@ test('diff.last reports changed on first run and not changed on same input', asy
const third = await cmd.run({
input: streamOf([{ a: 2 }]),
args: { _: [], key: 'k' },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
args: { _: [], key: "k" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
});
const out3 = [];
for await (const it of third.output) out3.push(it);

View File

@ -1,19 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
test('doctor returns tool-mode ok with version', () => {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'doctor'], {
encoding: 'utf8',
env: { ...process.env, LOBSTER_STATE_DIR: path.join(process.cwd(), '.tmp-test-state') },
test("doctor returns tool-mode ok with version", () => {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync("node", [bin, "doctor"], {
encoding: "utf8",
env: { ...process.env, LOBSTER_STATE_DIR: path.join(process.cwd(), ".tmp-test-state") },
});
assert.equal(res.status, 0);
const out = JSON.parse(res.stdout);
assert.equal(out.ok, true);
assert.equal(out.protocolVersion, 1);
assert.equal(out.status, 'ok');
assert.equal(out.status, "ok");
assert.equal(out.output[0].toolMode, true);
assert.ok(typeof out.output[0].version === 'string');
assert.ok(typeof out.output[0].version === "string");
});

View File

@ -1,19 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { createDefaultRegistry } from '../src/commands/registry.js';
import { runWorkflowFile } from '../src/workflows/file.js';
import { PassThrough } from 'node:stream';
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runWorkflowFile } from "../src/workflows/file.js";
import { PassThrough } from "node:stream";
function runLobster(args: string[], opts?: { env?: Record<string, string | undefined> }) {
const res = spawnSync(process.execPath, [path.join('bin', 'lobster.js'), ...args], {
cwd: path.resolve('.'),
const res = spawnSync(process.execPath, [path.join("bin", "lobster.js"), ...args], {
cwd: path.resolve("."),
env: { ...process.env, ...(opts?.env ?? undefined) },
encoding: 'utf8',
encoding: "utf8",
});
return res;
}
@ -21,12 +21,16 @@ function runLobster(args: string[], opts?: { env?: Record<string, string | undef
function createStreams() {
const stdout = new PassThrough();
const stderr = new PassThrough();
let stdoutData = '';
let stderrData = '';
stdout.setEncoding('utf8');
stderr.setEncoding('utf8');
stdout.on('data', (chunk) => { stdoutData += chunk; });
stderr.on('data', (chunk) => { stderrData += chunk; });
let stdoutData = "";
let stderrData = "";
stdout.setEncoding("utf8");
stderr.setEncoding("utf8");
stdout.on("data", (chunk) => {
stdoutData += chunk;
});
stderr.on("data", (chunk) => {
stderrData += chunk;
});
return {
stdout,
stderr,
@ -35,20 +39,20 @@ function createStreams() {
};
}
test('dry-run of a 3-step workflow file (shell steps)', async () => {
test("dry-run of a 3-step workflow file (shell steps)", async () => {
const workflow = {
name: 'test-dry-run',
args: { url: { default: 'https://example.com' } },
name: "test-dry-run",
args: { url: { default: "https://example.com" } },
steps: [
{ id: 'fetch-data', run: 'curl ${url}' },
{ id: 'transform', run: 'jq ".items"' },
{ id: 'upload', run: 'curl -X POST https://api.example.com' },
{ id: "fetch-data", run: "curl ${url}" },
{ id: "transform", run: 'jq ".items"' },
{ id: "upload", run: "curl -X POST https://api.example.com" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -59,12 +63,12 @@ test('dry-run of a 3-step workflow file (shell steps)', async () => {
stdout,
stderr,
env: { ...process.env },
mode: 'human',
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
@ -76,22 +80,22 @@ test('dry-run of a 3-step workflow file (shell steps)', async () => {
assert.match(output, /upload\s+\[shell\]/);
});
test('dry-run of a workflow with an approval step', async () => {
test("dry-run of a workflow with an approval step", async () => {
const workflow = {
steps: [
{ id: 'collect', run: 'echo hello' },
{ id: "collect", run: "echo hello" },
{
id: 'approve_step',
run: 'echo check',
approval: 'Proceed with deployment?',
id: "approve_step",
run: "echo check",
approval: "Proceed with deployment?",
},
{ id: 'deploy', run: 'echo deploying' },
{ id: "deploy", run: "echo deploying" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-approval-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approval-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -102,12 +106,12 @@ test('dry-run of a workflow with an approval step', async () => {
stdout,
stderr,
env: { ...process.env },
mode: 'tool',
mode: "tool",
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
@ -115,22 +119,22 @@ test('dry-run of a workflow with an approval step', async () => {
assert.match(output, /\[approval required\]/);
});
test('dry-run of a workflow with a conditional step that would be skipped', async () => {
test("dry-run of a workflow with a conditional step that would be skipped", async () => {
const workflow = {
steps: [
{ id: 'gate', run: 'echo gate' },
{ id: "gate", run: "echo gate" },
{
id: 'conditional',
run: 'echo conditional',
id: "conditional",
run: "echo conditional",
condition: false,
},
{ id: 'final', run: 'echo final' },
{ id: "final", run: "echo final" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-cond-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-cond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -141,12 +145,12 @@ test('dry-run of a workflow with a conditional step that would be skipped', asyn
stdout,
stderr,
env: { ...process.env },
mode: 'human',
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /conditional\s+\[skipped — condition: false\]/);
@ -154,71 +158,85 @@ test('dry-run of a workflow with a conditional step that would be skipped', asyn
assert.match(output, /final\s+\[shell\]/);
});
test('dry-run of an inline pipeline string via CLI', async () => {
const res = runLobster(['run', '--dry-run', 'exec --json "echo [1,2,3]" | where active=true | table']);
test("dry-run of an inline pipeline string via CLI", async () => {
const res = runLobster([
"run",
"--dry-run",
'exec --json "echo [1,2,3]" | where active=true | table',
]);
assert.equal(res.status, 0, `expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
assert.match(res.stderr, /\[DRY RUN\] Pipeline/);
assert.match(res.stderr, /exec/);
assert.match(res.stderr, /where/);
assert.match(res.stderr, /table/);
// stdout must be clean — no JSON array leaking through
assert.equal(res.stdout.trim(), '', `expected empty stdout, got: ${res.stdout}`);
assert.equal(res.stdout.trim(), "", `expected empty stdout, got: ${res.stdout}`);
});
test('dry-run exits 0 on valid input', async () => {
test("dry-run exits 0 on valid input", async () => {
const workflow = {
steps: [
{ id: 'step1', run: 'echo hello' },
],
steps: [{ id: "step1", run: "echo hello" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-exit-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-exit-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(['run', '--dry-run', '--file', filePath]);
const res = runLobster(["run", "--dry-run", "--file", filePath]);
assert.equal(res.status, 0, `expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
assert.match(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /step1/);
// stdout must be clean — no JSON output
assert.equal(res.stdout.trim(), '', `expected empty stdout, got: ${res.stdout}`);
assert.equal(res.stdout.trim(), "", `expected empty stdout, got: ${res.stdout}`);
});
test('normal run still works (no regression)', async () => {
test("normal run still works (no regression)", async () => {
const workflow = {
steps: [
{
id: 'greet',
command: 'node -e "process.stdout.write(JSON.stringify({msg:\'hello\'}))"',
id: "greet",
command: "node -e \"process.stdout.write(JSON.stringify({msg:'hello'}))\"",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-regression-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-regression-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(['run', '--file', filePath]);
const res = runLobster(["run", "--file", filePath]);
assert.equal(res.status, 0, `expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.equal(
res.status,
0,
`expected exit 0, got ${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
);
const parsed = JSON.parse(res.stdout.trim());
assert.deepEqual(parsed, [{ msg: 'hello' }]);
assert.deepEqual(parsed, [{ msg: "hello" }]);
});
test('dry-run approval step sets approved:true so downstream conditions evaluate correctly', async () => {
test("dry-run approval step sets approved:true so downstream conditions evaluate correctly", async () => {
const workflow = {
steps: [
{ id: 'gate', run: 'echo gate', approval: 'Continue?' },
{ id: 'post-approval', run: 'echo done', when: '$gate.approved' },
{ id: "gate", run: "echo gate", approval: "Continue?" },
{ id: "post-approval", run: "echo done", when: "$gate.approved" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-approvcond-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approvcond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -229,30 +247,30 @@ test('dry-run approval step sets approved:true so downstream conditions evaluate
stdout,
stderr,
env: { ...process.env },
mode: 'human',
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
const output = getStderr();
// post-approval step should NOT be marked as skipped — approval is modeled as granted
assert.doesNotMatch(output, /post-approval.*skipped/);
assert.match(output, /post-approval\s+\[shell\]/);
});
test('dry-run workflow with pipeline step', async () => {
test("dry-run workflow with pipeline step", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: 'fetch', run: 'echo hello' },
{ id: 'process', pipeline: 'exec --json "echo [1]" | head 1' },
{ id: "fetch", run: "echo hello" },
{ id: "process", pipeline: 'exec --json "echo [1]" | head 1' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-pipeline-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-pipeline-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -263,13 +281,13 @@ test('dry-run workflow with pipeline step', async () => {
stdout,
stderr,
env: { ...process.env },
mode: 'human',
mode: "human",
registry,
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
assert.deepEqual(result.output, []);
const output = getStderr();
@ -278,248 +296,48 @@ test('dry-run workflow with pipeline step', async () => {
assert.match(output, /pipeline: exec --json "echo \[1\]" \| head 1/);
});
test('dry-run throws on stdin ref to non-existent step', async () => {
test("dry-run throws on stdin ref to non-existent step", async () => {
const workflow = {
steps: [
{ id: 'gen', run: 'echo hello' },
{ id: 'consumer', run: 'echo done', stdin: '$missing.stdout' },
{ id: "gen", run: "echo hello" },
{ id: "consumer", run: "echo done", stdin: "$missing.stdout" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-stdin-bad-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-stdin-bad-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr } = createStreams();
await assert.rejects(
() => runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', dryRun: true },
}),
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
}),
/Unknown step reference: missing\.stdout/,
);
});
test('dry-run annotates valid stdin step refs as unknown at plan time', async () => {
test("dry-run annotates valid stdin step refs as unknown at plan time", async () => {
const workflow = {
steps: [
{ id: 'gen', run: 'echo hello' },
{ id: 'consumer', run: 'echo done', stdin: '$gen.stdout' },
{ id: "gen", run: "echo hello" },
{ id: "consumer", run: "echo done", stdin: "$gen.stdout" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-stdin-valid-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', dryRun: true },
});
assert.equal(result.status, 'ok');
assert.match(getStderr(), /output unknown at plan time/);
});
test('dry-run preserves step output refs in shell commands instead of collapsing them to empty strings', async () => {
const workflow = {
steps: [
{ id: 'fetch', run: 'echo hello' },
{ id: 'render', run: 'echo A=[$fetch.stdout] B=[$fetch.json]' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-shellrefs-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', dryRun: true },
});
assert.equal(result.status, 'ok');
const output = getStderr();
assert.match(output, /run: echo A=\[\$fetch\.stdout\] B=\[\$fetch\.json\]/);
assert.match(output, /\[contains step output refs — unknown at plan time\]/);
assert.doesNotMatch(output, /run: echo A=\[\] B=\[\]/);
});
test('dry-run still resolves approved refs in shell commands', async () => {
const workflow = {
steps: [
{ id: 'gate', run: 'echo ok', approval: 'Continue?' },
{ id: 'render', run: 'echo approved=$gate.approved' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-approvedref-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', dryRun: true },
});
assert.equal(result.status, 'ok');
const output = getStderr();
assert.match(output, /run: echo approved=true/);
assert.doesNotMatch(output, /approved=\$gate\.approved/);
});
test('dry-run throws on pipeline step with unknown command', async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: 'step1', pipeline: 'not_a_real_command | head 1' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-badcmd-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const { stdout, stderr } = createStreams();
await assert.rejects(
() => runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', registry, dryRun: true },
}),
/unknown command: not_a_real_command/,
);
});
test('dry-run flag is consumed regardless of position in argv', async () => {
// --dry-run before pipeline tokens (canonical form)
const before = runLobster(['run', '--dry-run', 'exec --json "echo [1]"']);
assert.equal(before.status, 0);
assert.match(before.stderr, /\[DRY RUN\]/);
assert.equal(before.stdout.trim(), '');
// --dry-run after positional file arg: lobster run workflow.lobster --dry-run
// This is the primary use-case that was previously broken.
});
test('dry-run flag after positional file arg activates dry-run', async () => {
const workflow = {
steps: [{ id: 'greet', run: 'echo hello' }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-test-'));
const filePath = path.join(tmpDir, 'test.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow), 'utf8');
// Flag comes AFTER the positional file argument — must still activate dry-run.
const res = runLobster(['run', filePath, '--dry-run']);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /\[DRY RUN\]/, 'expected [DRY RUN] in stderr when flag is after file path');
assert.equal(res.stdout.trim(), '', 'expected no stdout in dry-run mode');
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test('dry-run flag still activates after positional workflow file when other Lobster flags are present', async () => {
const workflow = {
args: { name: { default: 'hello' } },
steps: [{ id: 'greet', run: 'echo ${name}' }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-test-args-'));
const filePath = path.join(tmpDir, 'test.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow), 'utf8');
const res = runLobster(['run', filePath, '--args-json', '{"name":"world"}', '--dry-run']);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /run: echo world/);
assert.equal(res.stdout.trim(), '');
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test('command-level --dry-run in the first stage is not stolen by Lobster dry-run parsing', () => {
const res = runLobster(['run', 'openclaw.invoke', '--tool', 'message', '--action', 'send', '--dry-run']);
assert.notEqual(res.status, 0, 'expected command execution to fail instead of Lobster dry-run exiting 0');
assert.doesNotMatch(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /requires --url or OPENCLAW_URL/);
});
test('dry-run allows pipeline stage names that still depend on prior step output', async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: 'planner', run: 'echo openclaw.invoke' },
{ id: 'call', pipeline: '$planner.stdout --tool message --action send' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-dynamic-stage-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: { stdin: process.stdin, stdout, stderr, env: { ...process.env }, mode: 'human', registry, dryRun: true },
});
assert.equal(result.status, 'ok');
const output = getStderr();
assert.match(output, /pipeline: \$planner\.stdout --tool message --action send/);
assert.match(output, /\[command validation deferred — stage name depends on step output\]/);
});
test('dry-run does not eat shell variables that only resemble step refs', async () => {
const workflow = {
steps: [
{ id: 'shell_vars', run: 'echo $HOME.json $PATH.stdout' },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-shell-vars-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const res = runLobster(['run', '--dry-run', filePath]);
assert.equal(res.status, 0);
assert.match(res.stderr, /echo \$HOME\.json \$PATH\.stdout/);
});
test('dry-run evaluates compound conditions with approvals, input placeholders, and parentheses', async () => {
const workflow = {
steps: [
{ id: 'gate', run: 'echo ok', approval: 'Continue?' },
{
id: 'review',
input: {
prompt: 'Review?',
responseSchema: {
type: 'object',
properties: { decision: { type: 'string' } },
required: ['decision'],
},
},
},
{
id: 'deploy',
run: 'echo deploy',
condition: '$gate.approved && ($review.response.pending == true || $review.skipped)',
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-dry-compound-cond-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-stdin-valid-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
@ -530,12 +348,270 @@ test('dry-run evaluates compound conditions with approvals, input placeholders,
stdout,
stderr,
env: { ...process.env },
mode: 'human',
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
assert.match(getStderr(), /output unknown at plan time/);
});
test("dry-run preserves step output refs in shell commands instead of collapsing them to empty strings", async () => {
const workflow = {
steps: [
{ id: "fetch", run: "echo hello" },
{ id: "render", run: "echo A=[$fetch.stdout] B=[$fetch.json]" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-shellrefs-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /run: echo A=\[\$fetch\.stdout\] B=\[\$fetch\.json\]/);
assert.match(output, /\[contains step output refs — unknown at plan time\]/);
assert.doesNotMatch(output, /run: echo A=\[\] B=\[\]/);
});
test("dry-run still resolves approved refs in shell commands", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo ok", approval: "Continue?" },
{ id: "render", run: "echo approved=$gate.approved" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-approvedref-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /run: echo approved=true/);
assert.doesNotMatch(output, /approved=\$gate\.approved/);
});
test("dry-run throws on pipeline step with unknown command", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [{ id: "step1", pipeline: "not_a_real_command | head 1" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-badcmd-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr } = createStreams();
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
registry,
dryRun: true,
},
}),
/unknown command: not_a_real_command/,
);
});
test("dry-run flag is consumed regardless of position in argv", async () => {
// --dry-run before pipeline tokens (canonical form)
const before = runLobster(["run", "--dry-run", 'exec --json "echo [1]"']);
assert.equal(before.status, 0);
assert.match(before.stderr, /\[DRY RUN\]/);
assert.equal(before.stdout.trim(), "");
// --dry-run after positional file arg: lobster run workflow.lobster --dry-run
// This is the primary use-case that was previously broken.
});
test("dry-run flag after positional file arg activates dry-run", async () => {
const workflow = {
steps: [{ id: "greet", run: "echo hello" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-test-"));
const filePath = path.join(tmpDir, "test.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow), "utf8");
// Flag comes AFTER the positional file argument — must still activate dry-run.
const res = runLobster(["run", filePath, "--dry-run"]);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(
res.stderr,
/\[DRY RUN\]/,
"expected [DRY RUN] in stderr when flag is after file path",
);
assert.equal(res.stdout.trim(), "", "expected no stdout in dry-run mode");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test("dry-run flag still activates after positional workflow file when other Lobster flags are present", async () => {
const workflow = {
args: { name: { default: "hello" } },
steps: [{ id: "greet", run: "echo ${name}" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-test-args-"));
const filePath = path.join(tmpDir, "test.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow), "utf8");
const res = runLobster(["run", filePath, "--args-json", '{"name":"world"}', "--dry-run"]);
assert.equal(res.status, 0, `expected exit 0\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /run: echo world/);
assert.equal(res.stdout.trim(), "");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
test("command-level --dry-run in the first stage is not stolen by Lobster dry-run parsing", () => {
const res = runLobster([
"run",
"openclaw.invoke",
"--tool",
"message",
"--action",
"send",
"--dry-run",
]);
assert.notEqual(
res.status,
0,
"expected command execution to fail instead of Lobster dry-run exiting 0",
);
assert.doesNotMatch(res.stderr, /\[DRY RUN\]/);
assert.match(res.stderr, /requires --url or OPENCLAW_URL/);
});
test("dry-run allows pipeline stage names that still depend on prior step output", async () => {
const registry = createDefaultRegistry();
const workflow = {
steps: [
{ id: "planner", run: "echo openclaw.invoke" },
{ id: "call", pipeline: "$planner.stdout --tool message --action send" },
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-dynamic-stage-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
registry,
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.match(output, /pipeline: \$planner\.stdout --tool message --action send/);
assert.match(output, /\[command validation deferred — stage name depends on step output\]/);
});
test("dry-run does not eat shell variables that only resemble step refs", async () => {
const workflow = {
steps: [{ id: "shell_vars", run: "echo $HOME.json $PATH.stdout" }],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-shell-vars-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const res = runLobster(["run", "--dry-run", filePath]);
assert.equal(res.status, 0);
assert.match(res.stderr, /echo \$HOME\.json \$PATH\.stdout/);
});
test("dry-run evaluates compound conditions with approvals, input placeholders, and parentheses", async () => {
const workflow = {
steps: [
{ id: "gate", run: "echo ok", approval: "Continue?" },
{
id: "review",
input: {
prompt: "Review?",
responseSchema: {
type: "object",
properties: { decision: { type: "string" } },
required: ["decision"],
},
},
},
{
id: "deploy",
run: "echo deploy",
condition: "$gate.approved && ($review.response.pending == true || $review.skipped)",
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-dry-compound-cond-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const { stdout, stderr, getStderr } = createStreams();
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout,
stderr,
env: { ...process.env },
mode: "human",
dryRun: true,
},
});
assert.equal(result.status, "ok");
const output = getStderr();
assert.doesNotMatch(output, /deploy.*skipped/);
assert.match(output, /deploy\s+\[shell\]/);

View File

@ -178,7 +178,9 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
})();
const res1 = await runPipeline({
pipeline: [{ name: "email.triage", args: { llm: true, model: "claude-test", limit: 20 }, raw: "" }],
pipeline: [
{ name: "email.triage", args: { llm: true, model: "claude-test", limit: 20 }, raw: "" },
],
registry,
input: input1,
stdin: process.stdin,

View File

@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items) {
return (async function* () {
@ -8,9 +8,9 @@ function streamOf(items) {
})();
}
test('exec --stdin jsonl feeds pipeline input to subprocess', async () => {
test("exec --stdin jsonl feeds pipeline input to subprocess", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('exec');
const cmd = registry.get("exec");
const nodeScript = [
"let d='';",
@ -19,13 +19,13 @@ test('exec --stdin jsonl feeds pipeline input to subprocess', async () => {
" const lines=d.trim().split('\\n').filter(Boolean);",
" console.log(JSON.stringify(lines));",
"});",
].join('');
].join("");
const result = await cmd.run({
input: streamOf([{ a: 1 }, { a: 2 }]),
args: {
_: ['node', '-e', nodeScript],
stdin: 'jsonl',
_: ["node", "-e", nodeScript],
stdin: "jsonl",
json: true,
},
ctx: {
@ -34,7 +34,7 @@ test('exec --stdin jsonl feeds pipeline input to subprocess', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'human',
mode: "human",
render: { json() {}, lines() {} },
},
});

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -9,17 +9,17 @@ const __dirname = dirname(__filename);
const argv = process.argv.slice(2);
// Minimal mock for `gog gmail search` and `gog gmail send`.
if (argv[0] === 'gmail' && argv[1] === 'search') {
const data = readFileSync(join(__dirname, 'gog_gmail_search.json'), 'utf8');
if (argv[0] === "gmail" && argv[1] === "search") {
const data = readFileSync(join(__dirname, "gog_gmail_search.json"), "utf8");
process.stdout.write(data);
process.exit(0);
}
if (argv[0] === 'gmail' && argv[1] === 'send') {
if (argv[0] === "gmail" && argv[1] === "send") {
// Echo a json success object.
process.stdout.write(JSON.stringify({ ok: true }));
process.exit(0);
}
process.stderr.write('mock-gog: unsupported args: ' + argv.join(' ') + '\n');
process.stderr.write("mock-gog: unsupported args: " + argv.join(" ") + "\n");
process.exit(2);

262
test/for_each.test.ts Normal file
View File

@ -0,0 +1,262 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("for_each iterates items and collects per-iteration results", async () => {
const result = await runWorkflow({
steps: [
{
id: "data",
command: 'node -e "process.stdout.write(JSON.stringify([{name:\\"a\\"},{name:\\"b\\"}]))"',
},
{
id: "loop",
for_each: "$data.json",
steps: [
{
id: "transform",
command:
'node -e "process.stdout.write(JSON.stringify({upper: process.env.NAME.toUpperCase()}))"',
env: { NAME: "$item.json.name" },
},
],
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(output.length, 2);
assert.equal(output[0].index, 0);
assert.equal(output[1].index, 1);
assert.equal(output[0].transform.upper, "A");
assert.equal(output[1].transform.upper, "B");
});
test("for_each supports custom item_var and index_var", async () => {
const result = await runWorkflow({
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([10,20]))"' },
{
id: "loop",
for_each: "$vals.json",
item_var: "num",
index_var: "idx",
steps: [
{
id: "emit",
command:
'node -e "process.stdout.write(JSON.stringify({num:$num.json,idx:$idx.json}))"',
},
],
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [
{ num: 10, idx: 0, emit: { num: 10, idx: 0 } },
{ num: 20, idx: 1, emit: { num: 20, idx: 1 } },
]);
});
test("for_each throws when source is not an array", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "data", command: 'node -e "process.stdout.write(JSON.stringify({x:1}))"' },
{ id: "loop", for_each: "$data.json", steps: [{ id: "x", command: "echo hi" }] },
],
}),
/for_each: expected array/,
);
});
test("for_each validation rejects empty sub-step list", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "loop", for_each: "$x.json", steps: [] }],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/for_each requires a non-empty steps array/,
);
});
test("for_each validation rejects run/command/pipeline/workflow/parallel on loop step", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
run: "echo no",
steps: [{ id: "s", command: "echo hi" }],
},
],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/for_each cannot also define run, command, pipeline, workflow, or parallel/,
);
});
test("for_each validation rejects approval/input in sub-steps", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
steps: [{ id: "s", command: "echo hi", approval: true }],
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /cannot contain approval or input/);
});
test("for_each validation rejects duplicate sub-step ids", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
steps: [
{ id: "dup", command: "echo a" },
{ id: "dup", command: "echo b" },
],
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /duplicate for_each sub-step id/);
});
test("for_each validation rejects item_var/index_var collisions", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "loop",
for_each: "$x.json",
item_var: "x",
index_var: "x",
steps: [{ id: "s", command: "echo hi" }],
},
],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/item_var and index_var cannot be the same/,
);
});
test("for_each pause_ms and batch_size are accepted and executable", async () => {
const result = await runWorkflow({
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([1,2,3]))"' },
{
id: "loop",
for_each: "$vals.json",
batch_size: 2,
pause_ms: 10,
steps: [
{ id: "emit", command: 'node -e "process.stdout.write(JSON.stringify({v:$item.json}))"' },
],
},
],
});
assert.equal(result.status, "ok");
assert.equal((result.output as any[]).length, 3);
});
test("for_each dry-run renders loop structure", async () => {
const workflow = {
steps: [
{ id: "vals", command: 'node -e "process.stdout.write(JSON.stringify([1,2]))"' },
{
id: "loop",
for_each: "$vals.json",
batch_size: 2,
steps: [{ id: "emit", command: "echo hi" }],
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-foreach-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderr = new PassThrough();
let out = "";
stderr.on("data", (d: Buffer | string) => {
out += String(d);
});
await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
dryRun: true,
registry: createDefaultRegistry(),
},
});
assert.match(out, /\[for_each\]/);
assert.match(out, /sub-steps: 1/);
assert.match(out, /batch_size: 2/);
});

View File

@ -1,21 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPrChangeSummary } from '../src/workflows/github_pr_monitor.js';
import test from "node:test";
import assert from "node:assert/strict";
import { buildPrChangeSummary } from "../src/workflows/github_pr_monitor.js";
function formatLikeWorkflow({ repo, pr, after, before }) {
const summary = buildPrChangeSummary(before, after);
const fields = summary.changedFields.length ? ` (${summary.changedFields.join(', ')})` : '';
const title = after?.title ? `: ${after.title}` : '';
const url = after?.url ? ` ${after.url}` : '';
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, ' ').trim();
const fields = summary.changedFields.length ? ` (${summary.changedFields.join(", ")})` : "";
const title = after?.title ? `: ${after.title}` : "";
const url = after?.url ? ` ${after.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
}
test('notify message includes repo/pr and changed fields', () => {
const before = { number: 1, title: 'A', url: 'u', state: 'OPEN', updatedAt: 't1' };
const after = { ...before, title: 'B', updatedAt: 't2' };
test("notify message includes repo/pr and changed fields", () => {
const before = { number: 1, title: "A", url: "u", state: "OPEN", updatedAt: "t1" };
const after = { ...before, title: "B", updatedAt: "t2" };
const msg = formatLikeWorkflow({ repo: 'o/r', pr: 1, before, after });
assert.ok(msg.includes('o/r#1'));
assert.ok(msg.includes('title'));
assert.ok(msg.includes('updatedAt'));
const msg = formatLikeWorkflow({ repo: "o/r", pr: 1, before, after });
assert.ok(msg.includes("o/r#1"));
assert.ok(msg.includes("title"));
assert.ok(msg.includes("updatedAt"));
});

View File

@ -1,45 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPrChangeSummary } from '../src/workflows/github_pr_monitor.js';
import test from "node:test";
import assert from "node:assert/strict";
import { buildPrChangeSummary } from "../src/workflows/github_pr_monitor.js";
const build = buildPrChangeSummary as any;
test('buildPrChangeSummary reports all fields on first snapshot', () => {
test("buildPrChangeSummary reports all fields on first snapshot", () => {
const after = {
number: 1,
title: 'A',
url: 'u',
state: 'OPEN',
title: "A",
url: "u",
state: "OPEN",
isDraft: false,
mergeable: 'MERGEABLE',
reviewDecision: 'REVIEW_REQUIRED',
updatedAt: 't1',
baseRefName: 'main',
headRefName: 'feat',
mergeable: "MERGEABLE",
reviewDecision: "REVIEW_REQUIRED",
updatedAt: "t1",
baseRefName: "main",
headRefName: "feat",
};
const res = build(null, after);
assert.ok(res.changedFields.length > 0);
assert.equal(res.changes.title.to, 'A');
assert.equal(res.changes.title.to, "A");
});
test('buildPrChangeSummary only includes changed fields', () => {
test("buildPrChangeSummary only includes changed fields", () => {
const before = {
number: 1,
title: 'A',
url: 'u',
state: 'OPEN',
title: "A",
url: "u",
state: "OPEN",
isDraft: false,
mergeable: 'MERGEABLE',
mergeable: "MERGEABLE",
reviewDecision: null,
updatedAt: 't1',
baseRefName: 'main',
headRefName: 'feat',
updatedAt: "t1",
baseRefName: "main",
headRefName: "feat",
};
const after = { ...before, title: 'B', updatedAt: 't2' };
const after = { ...before, title: "B", updatedAt: "t2" };
const res = build(before, after);
assert.deepEqual(res.changedFields.sort(), ['title', 'updatedAt'].sort());
assert.equal(res.changes.title.from, 'A');
assert.equal(res.changes.title.to, 'B');
assert.deepEqual(res.changedFields.sort(), ["title", "updatedAt"].sort());
assert.equal(res.changes.title.from, "A");
assert.equal(res.changes.title.to, "B");
});

View File

@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,33 +15,37 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test('groupBy groups items by key and preserves group order', async () => {
test("groupBy groups items by key and preserves group order", async () => {
const input = [
{ from: 'a', id: 1 },
{ from: 'b', id: 2 },
{ from: 'a', id: 3 },
{ from: "a", id: 1 },
{ from: "b", id: 2 },
{ from: "a", id: 3 },
];
const out = await run('groupBy --key from', input);
const out = await run("groupBy --key from", input);
assert.equal(out.length, 2);
assert.deepEqual(out[0].key, 'a');
assert.deepEqual(out[0].items.map((x: any) => x.id), [1, 3]);
assert.deepEqual(out[0].key, "a");
assert.deepEqual(
out[0].items.map((x: any) => x.id),
[1, 3],
);
assert.equal(out[0].count, 2);
assert.deepEqual(out[1].key, 'b');
assert.deepEqual(out[1].key, "b");
});
test('groupBy supports nested key paths', async () => {
const input = [
{ user: { id: 'u1' } },
{ user: { id: 'u2' } },
{ user: { id: 'u1' } },
];
const out = await run('groupBy --key user.id', input);
assert.deepEqual(out.map((g: any) => g.key), ['u1', 'u2']);
test("groupBy supports nested key paths", async () => {
const input = [{ user: { id: "u1" } }, { user: { id: "u2" } }, { user: { id: "u1" } }];
const out = await run("groupBy --key user.id", input);
assert.deepEqual(
out.map((g: any) => g.key),
["u1", "u2"],
);
assert.equal(out[0].count, 2);
});

View File

@ -1,11 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDefaultRegistry } from '../src/commands/registry.js';
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items: any[]) {
return (async function* () {
@ -19,36 +19,36 @@ async function collect(iterable: AsyncIterable<any>) {
return items;
}
test('llm.invoke auto-detects OpenClaw provider and normalizes output', async () => {
test("llm.invoke auto-detects OpenClaw provider and normalizes output", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('llm.invoke');
assert.ok(cmd, 'llm.invoke should be registered');
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cmd = registry.get("llm.invoke");
assert.ok(cmd, "llm.invoke should be registered");
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('nope');
res.end("nope");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
bodyLog.push(parsed);
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: 'invoke_1',
runId: "invoke_1",
model: parsed.args?.model,
prompt: parsed.args?.prompt,
output: { data: { summary: 'hello' } },
output: { data: { summary: "hello" } },
},
},
}),
@ -58,90 +58,93 @@ test('llm.invoke auto-detects OpenClaw provider and normalizes output', async ()
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: 'text', text: 'doc' }]),
input: streamOf([{ kind: "text", text: "doc" }]),
args: {
_: [],
model: 'claude-3-sonnet',
prompt: 'Summarize',
model: "claude-3-sonnet",
prompt: "Summarize",
},
ctx: baseCtx({ OPENCLAW_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir }, registry),
ctx: baseCtx(
{ OPENCLAW_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir },
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].kind, 'llm.invoke');
assert.equal(items[0].source, 'openclaw');
assert.equal(items[0].runId, 'invoke_1');
assert.equal(items[0].output.data.summary, 'hello');
assert.equal(items[0].kind, "llm.invoke");
assert.equal(items[0].source, "openclaw");
assert.equal(items[0].runId, "invoke_1");
assert.equal(items[0].output.data.summary, "hello");
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, 'llm-task');
assert.equal(bodyLog[0].args.prompt, 'Summarize');
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].args.prompt, "Summarize");
} finally {
await rm(cacheDir, { recursive: true, force: true });
await closeServer(server);
}
});
test('llm.invoke uses Pi adapter over local HTTP bridge', async () => {
test("llm.invoke uses Pi adapter over local HTTP bridge", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('llm.invoke');
const cmd = registry.get("llm.invoke");
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const requestLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/invoke') {
if (req.method !== "POST" || req.url !== "/invoke") {
res.writeHead(404);
res.end('nope');
res.end("nope");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
requestLog.push(parsed);
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
runId: 'pi_1',
runId: "pi_1",
model: parsed.model,
prompt: parsed.prompt,
output: {
format: 'json',
format: "json",
text: '{"decision":"reply"}',
data: { decision: 'reply' },
data: { decision: "reply" },
},
diagnostics: { adapter: 'pi' },
diagnostics: { adapter: "pi" },
},
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: 'text', text: 'draft this' }]),
input: streamOf([{ kind: "text", text: "draft this" }]),
args: {
_: [],
provider: 'pi',
prompt: 'Decide',
'output-schema': '{"type":"object","required":["decision"]}',
provider: "pi",
prompt: "Decide",
"output-schema": '{"type":"object","required":["decision"]}',
},
ctx: baseCtx(
{
LOBSTER_PI_LLM_ADAPTER_URL: `http://127.0.0.1:${port}`,
LOBSTER_LLM_MODEL: 'anthropic/claude-sonnet-4-5',
LOBSTER_LLM_MODEL: "anthropic/claude-sonnet-4-5",
LOBSTER_CACHE_DIR: cacheDir,
},
registry,
@ -150,13 +153,13 @@ test('llm.invoke uses Pi adapter over local HTTP bridge', async () => {
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].kind, 'llm.invoke');
assert.equal(items[0].source, 'pi');
assert.equal(items[0].model, 'anthropic/claude-sonnet-4-5');
assert.equal(items[0].output.data.decision, 'reply');
assert.equal(items[0].kind, "llm.invoke");
assert.equal(items[0].source, "pi");
assert.equal(items[0].model, "anthropic/claude-sonnet-4-5");
assert.equal(items[0].output.data.decision, "reply");
assert.equal(requestLog.length, 1);
assert.equal(requestLog[0].prompt, 'Decide');
assert.equal(requestLog[0].model, 'anthropic/claude-sonnet-4-5');
assert.equal(requestLog[0].prompt, "Decide");
assert.equal(requestLog[0].model, "anthropic/claude-sonnet-4-5");
assert.equal(requestLog[0].artifacts.length, 1);
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -171,7 +174,7 @@ function baseCtx(envOverrides: Record<string, string>, registry?: any) {
stderr: process.stderr,
env: { ...process.env, ...envOverrides },
registry: registry ?? null,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
};
}

View File

@ -1,11 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDefaultRegistry } from '../src/commands/registry.js';
import { createDefaultRegistry } from "../src/commands/registry.js";
function streamOf(items: any[]) {
return (async function* () {
@ -19,38 +19,38 @@ async function collect(iterable: AsyncIterable<any>) {
return items;
}
test('llm_task.invoke posts to /tools/invoke (clawd) and normalizes result', async () => {
test("llm_task.invoke posts to /tools/invoke (clawd) and normalizes result", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('llm_task.invoke');
assert.ok(cmd, 'llm_task.invoke should be registered');
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd, "llm_task.invoke should be registered");
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('nope');
res.end("nope");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
bodyLog.push(parsed);
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: 'task_1',
runId: "task_1",
model: parsed.args?.model,
prompt: parsed.args?.prompt,
output: {
text: 'done',
data: { summary: 'hello world' },
text: "done",
data: { summary: "hello world" },
},
usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 },
},
@ -62,36 +62,39 @@ test('llm_task.invoke posts to /tools/invoke (clawd) and normalizes result', asy
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: 'text', text: 'doc' }]),
input: streamOf([{ kind: "text", text: "doc" }]),
args: {
_: [],
token: 'test-token',
model: 'claude-3-sonnet',
prompt: 'Summarize',
token: "test-token",
model: "claude-3-sonnet",
prompt: "Summarize",
},
ctx: baseCtx({ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` }, registry),
ctx: baseCtx(
{ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` },
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
const payload = items[0];
assert.equal(payload.kind, 'llm_task.invoke');
assert.equal(payload.runId, 'task_1');
assert.equal(payload.output.data.summary, 'hello world');
assert.equal(payload.model, 'claude-3-sonnet');
assert.equal(payload.source, 'clawd');
assert.equal(payload.kind, "llm_task.invoke");
assert.equal(payload.runId, "task_1");
assert.equal(payload.output.data.summary, "hello world");
assert.equal(payload.model, "claude-3-sonnet");
assert.equal(payload.source, "clawd");
assert.equal(payload.cached, false);
assert.ok(payload.cacheKey);
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, 'llm-task');
assert.equal(bodyLog[0].action, 'invoke');
assert.equal(bodyLog[0].args.prompt, 'Summarize');
assert.equal(bodyLog[0].args.model, 'claude-3-sonnet');
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].action, "invoke");
assert.equal(bodyLog[0].args.prompt, "Summarize");
assert.equal(bodyLog[0].args.model, "claude-3-sonnet");
assert.equal(bodyLog[0].args.artifacts.length, 1);
assert.equal(bodyLog[0].args.artifactHashes.length, 1);
} finally {
@ -100,15 +103,15 @@ test('llm_task.invoke posts to /tools/invoke (clawd) and normalizes result', asy
}
});
test('llm_task.invoke retries when schema validation fails', async () => {
test("llm_task.invoke retries when schema validation fails", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('llm_task.invoke');
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
let calls = 0;
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end();
return;
@ -121,35 +124,38 @@ test('llm_task.invoke retries when schema validation fails', async () => {
ok: true,
result: {
runId: `attempt_${calls}`,
output: valid ? { data: { decision: 'send' } } : { data: { foo: 'bar' } },
output: valid ? { data: { decision: "send" } } : { data: { foo: "bar" } },
},
},
};
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(payload));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([]),
args: {
_: [],
model: 'claude-3-opus',
prompt: 'Decide',
'output-schema': '{"type":"object","required":["decision"]}',
'max-validation-retries': 2,
model: "claude-3-opus",
prompt: "Decide",
"output-schema": '{"type":"object","required":["decision"]}',
"max-validation-retries": 2,
},
ctx: baseCtx({ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` }, registry),
ctx: baseCtx(
{ LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` },
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].runId, 'attempt_2');
assert.equal(items[0].output.data.decision, 'send');
assert.equal(items[0].runId, "attempt_2");
assert.equal(items[0].output.data.decision, "send");
assert.equal(calls, 2);
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -157,65 +163,68 @@ test('llm_task.invoke retries when schema validation fails', async () => {
}
});
test('llm_task.invoke persists to run state so resume skips remote call', async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), 'lobster-state-'));
test("llm_task.invoke persists to run state so resume skips remote call", async () => {
const stateDir = await mkdtemp(path.join(tmpdir(), "lobster-state-"));
const registry = createDefaultRegistry();
const cmd = registry.get('llm_task.invoke');
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd);
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
void buf;
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({ ok: true, result: { ok: true, result: { runId: 'state_run', output: { data: { ok: true } } } } }),
JSON.stringify({
ok: true,
result: { ok: true, result: { runId: "state_run", output: { data: { ok: true } } } },
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const ctxEnv = { LOBSTER_STATE_DIR: stateDir, LOBSTER_CACHE_DIR: cacheDir };
try {
const first = await cmd.run({
input: streamOf([{ foo: 'bar' }]),
input: streamOf([{ foo: "bar" }]),
args: {
_: [],
model: 'claude',
prompt: 'Do thing',
'state-key': 'run123',
model: "claude",
prompt: "Do thing",
"state-key": "run123",
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const firstItems = await collect(first.output!);
assert.equal(firstItems[0].source, 'clawd');
assert.equal(firstItems[0].source, "clawd");
await closeServer(server);
const second = await cmd.run({
input: streamOf([{ foo: 'bar' }]),
input: streamOf([{ foo: "bar" }]),
args: {
_: [],
model: 'claude',
prompt: 'Do thing',
'state-key': 'run123',
model: "claude",
prompt: "Do thing",
"state-key": "run123",
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const secondItems = await collect(second.output!);
assert.equal(secondItems.length, 1);
assert.equal(secondItems[0].source, 'run_state');
assert.equal(secondItems[0].source, "run_state");
} finally {
await rm(stateDir, { recursive: true, force: true });
await rm(cacheDir, { recursive: true, force: true });
@ -223,32 +232,35 @@ test('llm_task.invoke persists to run state so resume skips remote call', async
}
});
test('llm_task.invoke reuses file cache when URL unavailable', async () => {
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
test("llm_task.invoke reuses file cache when URL unavailable", async () => {
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const registry = createDefaultRegistry();
const cmd = registry.get('llm_task.invoke');
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd);
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
void buf;
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({ ok: true, result: { ok: true, result: { runId: 'cache_run', output: { text: 'cached' } } } }),
JSON.stringify({
ok: true,
result: { ok: true, result: { runId: "cache_run", output: { text: "cached" } } },
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
const ctxEnv = { LOBSTER_CACHE_DIR: cacheDir, CLAWD_URL: `http://localhost:${port}` };
@ -257,13 +269,13 @@ test('llm_task.invoke reuses file cache when URL unavailable', async () => {
input: streamOf([]),
args: {
_: [],
model: 'claude',
prompt: 'Cache me',
model: "claude",
prompt: "Cache me",
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const firstItems = await collect(first.output!);
assert.equal(firstItems[0].source, 'clawd');
assert.equal(firstItems[0].source, "clawd");
await closeServer(server);
@ -271,14 +283,14 @@ test('llm_task.invoke reuses file cache when URL unavailable', async () => {
input: streamOf([]),
args: {
_: [],
model: 'claude',
prompt: 'Cache me',
model: "claude",
prompt: "Cache me",
},
ctx: baseCtx({ ...ctxEnv, CLAWD_URL: `http://localhost:${port}` }, registry),
} as any);
const secondItems = await collect(second.output!);
assert.equal(secondItems.length, 1);
assert.equal(secondItems[0].source, 'cache');
assert.equal(secondItems[0].source, "cache");
assert.equal(secondItems[0].cached, true);
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -286,38 +298,38 @@ test('llm_task.invoke reuses file cache when URL unavailable', async () => {
}
});
test('llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--model', async () => {
test("llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--model", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('llm_task.invoke');
const cmd = registry.get("llm_task.invoke");
assert.ok(cmd);
const cacheDir = await mkdtemp(path.join(tmpdir(), 'lobster-cache-'));
const cacheDir = await mkdtemp(path.join(tmpdir(), "lobster-cache-"));
const bodyLog: any[] = [];
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/tools/invoke') {
if (req.method !== "POST" || req.url !== "/tools/invoke") {
res.writeHead(404);
res.end('not found');
res.end("not found");
return;
}
let buf = '';
req.setEncoding('utf8');
req.on('data', (d) => (buf += d));
req.on('end', () => {
const parsed = JSON.parse(buf || '{}');
let buf = "";
req.setEncoding("utf8");
req.on("data", (d) => (buf += d));
req.on("end", () => {
const parsed = JSON.parse(buf || "{}");
bodyLog.push(parsed);
// This is the OpenClaw tool router envelope.
res.writeHead(200, { 'content-type': 'application/json' });
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
ok: true,
result: {
ok: true,
result: {
runId: 'task_clawd_1',
output: { data: { hello: 'world' } },
runId: "task_clawd_1",
output: { data: { hello: "world" } },
},
},
}),
@ -327,31 +339,34 @@ test('llm_task.invoke uses CLAWD_URL (/tools/invoke) without requiring --url/--m
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
const port = typeof addr === "object" && addr ? addr.port : 0;
try {
const result = await cmd.run({
input: streamOf([{ kind: 'text', text: 'doc' }]),
input: streamOf([{ kind: "text", text: "doc" }]),
args: {
_: [],
// no url, no model
prompt: 'Summarize',
prompt: "Summarize",
refresh: true,
},
ctx: baseCtx({ CLAWD_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir }, registry),
ctx: baseCtx(
{ CLAWD_URL: `http://localhost:${port}`, LOBSTER_CACHE_DIR: cacheDir },
registry,
),
} as any);
const items = await collect(result.output!);
assert.equal(items.length, 1);
assert.equal(items[0].source, 'clawd');
assert.equal(items[0].source, "clawd");
assert.equal(items[0].cached, false);
assert.equal(items[0].runId, 'task_clawd_1');
assert.equal(items[0].output.data.hello, 'world');
assert.equal(items[0].runId, "task_clawd_1");
assert.equal(items[0].output.data.hello, "world");
assert.equal(bodyLog.length, 1);
assert.equal(bodyLog[0].tool, 'llm-task');
assert.equal(bodyLog[0].action, 'invoke');
assert.equal(bodyLog[0].args.prompt, 'Summarize');
assert.equal(bodyLog[0].tool, "llm-task");
assert.equal(bodyLog[0].action, "invoke");
assert.equal(bodyLog[0].args.prompt, "Summarize");
assert.ok(Array.isArray(bodyLog[0].args.artifactHashes));
} finally {
await rm(cacheDir, { recursive: true, force: true });
@ -366,7 +381,7 @@ function baseCtx(envOverrides: Record<string, string>, registry?) {
stderr: process.stderr,
env: { ...process.env, ...envOverrides },
registry: registry ?? null,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
};
}

View File

@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,29 +15,31 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test('map --wrap wraps items', async () => {
const out = await run('map --wrap item', [1, 2]);
test("map --wrap wraps items", async () => {
const out = await run("map --wrap item", [1, 2]);
assert.deepEqual(out, [{ item: 1 }, { item: 2 }]);
});
test('map --unwrap unwraps fields', async () => {
const out = await run('map --unwrap x', [{ x: 1 }, { x: 2 }]);
test("map --unwrap unwraps fields", async () => {
const out = await run("map --unwrap x", [{ x: 1 }, { x: 2 }]);
assert.deepEqual(out, [1, 2]);
});
test('map adds fields via assignments with template values', async () => {
const out = await run('map kind=pr id={{id}}', [{ id: 123, title: 't' }]);
test("map adds fields via assignments with template values", async () => {
const out = await run("map kind=pr id={{id}}", [{ id: 123, title: "t" }]);
// assignment overwrites existing id with rendered string
assert.deepEqual(out, [{ id: '123', title: 't', kind: 'pr' }]);
assert.deepEqual(out, [{ id: "123", title: "t", kind: "pr" }]);
});
test('map converts non-object items to {value: item} when adding fields', async () => {
const out = await run('map kind=num', [5]);
assert.deepEqual(out, [{ value: 5, kind: 'num' }]);
test("map converts non-object items to {value: item} when adding fields", async () => {
const out = await run("map kind=num", [5]);
assert.deepEqual(out, [{ value: 5, kind: "num" }]);
});

View File

@ -1,14 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import os from 'node:os';
import { promises as fsp } from 'node:fs';
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
import os from "node:os";
import { promises as fsp } from "node:fs";
function runTool(pipeline, env) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'run', '--mode', 'tool', pipeline], {
encoding: 'utf8',
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync("node", [bin, "run", "--mode", "tool", pipeline], {
encoding: "utf8",
env: { ...process.env, ...env },
});
assert.equal(res.status, 0);
@ -16,40 +16,44 @@ function runTool(pipeline, env) {
}
function resume(token, approve, env) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'resume', '--token', token, '--approve', approve ? 'yes' : 'no'], {
encoding: 'utf8',
env: { ...process.env, ...env },
});
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync(
"node",
[bin, "resume", "--token", token, "--approve", approve ? "yes" : "no"],
{
encoding: "utf8",
env: { ...process.env, ...env },
},
);
assert.equal(res.status, 0);
return JSON.parse(res.stdout);
}
test('two approve gates can be resumed sequentially', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-multi-approval-'));
const stateDir = path.join(tmpDir, 'state');
test("two approve gates can be resumed sequentially", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-multi-approval-"));
const stateDir = path.join(tmpDir, "state");
const env = { LOBSTER_STATE_DIR: stateDir };
const pipeline = [
"exec --json --shell \"printf '%s' '[{\\\"x\\\":1}]'\"",
"approve --prompt 'first?'",
"approve --prompt 'second?'",
'pick x',
].join(' | ');
"pick x",
].join(" | ");
const first = runTool(pipeline, env);
assert.equal(first.status, 'needs_approval');
assert.equal(first.requiresApproval.prompt, 'first?');
assert.equal(first.status, "needs_approval");
assert.equal(first.requiresApproval.prompt, "first?");
const second = resume(first.requiresApproval.resumeToken, true, env);
assert.equal(second.status, 'needs_approval');
assert.equal(second.requiresApproval.prompt, 'second?');
assert.equal(second.status, "needs_approval");
assert.equal(second.requiresApproval.prompt, "second?");
const done = resume(second.requiresApproval.resumeToken, true, env);
assert.equal(done.status, 'ok');
assert.equal(done.status, "ok");
assert.deepEqual(done.output, [{ x: 1 }]);
const files = await fsp.readdir(stateDir);
const pipelineResumeFiles = files.filter((name) => name.startsWith('pipeline_resume_'));
const pipelineResumeFiles = files.filter((name) => name.startsWith("pipeline_resume_"));
assert.deepEqual(pipelineResumeFiles, []);
});

194
test/on_error.test.ts Normal file
View File

@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("on_error defaults to stop and propagates errors", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"' },
{ id: "after", command: "echo should-not-run" },
],
}),
/workflow command failed/,
);
});
test("on_error: stop explicit also propagates errors", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "stop" },
{ id: "after", command: "echo should-not-run" },
],
}),
/workflow command failed/,
);
});
test("on_error: continue records error and proceeds", async () => {
const result = await runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "after", command: 'echo "ran"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["ran\n"]);
});
test("on_error: continue exposes error marker to later steps", async () => {
const result = await runWorkflow({
steps: [
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "continue" },
{
id: "check",
command: 'node -e "process.stdout.write(JSON.stringify({saw: process.env.SAW}))"',
env: { SAW: "$fail.error" },
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ saw: "true" }]);
});
test("on_error: skip_rest stops remaining steps and keeps prior output", async () => {
const result = await runWorkflow({
steps: [
{ id: "good", command: 'node -e "process.stdout.write(JSON.stringify({kept:true}))"' },
{ id: "fail", command: 'node -e "process.exit(1)"', on_error: "skip_rest" },
{ id: "after", command: "echo should-not-run" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ kept: true }]);
});
test("on_error: continue supports condition branching on error state", async () => {
const result = await runWorkflow({
steps: [
{ id: "risky", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "success_path", command: "echo success", when: "$risky.error != true" },
{ id: "failure_path", command: "echo failure", when: "$risky.error == true" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, ["failure\n"]);
});
test("on_error validation rejects invalid values", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "x", command: "echo hi", on_error: "invalid" }],
}),
"utf8",
);
await assert.rejects(
() => loadWorkflowFile(filePath),
/on_error must be "stop", "continue", or "skip_rest"/,
);
});
test("multiple continue failures preserve both error markers", async () => {
const result = await runWorkflow({
steps: [
{ id: "a", command: 'node -e "process.exit(1)"', on_error: "continue" },
{ id: "b", command: 'node -e "process.exit(1)"', on_error: "continue" },
{
id: "report",
command:
'node -e "process.stdout.write(JSON.stringify({a:process.env.A,b:process.env.B}))"',
env: { A: "$a.error", B: "$b.error" },
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ a: "true", b: "true" }]);
});
test("on_error: continue preserves output when final step fails", async () => {
const result = await runWorkflow({
steps: [
{ id: "good", command: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"' },
{ id: "fail_last", command: 'node -e "process.exit(1)"', on_error: "continue" },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ ok: true }]);
});
test("pipeline approval halts bypass on_error handling", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{ id: "gate", pipeline: "approve --prompt 'Proceed?'", on_error: "continue" },
{ id: "after", command: "echo should-not-run" },
],
}),
/halted for approval inside pipeline/,
);
});
test("external abort propagates even with on_error: continue", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-onerror-abort-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{ id: "slow", command: 'node -e "setTimeout(() => {}, 5000)"', on_error: "continue" },
],
}),
"utf8",
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
() =>
runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
signal: controller.signal,
},
}),
(err: any) => err?.name === "AbortError" || err?.code === "ABORT_ERR",
);
});

View File

@ -1,13 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDefaultRegistry } from '../src/commands/registry.js';
import test from "node:test";
import assert from "node:assert/strict";
import { createDefaultRegistry } from "../src/commands/registry.js";
test('clawd.invoke remains available as an alias', async () => {
test("clawd.invoke remains available as an alias", async () => {
const registry = createDefaultRegistry();
const a = registry.get('openclaw.invoke');
const b = registry.get('clawd.invoke');
assert.ok(a, 'expected openclaw.invoke to exist');
assert.ok(b, 'expected clawd.invoke to exist');
assert.equal(typeof a.run, 'function');
assert.equal(typeof b.run, 'function');
const a = registry.get("openclaw.invoke");
const b = registry.get("clawd.invoke");
assert.ok(a, "expected openclaw.invoke to exist");
assert.ok(b, "expected clawd.invoke to exist");
assert.equal(typeof a.run, "function");
assert.equal(typeof b.run, "function");
});

203
test/parallel.test.ts Normal file
View File

@ -0,0 +1,203 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("parallel wait=all runs all branches and merges output", async () => {
const result = await runWorkflow({
steps: [
{
id: "fetch",
parallel: {
wait: "all",
branches: [
{ id: "a", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"a\\"}))"' },
{ id: "b", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"b\\"}))"' },
{ id: "c", command: 'node -e "process.stdout.write(JSON.stringify({src:\\"c\\"}))"' },
],
},
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(output[0].a.src, "a");
assert.equal(output[0].b.src, "b");
assert.equal(output[0].c.src, "c");
});
test("parallel wait=any returns first branch result", async () => {
const result = await runWorkflow({
steps: [
{
id: "race",
parallel: {
wait: "any",
branches: [
{
id: "fast",
command: 'node -e "process.stdout.write(JSON.stringify({winner:true}))"',
},
{
id: "slow",
command:
'node -e "setTimeout(() => process.stdout.write(JSON.stringify({winner:false})), 5000)"',
},
],
},
},
],
});
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(Object.keys(output[0]).length, 1);
assert.equal(output[0].fast.winner, true);
});
test("parallel branch results are available to later steps by branch id", async () => {
const result = await runWorkflow({
steps: [
{
id: "fetch",
parallel: {
wait: "all",
branches: [
{ id: "x", command: 'node -e "process.stdout.write(JSON.stringify({val:10}))"' },
{ id: "y", command: 'node -e "process.stdout.write(JSON.stringify({val:20}))"' },
],
},
},
{
id: "use",
command: 'node -e "process.stdout.write(JSON.stringify({x:$x.json.val,y:$y.json.val}))"',
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ x: 10, y: 20 }]);
});
test("parallel wait=all propagates branch failure", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "p",
parallel: {
wait: "all",
branches: [
{ id: "ok", command: "echo ok" },
{ id: "fail", command: 'node -e "process.exit(1)"' },
],
},
},
],
}),
/Parallel branch failed/,
);
});
test("parallel validation rejects empty branches", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [{ id: "p", parallel: { branches: [] } }],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /non-empty branches/);
});
test("parallel validation rejects duplicate branch ids", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "p",
parallel: {
branches: [
{ id: "dup", command: "echo a" },
{ id: "dup", command: "echo b" },
],
},
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /duplicate parallel branch id/);
});
test("parallel validation rejects branch without execution", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-parallel-"));
const filePath = path.join(tmpDir, "bad.lobster");
await fsp.writeFile(
filePath,
JSON.stringify({
steps: [
{
id: "p",
parallel: {
branches: [{ id: "empty" }],
},
},
],
}),
"utf8",
);
await assert.rejects(() => loadWorkflowFile(filePath), /requires run, command, or pipeline/);
});
test("parallel timeout aborts block", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "p",
parallel: {
wait: "all",
timeout_ms: 100,
branches: [
{
id: "slow",
command: "node -e \"setTimeout(() => process.stdout.write('ok'), 5000)\"",
},
],
},
},
],
}),
/Parallel step p timed out after 100ms/,
);
});

View File

@ -1,43 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parsePipeline } from '../src/parser.js';
import test from "node:test";
import assert from "node:assert/strict";
import { parsePipeline } from "../src/parser.js";
test('parsePipeline splits stages and args', () => {
test("parsePipeline splits stages and args", () => {
const p = parsePipeline("exec echo hi | where a=1 | pick id,subject");
assert.equal(p.length, 3);
assert.equal(p[0].name, 'exec');
assert.deepEqual(p[0].args._, ['echo', 'hi']);
assert.equal(p[1].name, 'where');
assert.equal(p[1].args._[0], 'a=1');
assert.equal(p[2].name, 'pick');
assert.equal(p[2].args._[0], 'id,subject');
assert.equal(p[0].name, "exec");
assert.deepEqual(p[0].args._, ["echo", "hi"]);
assert.equal(p[1].name, "where");
assert.equal(p[1].args._[0], "a=1");
assert.equal(p[2].name, "pick");
assert.equal(p[2].args._[0], "id,subject");
});
test('parsePipeline keeps quoted pipes', () => {
test("parsePipeline keeps quoted pipes", () => {
const p = parsePipeline("exec echo 'a|b' | json");
assert.equal(p.length, 2);
assert.deepEqual(p[0].args._, ['echo', 'a|b']);
assert.deepEqual(p[0].args._, ["echo", "a|b"]);
});
test('parsePipeline preserves JSON escapes in double-quoted args', () => {
const p = parsePipeline('openclaw.invoke --tool llm-task --action json --args-json "{\\"prompt\\":\\"line1\\\\nline2\\",\\"schema\\":{\\"type\\":\\"object\\"}}"');
test("parsePipeline preserves JSON escapes in double-quoted args", () => {
const p = parsePipeline(
'openclaw.invoke --tool llm-task --action json --args-json "{\\"prompt\\":\\"line1\\\\nline2\\",\\"schema\\":{\\"type\\":\\"object\\"}}"',
);
assert.equal(p.length, 1);
const raw = p[0].args['args-json'];
const raw = p[0].args["args-json"];
const parsed = JSON.parse(raw);
assert.equal(parsed.prompt, 'line1\nline2');
assert.equal(parsed.schema.type, 'object');
assert.equal(parsed.prompt, "line1\nline2");
assert.equal(parsed.schema.type, "object");
});
test('parsePipeline keeps single-quoted args literal', () => {
const p = parsePipeline("openclaw.invoke --args-json '{\"x\":\"a\\\\nb\"}'");
test("parsePipeline keeps single-quoted args literal", () => {
const p = parsePipeline('openclaw.invoke --args-json \'{"x":"a\\\\nb"}\'');
assert.equal(p.length, 1);
assert.equal(p[0].args['args-json'], '{"x":"a\\\\nb"}');
assert.equal(p[0].args["args-json"], '{"x":"a\\\\nb"}');
});
test('parsePipeline preserves escaped apostrophes in single-quoted args', () => {
const p = parsePipeline("openclaw.invoke --args-json '{\"prompt\":\"don\\'t\"}'");
test("parsePipeline preserves escaped apostrophes in single-quoted args", () => {
const p = parsePipeline('openclaw.invoke --args-json \'{"prompt":"don\\\'t"}\'');
assert.equal(p.length, 1);
const raw = p[0].args['args-json'];
const raw = p[0].args["args-json"];
const parsed = JSON.parse(raw);
assert.equal(parsed.prompt, "don't");
});

View File

@ -1,30 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PassThrough } from 'node:stream';
import test from "node:test";
import assert from "node:assert/strict";
import { PassThrough } from "node:stream";
import { readLineFromStream } from '../src/read_line.js';
import { readLineFromStream } from "../src/read_line.js";
test('readLineFromStream resolves on newline', async () => {
test("readLineFromStream resolves on newline", async () => {
const input = new PassThrough();
const promise = readLineFromStream(input);
input.write('yes\n');
input.write("yes\n");
input.end();
const value = await promise;
assert.equal(value, 'yes');
assert.equal(value, "yes");
});
test('readLineFromStream resolves on end without newline', async () => {
test("readLineFromStream resolves on end without newline", async () => {
const input = new PassThrough();
const promise = readLineFromStream(input);
input.write('partial');
input.write("partial");
input.end();
const value = await promise;
assert.equal(value, 'partial');
assert.equal(value, "partial");
});
test('readLineFromStream times out when no input arrives', async () => {
test("readLineFromStream times out when no input arrives", async () => {
const input = new PassThrough();
await assert.rejects(() => readLineFromStream(input, { timeoutMs: 5 }), /Timed out waiting for input/);
await assert.rejects(
() => readLineFromStream(input, { timeoutMs: 5 }),
/Timed out waiting for input/,
);
});

View File

@ -1,105 +1,110 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { decodeResumeToken } from '../src/resume.js';
import { encodeToken } from '../src/token.js';
import { decodeResumeToken } from "../src/resume.js";
import { encodeToken } from "../src/token.js";
function runCli(args: string[], env: Record<string, string | undefined>) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
return spawnSync('node', [bin, ...args], {
encoding: 'utf8',
const bin = path.join(process.cwd(), "bin", "lobster.js");
return spawnSync("node", [bin, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
});
}
test('state-backed resume token roundtrip and resume pipeline continues', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-resume-'));
const stateDir = path.join(tmpDir, 'state');
test("state-backed resume token roundtrip and resume pipeline continues", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-resume-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'ok?' | pick a";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const firstJson = JSON.parse(first.stdout);
assert.equal(firstJson.status, 'needs_approval');
assert.equal(firstJson.status, "needs_approval");
assert.ok(firstJson.requiresApproval?.resumeToken);
const payload = decodeResumeToken(firstJson.requiresApproval.resumeToken);
assert.equal(payload.kind, 'pipeline-resume');
assert.equal(typeof payload.stateKey, 'string');
assert.equal(payload.kind, "pipeline-resume");
assert.equal(typeof payload.stateKey, "string");
const resumed = runCli(
['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'yes'],
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "yes"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ a: 1 }]);
});
test('decodeResumeToken rejects inline executable pipeline tokens', () => {
test("decodeResumeToken rejects inline executable pipeline tokens", () => {
const forgedToken = encodeToken({
protocolVersion: 1,
v: 1,
pipeline: [{ name: 'exec', args: { shell: 'echo FORGED' }, raw: "exec --shell 'echo FORGED'" }],
pipeline: [{ name: "exec", args: { shell: "echo FORGED" }, raw: "exec --shell 'echo FORGED'" }],
resumeAtIndex: 0,
items: [],
prompt: 'ignored',
prompt: "ignored",
});
assert.throws(() => decodeResumeToken(forgedToken), /Invalid token/);
});
test('resume cancellation cleans up pipeline resume state', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-resume-cancel-'));
const stateDir = path.join(tmpDir, 'state');
test("resume cancellation cleans up pipeline resume state", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-resume-cancel-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'ok?' | pick a";
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const firstJson = JSON.parse(first.stdout);
assert.equal(firstJson.status, 'needs_approval');
assert.equal(firstJson.status, "needs_approval");
const cancelled = runCli(
['resume', '--token', firstJson.requiresApproval.resumeToken, '--approve', 'no'],
["resume", "--token", firstJson.requiresApproval.resumeToken, "--approve", "no"],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(cancelled.status, 0);
const cancelledJson = JSON.parse(cancelled.stdout);
assert.equal(cancelledJson.status, 'cancelled');
assert.equal(cancelledJson.status, "cancelled");
const files = await fsp.readdir(stateDir);
const pipelineResumeFiles = files.filter((name) => name.startsWith('pipeline_resume_'));
const pipelineResumeFiles = files.filter((name) => name.startsWith("pipeline_resume_"));
assert.deepEqual(pipelineResumeFiles, []);
});
test('cli resume accepts --response-json for pipeline input requests', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-resume-input-'));
const stateDir = path.join(tmpDir, 'state');
test("cli resume accepts --response-json for pipeline input requests", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-resume-input-"));
const stateDir = path.join(tmpDir, "state");
const pipeline =
`ask --prompt 'Review?' --schema '{"type":"object","properties":{"decision":{"type":"string"}},"required":["decision"]}'`;
const pipeline = `ask --prompt 'Review?' --schema '{"type":"object","properties":{"decision":{"type":"string"}},"required":["decision"]}'`;
const first = runCli(['run', '--mode', 'tool', pipeline], { LOBSTER_STATE_DIR: stateDir });
const first = runCli(["run", "--mode", "tool", pipeline], { LOBSTER_STATE_DIR: stateDir });
assert.equal(first.status, 0);
const firstJson = JSON.parse(first.stdout);
assert.equal(firstJson.status, 'needs_input');
assert.equal(firstJson.status, "needs_input");
assert.ok(firstJson.requiresInput?.resumeToken);
const resumed = runCli(
['resume', '--token', firstJson.requiresInput.resumeToken, '--response-json', '{"decision":"approve"}'],
[
"resume",
"--token",
firstJson.requiresInput.resumeToken,
"--response-json",
'{"decision":"approve"}',
],
{ LOBSTER_STATE_DIR: stateDir },
);
assert.equal(resumed.status, 0);
const resumedJson = JSON.parse(resumed.stdout);
assert.equal(resumedJson.status, 'ok');
assert.deepEqual(resumedJson.output, [{ decision: 'approve' }]);
assert.equal(resumedJson.status, "ok");
assert.deepEqual(resumedJson.output, [{ decision: "approve" }]);
});

View File

@ -1,23 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { Lobster } from '../src/sdk/Lobster.js';
import { Lobster } from "../src/sdk/Lobster.js";
test('sdk Lobster.resume accepts structured input responses', async () => {
test("sdk Lobster.resume accepts structured input responses", async () => {
const workflow = new Lobster().pipe({
async run() {
return {
halt: true,
output: (async function* () {
yield {
type: 'input_request',
prompt: 'Decision?',
type: "input_request",
prompt: "Decision?",
responseSchema: {
type: 'object',
properties: { decision: { type: 'string', enum: ['approve', 'reject'] } },
required: ['decision'],
type: "object",
properties: { decision: { type: "string", enum: ["approve", "reject"] } },
required: ["decision"],
},
subject: { text: 'draft v1' },
subject: { text: "draft v1" },
};
})(),
};
@ -26,32 +26,32 @@ test('sdk Lobster.resume accepts structured input responses', async () => {
const first = await workflow.run();
assert.equal(first.ok, true);
assert.equal(first.status, 'needs_input');
assert.equal(first.status, "needs_input");
assert.ok(first.requiresInput?.resumeToken);
const resumed = await workflow.resume(first.requiresInput!.resumeToken, {
response: { decision: 'approve' },
response: { decision: "approve" },
});
assert.equal(resumed.ok, true);
assert.equal(resumed.status, 'ok');
assert.deepEqual(resumed.output, [{ decision: 'approve' }]);
assert.equal(resumed.status, "ok");
assert.deepEqual(resumed.output, [{ decision: "approve" }]);
});
test('sdk Lobster.resume rejects invalid structured input responses', async () => {
test("sdk Lobster.resume rejects invalid structured input responses", async () => {
const workflow = new Lobster().pipe({
async run() {
return {
halt: true,
output: (async function* () {
yield {
type: 'input_request',
prompt: 'Decision?',
type: "input_request",
prompt: "Decision?",
responseSchema: {
type: 'object',
properties: { decision: { type: 'string', enum: ['approve', 'reject'] } },
required: ['decision'],
type: "object",
properties: { decision: { type: "string", enum: ["approve", "reject"] } },
required: ["decision"],
},
subject: { text: 'draft v1' },
subject: { text: "draft v1" },
};
})(),
};
@ -59,9 +59,9 @@ test('sdk Lobster.resume rejects invalid structured input responses', async () =
});
const first = await workflow.run();
assert.equal(first.status, 'needs_input');
assert.equal(first.status, "needs_input");
await assert.rejects(
() => workflow.resume(first.requiresInput!.resumeToken, { response: { decision: 'maybe' } }),
() => workflow.resume(first.requiresInput!.resumeToken, { response: { decision: "maybe" } }),
/does not match schema/i,
);
});

View File

@ -1,37 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { resolveInlineShellCommand } from '../src/shell.js';
import { resolveInlineShellCommand } from "../src/shell.js";
test('resolveInlineShellCommand uses POSIX shell by default', () => {
test("resolveInlineShellCommand uses POSIX shell by default", () => {
const resolved = resolveInlineShellCommand({
command: 'echo hello',
env: { SHELL: '/bin/zsh' },
platform: 'darwin',
command: "echo hello",
env: { SHELL: "/bin/zsh" },
platform: "darwin",
});
assert.equal(resolved.command, '/bin/sh');
assert.deepEqual(resolved.argv, ['-lc', 'echo hello']);
assert.equal(resolved.command, "/bin/sh");
assert.deepEqual(resolved.argv, ["-lc", "echo hello"]);
});
test('resolveInlineShellCommand uses cmd on Windows', () => {
test("resolveInlineShellCommand uses cmd on Windows", () => {
const resolved = resolveInlineShellCommand({
command: 'echo hello',
env: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
platform: 'win32',
command: "echo hello",
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" },
platform: "win32",
});
assert.equal(resolved.command, 'C:\\Windows\\System32\\cmd.exe');
assert.deepEqual(resolved.argv, ['/d', '/s', '/c', 'echo hello']);
assert.equal(resolved.command, "C:\\Windows\\System32\\cmd.exe");
assert.deepEqual(resolved.argv, ["/d", "/s", "/c", "echo hello"]);
});
test('resolveInlineShellCommand respects powershell override', () => {
test("resolveInlineShellCommand respects powershell override", () => {
const resolved = resolveInlineShellCommand({
command: 'Write-Host hello',
env: { LOBSTER_SHELL: 'pwsh' },
platform: 'linux',
command: "Write-Host hello",
env: { LOBSTER_SHELL: "pwsh" },
platform: "linux",
});
assert.equal(resolved.command, 'pwsh');
assert.deepEqual(resolved.argv, ['-NoProfile', '-Command', 'Write-Host hello']);
assert.equal(resolved.command, "pwsh");
assert.deepEqual(resolved.argv, ["-NoProfile", "-Command", "Write-Host hello"]);
});

View File

@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import test from "node:test";
import assert from "node:assert/strict";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -15,39 +15,47 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test('sort sorts primitives ascending by default', async () => {
const out = await run('sort', [3, 1, 2]);
test("sort sorts primitives ascending by default", async () => {
const out = await run("sort", [3, 1, 2]);
assert.deepEqual(out, [1, 2, 3]);
});
test('sort supports --desc', async () => {
const out = await run('sort --desc', ["b", "a", "c"]);
test("sort supports --desc", async () => {
const out = await run("sort --desc", ["b", "a", "c"]);
assert.deepEqual(out, ["c", "b", "a"]);
});
test('sort sorts objects by --key and is stable', async () => {
test("sort sorts objects by --key and is stable", async () => {
const input = [
{ id: 'a', k: 2 },
{ id: 'b', k: 1 },
{ id: 'c', k: 2 },
{ id: "a", k: 2 },
{ id: "b", k: 1 },
{ id: "c", k: 2 },
];
const out = await run('sort --key k', input);
assert.deepEqual(out.map((x: any) => x.id), ['b', 'a', 'c']);
const out = await run("sort --key k", input);
assert.deepEqual(
out.map((x: any) => x.id),
["b", "a", "c"],
);
});
test('sort places undefined/null last', async () => {
test("sort places undefined/null last", async () => {
const input = [
{ id: 'a', k: undefined },
{ id: 'b', k: 2 },
{ id: 'c', k: null },
{ id: 'd', k: 1 },
{ id: "a", k: undefined },
{ id: "b", k: 2 },
{ id: "c", k: null },
{ id: "d", k: 1 },
];
const out = await run('sort --key k', input);
assert.deepEqual(out.map((x: any) => x.id), ['d', 'b', 'a', 'c']);
const out = await run("sort --key k", input);
assert.deepEqual(
out.map((x: any) => x.id),
["d", "b", "a", "c"],
);
});

View File

@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import { mkdtempSync } from 'node:fs';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { runPipeline } from '../src/runtime.js';
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtempSync } from "node:fs";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runPipeline } from "../src/runtime.js";
function streamOf(items) {
return (async function* () {
@ -12,26 +12,42 @@ function streamOf(items) {
})();
}
test('state.set writes and state.get reads', async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-state-'));
test("state.set writes and state.get reads", async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
const registry = createDefaultRegistry();
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
// write
const setCmd = registry.get('state.set');
const setCmd = registry.get("state.set");
await setCmd.run({
input: streamOf([{ a: 1 }]),
args: { _: ['demo-key'] },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
args: { _: ["demo-key"] },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
});
// read
const getCmd = registry.get('state.get');
const getCmd = registry.get("state.get");
const res = await getCmd.run({
input: streamOf([]),
args: { _: ['demo-key'] },
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env, registry, mode: 'tool', render: { json() {}, lines() {} } },
args: { _: ["demo-key"] },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
});
const items = [];
@ -39,20 +55,20 @@ test('state.set writes and state.get reads', async () => {
assert.deepEqual(items, [{ a: 1 }]);
});
test('state.get returns null for missing key', async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), 'lobster-state-'));
test("state.get returns null for missing key", async () => {
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
const registry = createDefaultRegistry();
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
const output = await runPipeline({
pipeline: [{ name: 'state.get', args: { _: ['missing'] }, raw: 'state.get missing' }],
pipeline: [{ name: "state.get", args: { _: ["missing"] }, raw: "state.get missing" }],
registry,
input: [],
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: 'tool',
mode: "tool",
});
assert.deepEqual(output.items, [null]);

283
test/step_retry.test.ts Normal file
View File

@ -0,0 +1,283 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { PassThrough } from "node:stream";
import { runWorkflowFile, loadWorkflowFile } from "../src/workflows/file.js";
import { withRetry, resolveRetryConfig } from "../src/core/retry.js";
// --- Retry utility unit tests ---
test("resolveRetryConfig fills defaults", () => {
const config = resolveRetryConfig(undefined);
assert.equal(config.max, 1);
assert.equal(config.backoff, "fixed");
assert.equal(config.delay_ms, 1000);
assert.equal(config.max_delay_ms, 30000);
assert.equal(config.jitter, false);
});
test("resolveRetryConfig preserves provided values", () => {
const config = resolveRetryConfig({ max: 5, backoff: "exponential", jitter: true });
assert.equal(config.max, 5);
assert.equal(config.backoff, "exponential");
assert.equal(config.jitter, true);
assert.equal(config.delay_ms, 1000); // default
});
test("withRetry succeeds on first try", async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls++;
return "ok";
},
resolveRetryConfig({ max: 3 }),
);
assert.equal(result, "ok");
assert.equal(calls, 1);
});
test("withRetry retries and succeeds", async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls++;
if (calls < 3) throw new Error("transient");
return "ok";
},
resolveRetryConfig({ max: 3, delay_ms: 10 }),
);
assert.equal(result, "ok");
assert.equal(calls, 3);
});
test("withRetry throws after max exhausted", async () => {
let calls = 0;
await assert.rejects(
withRetry(
async () => {
calls++;
throw new Error("always fails");
},
resolveRetryConfig({ max: 2, delay_ms: 10 }),
),
/always fails/,
);
assert.equal(calls, 2);
});
test("withRetry never retries abort errors", async () => {
let calls = 0;
const abortErr = new DOMException("aborted", "AbortError");
await assert.rejects(
withRetry(
async () => {
calls++;
throw abortErr;
},
resolveRetryConfig({ max: 3, delay_ms: 10 }),
),
(err: any) => err.name === "AbortError",
);
assert.equal(calls, 1);
});
test("withRetry calls onRetry callback", async () => {
const retries: number[] = [];
let calls = 0;
await withRetry(
async () => {
calls++;
if (calls < 3) throw new Error("fail");
return "ok";
},
resolveRetryConfig({ max: 3, delay_ms: 10 }),
{ onRetry: (attempt) => retries.push(attempt) },
);
assert.deepEqual(retries, [1, 2]);
});
// --- Validation tests ---
async function loadWorkflow(workflow: any) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-retry-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow), "utf8");
return loadWorkflowFile(filePath);
}
test("retry validation rejects non-object", async () => {
await assert.rejects(
loadWorkflow({ name: "bad", steps: [{ id: "x", command: "echo", retry: "yes" }] }),
/retry must be an object/,
);
});
test("retry validation rejects non-integer max", async () => {
await assert.rejects(
loadWorkflow({ name: "bad", steps: [{ id: "x", command: "echo", retry: { max: 1.5 } }] }),
/retry.max must be a positive integer/,
);
});
test("retry validation rejects max < 1", async () => {
await assert.rejects(
loadWorkflow({ name: "bad", steps: [{ id: "x", command: "echo", retry: { max: 0 } }] }),
/retry.max must be a positive integer/,
);
});
test("retry validation rejects invalid backoff", async () => {
await assert.rejects(
loadWorkflow({
name: "bad",
steps: [{ id: "x", command: "echo", retry: { backoff: "linear" } }],
}),
/retry.backoff must be "fixed" or "exponential"/,
);
});
test("retry validation rejects negative delay_ms", async () => {
await assert.rejects(
loadWorkflow({ name: "bad", steps: [{ id: "x", command: "echo", retry: { delay_ms: -1 } }] }),
/retry.delay_ms must be a finite non-negative number/,
);
});
test("retry validation rejects non-boolean jitter", async () => {
await assert.rejects(
loadWorkflow({ name: "bad", steps: [{ id: "x", command: "echo", retry: { jitter: "yes" } }] }),
/retry.jitter must be a boolean/,
);
});
test("retry validation accepts valid config", async () => {
const wf = await loadWorkflow({
name: "ok",
steps: [
{
id: "x",
command: "echo",
retry: { max: 3, backoff: "exponential", delay_ms: 500, jitter: true },
},
],
});
assert.ok(wf.steps[0].retry);
});
// --- Integration tests ---
async function runWorkflow(workflow: any) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-retry-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderrChunks: string[] = [];
const stderr = new PassThrough();
stderr.on("data", (d: Buffer) => stderrChunks.push(d.toString()));
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
},
});
return { result, stderrOutput: stderrChunks.join("") };
}
test("step retries and succeeds after transient failure", async () => {
// Script that fails twice then succeeds on third run using a counter file
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-retry-"));
const counterFile = path.join(tmpDir, "counter");
await fsp.writeFile(counterFile, "0", "utf8");
const workflow = {
name: "retry-succeed",
steps: [
{
id: "flaky",
command: `node -e "const fs=require('fs');const c=Number(fs.readFileSync('${counterFile}','utf8'))+1;fs.writeFileSync('${counterFile}',String(c));if(c<3){process.exit(1);}process.stdout.write(JSON.stringify({attempt:c}))"`,
retry: { max: 3, delay_ms: 50 },
},
],
};
const { result, stderrOutput } = await runWorkflow(workflow);
assert.equal(result.status, "ok");
const output = result.output as any[];
assert.equal(output[0].attempt, 3);
assert.ok(stderrOutput.includes("[RETRY]"), "should log retry attempts");
});
test("step exhausts retries and throws", async () => {
const workflow = {
name: "retry-exhaust",
steps: [
{
id: "fail",
command: 'node -e "process.exit(1)"',
retry: { max: 2, delay_ms: 50 },
},
],
};
await assert.rejects(
runWorkflow(workflow).then((r) => r.result),
/workflow command failed/,
);
});
test("step without retry fails immediately (no retry)", async () => {
const workflow = {
name: "no-retry",
steps: [{ id: "fail", command: 'node -e "process.exit(1)"' }],
};
await assert.rejects(
runWorkflow(workflow).then((r) => r.result),
/workflow command failed/,
);
});
test("dry-run renders retry config", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-retry-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
const workflow = {
name: "dry-run-retry",
steps: [
{
id: "fetch",
command: "curl https://example.com",
retry: { max: 3, backoff: "exponential", delay_ms: 1000 },
},
],
};
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderrChunks: string[] = [];
const stderr = new PassThrough();
stderr.on("data", (d: Buffer) => stderrChunks.push(d.toString()));
await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
dryRun: true,
},
});
const output = stderrChunks.join("");
assert.ok(output.includes("retry:"), "should show retry config");
assert.ok(output.includes("3 attempts"), "should show max attempts");
assert.ok(output.includes("exponential"), "should show backoff type");
});

207
test/step_timeout.test.ts Normal file
View File

@ -0,0 +1,207 @@
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { loadWorkflowFile, runWorkflowFile } from "../src/workflows/file.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
async function runWorkflow(
workflow: unknown,
opts?: {
signal?: AbortSignal;
dryRun?: boolean;
},
) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-step-timeout-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const stderr = new PassThrough();
const chunks: string[] = [];
stderr.on("data", (chunk: Buffer | string) => chunks.push(String(chunk)));
const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
signal: opts?.signal,
dryRun: opts?.dryRun,
registry: createDefaultRegistry(),
},
});
return { result, stderrOutput: chunks.join("") };
}
async function writeWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-step-timeout-load-"));
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return filePath;
}
test("timeout_ms validation rejects non-numeric values", async () => {
const filePath = await writeWorkflow({
steps: [{ id: "x", command: "echo hi", timeout_ms: "fast" }],
});
await assert.rejects(
() => loadWorkflowFile(filePath),
/timeout_ms must be a positive integer between 1 and 2147483647/,
);
});
test("timeout_ms validation rejects 0", async () => {
const filePath = await writeWorkflow({
steps: [{ id: "x", command: "echo hi", timeout_ms: 0 }],
});
await assert.rejects(
() => loadWorkflowFile(filePath),
/timeout_ms must be a positive integer between 1 and 2147483647/,
);
});
test("timeout_ms validation rejects non-integer values", async () => {
const filePath = await writeWorkflow({
steps: [{ id: "x", command: "echo hi", timeout_ms: 1.5 }],
});
await assert.rejects(
() => loadWorkflowFile(filePath),
/timeout_ms must be a positive integer between 1 and 2147483647/,
);
});
test("timeout_ms validation rejects values above Node timer max", async () => {
const filePath = await writeWorkflow({
steps: [{ id: "x", command: "echo hi", timeout_ms: 2_147_483_648 }],
});
await assert.rejects(
() => loadWorkflowFile(filePath),
/timeout_ms must be a positive integer between 1 and 2147483647/,
);
});
test("on_error validation rejects unsupported values", async () => {
const filePath = await writeWorkflow({
steps: [{ id: "x", command: "echo hi", on_error: "retry" }],
});
await assert.rejects(
() => loadWorkflowFile(filePath),
/on_error must be "stop", "continue", or "skip_rest"/,
);
});
test("timed-out step fails by default (on_error: stop)", async () => {
await assert.rejects(
() =>
runWorkflow({
steps: [{ id: "slow", command: 'node -e "setTimeout(() => {}, 5000)"', timeout_ms: 100 }],
}),
/timed out after 100ms/,
);
});
test("timed-out step with on_error: continue records error and continues", async () => {
const { result } = await runWorkflow({
steps: [
{
id: "slow",
command: 'node -e "setTimeout(() => {}, 5000)"',
timeout_ms: 100,
on_error: "continue",
},
{ id: "after", command: 'node -e "process.stdout.write(JSON.stringify({ok:true}))"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ ok: true }]);
});
test("timed-out step with on_error: skip_rest stops remaining steps", async () => {
const { result } = await runWorkflow({
steps: [
{ id: "start", command: 'node -e "process.stdout.write(JSON.stringify({kept:true}))"' },
{
id: "slow",
command: 'node -e "setTimeout(() => {}, 5000)"',
timeout_ms: 100,
on_error: "skip_rest",
},
{ id: "after", command: 'node -e "process.stdout.write(JSON.stringify({shouldRun:false}))"' },
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ kept: true }]);
});
test("step error marker is available to conditions after on_error: continue", async () => {
const { result } = await runWorkflow({
steps: [
{
id: "slow",
command: 'node -e "setTimeout(() => {}, 5000)"',
timeout_ms: 100,
on_error: "continue",
},
{
id: "check",
command:
'node -e "process.stdout.write(JSON.stringify({timedOut: process.env.TIMED_OUT}))"',
env: {
TIMED_OUT: "$slow.error",
},
when: "$slow.error == true",
},
],
});
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ timedOut: "true" }]);
});
test("external abort still propagates when timeout is configured", async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(
() =>
runWorkflow(
{
steps: [
{
id: "slow",
command: 'node -e "setTimeout(() => {}, 5000)"',
timeout_ms: 5000,
on_error: "continue",
},
],
},
{ signal: controller.signal },
),
(err: any) => err?.name === "AbortError" || err?.code === "ABORT_ERR",
);
});
test("dry-run renders timeout and on_error details", async () => {
const { stderrOutput } = await runWorkflow(
{
steps: [
{
id: "fetch",
command: "curl https://example.com",
timeout_ms: 5000,
on_error: "continue",
},
],
},
{ dryRun: true },
);
assert.match(stderrOutput, /timeout: 5000ms/);
assert.match(stderrOutput, /on_error: continue/);
});

View File

@ -1,12 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runPipeline } from '../src/runtime.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { parsePipeline } from '../src/parser.js';
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { parsePipeline } from "../src/parser.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
@ -18,31 +18,33 @@ async function run(pipelineText: string, input: any[]) {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
input: (async function* () { for (const x of input) yield x; })(),
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test('template renders fields and nested fields', async () => {
const out = await run("template --text 'hi {{user.name}}'", [{ user: { name: 'v' } }]);
assert.deepEqual(out, ['hi v']);
test("template renders fields and nested fields", async () => {
const out = await run("template --text 'hi {{user.name}}'", [{ user: { name: "v" } }]);
assert.deepEqual(out, ["hi v"]);
});
test('template renders missing fields as empty', async () => {
test("template renders missing fields as empty", async () => {
const out = await run("template --text 'x={{nope}}'", [{ a: 1 }]);
assert.deepEqual(out, ['x=']);
assert.deepEqual(out, ["x="]);
});
test('template supports {{.}} for whole item', async () => {
test("template supports {{.}} for whole item", async () => {
const out = await run("template --text '{{.}}'", [{ a: 1 }]);
assert.deepEqual(out, [JSON.stringify({ a: 1 })]);
});
test('template supports --file', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lobster-template-'));
const file = path.join(dir, 'tpl.txt');
await fs.writeFile(file, 'hey {{x}}', 'utf8');
const out = await run(`template --file ${file}`, [{ x: 'ok' }]);
assert.deepEqual(out, ['hey ok']);
test("template supports --file", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lobster-template-"));
const file = path.join(dir, "tpl.txt");
await fs.writeFile(file, "hey {{x}}", "utf8");
const out = await run(`template --file ${file}`, [{ x: "ok" }]);
assert.deepEqual(out, ["hey ok"]);
});

View File

@ -0,0 +1,130 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parsePipeline } from "../src/parser.js";
import { runPipeline } from "../src/runtime.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { applyFilters, parseFilterExpression } from "../src/core/filters.js";
async function run(pipelineText: string, input: any[]) {
const pipeline = parsePipeline(pipelineText);
const registry = createDefaultRegistry();
const res = await runPipeline({
pipeline,
registry,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: "tool",
input: (async function* () {
for (const x of input) yield x;
})(),
});
return res.items;
}
test("parseFilterExpression parses simple filter", () => {
assert.deepEqual(parseFilterExpression("upper"), ["upper"]);
});
test("parseFilterExpression parses filter args", () => {
assert.deepEqual(parseFilterExpression("truncate 80"), ["truncate", "80"]);
});
test("parseFilterExpression parses quoted args", () => {
assert.deepEqual(parseFilterExpression('replace "-" "_"'), ["replace", "-", "_"]);
});
test("applyFilters upper", () => {
assert.equal(applyFilters("hello", ["upper"]), "HELLO");
});
test("applyFilters lower", () => {
assert.equal(applyFilters("HELLO", ["lower"]), "hello");
});
test("applyFilters trim", () => {
assert.equal(applyFilters(" hi ", ["trim"]), "hi");
});
test("applyFilters truncate", () => {
assert.equal(applyFilters("hello world", ["truncate 5"]), "hello...");
});
test("applyFilters replace", () => {
assert.equal(applyFilters("a-b-c", ['replace "-" "_"']), "a_b_c");
});
test("applyFilters split", () => {
assert.deepEqual(applyFilters("a,b,c", ['split ","']), ["a", "b", "c"]);
});
test("applyFilters first/last", () => {
assert.equal(applyFilters([1, 2, 3], ["first"]), 1);
assert.equal(applyFilters([1, 2, 3], ["last"]), 3);
});
test("applyFilters length on array and string", () => {
assert.equal(applyFilters([1, 2, 3], ["length"]), 3);
assert.equal(applyFilters("hello", ["length"]), 5);
});
test("applyFilters join", () => {
assert.equal(applyFilters(["a", "b", "c"], ['join ", "']), "a, b, c");
});
test("applyFilters json/default/round", () => {
assert.equal(applyFilters({ a: 1 }, ["json"]), JSON.stringify({ a: 1 }, null, 2));
assert.equal(applyFilters(null, ['default "N/A"']), "N/A");
assert.equal(applyFilters("ok", ['default "N/A"']), "ok");
assert.equal(applyFilters(3.14159, ["round 2"]), 3.14);
});
test("applyFilters chain", () => {
assert.equal(applyFilters(" Hello World ", ["trim", "upper"]), "HELLO WORLD");
});
test("applyFilters date formatting is UTC-stable", () => {
const result = applyFilters(1710000000000, ['date "YYYY-MM-DD"']);
assert.equal(result, "2024-03-09");
});
test("applyFilters unknown filter throws", () => {
assert.throws(() => applyFilters("x", ["nonexistent"]), /Unknown template filter/);
});
test("template filter integration: upper", async () => {
const out = await run("template --text '{{name | upper}}'", [{ name: "alice" }]);
assert.deepEqual(out, ["ALICE"]);
});
test("template filter integration: length", async () => {
const out = await run("template --text '{{items | length}}'", [{ items: [1, 2, 3] }]);
assert.deepEqual(out, ["3"]);
});
test("template filter integration: default", async () => {
const out = await run("template --text '{{missing | default \"N/A\"}}'", [{ other: 1 }]);
assert.deepEqual(out, ["N/A"]);
});
test("template filter integration: chained", async () => {
const out = await run("template --text '{{name | trim | upper}}'", [{ name: " bob " }]);
assert.deepEqual(out, ["BOB"]);
});
test("template integration without filters remains unchanged", async () => {
const out = await run("template --text 'hi {{name}}'", [{ name: "v" }]);
assert.deepEqual(out, ["hi v"]);
});
test("template filter integration: join", async () => {
const out = await run("template --text '{{tags | join \", \"}}'", [{ tags: ["a", "b", "c"] }]);
assert.deepEqual(out, ["a, b, c"]);
});
test("template filter splitter handles quoted pipe characters", async () => {
const out = await run("template --text '{{line | split \"|\" | first}}'", [{ line: "a|b|c" }]);
assert.deepEqual(out, ["a"]);
});

View File

@ -1,12 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
test('tool mode outputs protocolVersion', () => {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
const res = spawnSync('node', [bin, 'run', '--mode', 'tool', "exec --json --shell 'echo [1]'"], {
encoding: 'utf8',
test("tool mode outputs protocolVersion", () => {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync("node", [bin, "run", "--mode", "tool", "exec --json --shell 'echo [1]'"], {
encoding: "utf8",
});
assert.equal(res.status, 0);

View File

@ -1,8 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parsePipeline } from '../src/parser.js';
import { createDefaultRegistry } from '../src/commands/registry.js';
import { runPipeline } from '../src/runtime.js';
import test from "node:test";
import assert from "node:assert/strict";
import { parsePipeline } from "../src/parser.js";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runPipeline } from "../src/runtime.js";
function streamOf(items) {
return (async function* () {
@ -10,10 +10,10 @@ function streamOf(items) {
})();
}
test('approve halts pipeline in tool mode', async () => {
test("approve halts pipeline in tool mode", async () => {
const registry = createDefaultRegistry();
const pipeline = parsePipeline(
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'send?' | exec --shell 'exit 1'"
"exec --json --shell \"node -e 'process.stdout.write(JSON.stringify([{a:1}]))'\" | approve --prompt 'send?' | exec --shell 'exit 1'",
);
const output = await runPipeline({
@ -24,30 +24,30 @@ test('approve halts pipeline in tool mode', async () => {
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
mode: 'tool',
mode: "tool",
});
assert.equal(output.halted, true);
assert.equal(output.items.length, 1);
assert.equal(output.items[0].type, 'approval_request');
assert.equal(output.items[0].type, "approval_request");
assert.equal(output.items[0].items.length, 1);
assert.deepEqual(output.items[0].items[0], { a: 1 });
});
test('approve passes through in human interactive mode only (emit required otherwise)', async () => {
test("approve passes through in human interactive mode only (emit required otherwise)", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('approve');
const cmd = registry.get("approve");
const result = await cmd.run({
input: streamOf([{ x: 1 }]),
args: { _: [], emit: true, prompt: 'ok?' },
args: { _: [], emit: true, prompt: "ok?" },
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: process.env,
registry,
mode: 'human',
mode: "human",
render: { json() {}, lines() {} },
},
});
@ -55,20 +55,21 @@ test('approve passes through in human interactive mode only (emit required other
const items = [];
for await (const it of result.output) items.push(it);
assert.equal(result.halt, true);
assert.equal(items[0].type, 'approval_request');
assert.equal(items[0].type, "approval_request");
});
test('ask halts pipeline in tool mode with needs_input payload', async () => {
test("ask halts pipeline in tool mode with needs_input payload", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get('ask');
const cmd = registry.get("ask");
const result = await cmd.run({
input: streamOf([{ text: 'draft v1' }]),
input: streamOf([{ text: "draft v1" }]),
args: {
_: [],
prompt: 'Review?',
'subject-from-stdin': true,
schema: '{"type":"object","properties":{"decision":{"type":"string"}},"required":["decision"]}',
prompt: "Review?",
"subject-from-stdin": true,
schema:
'{"type":"object","properties":{"decision":{"type":"string"}},"required":["decision"]}',
},
ctx: {
stdin: process.stdin,
@ -76,7 +77,7 @@ test('ask halts pipeline in tool mode with needs_input payload', async () => {
stderr: process.stderr,
env: process.env,
registry,
mode: 'tool',
mode: "tool",
render: { json() {}, lines() {} },
},
});
@ -85,7 +86,7 @@ test('ask halts pipeline in tool mode with needs_input payload', async () => {
for await (const it of result.output) items.push(it);
assert.equal(result.halt, true);
assert.equal(items.length, 1);
assert.equal(items[0].type, 'input_request');
assert.equal(items[0].prompt, 'Review?');
assert.equal(items[0].type, "input_request");
assert.equal(items[0].prompt, "Review?");
assert.deepEqual(items[0].subject, { text: '{"text":"draft v1"}' });
});

View File

@ -1,31 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import path from "node:path";
import os from "node:os";
import { runWorkflowFile } from '../src/workflows/file.js';
import { runWorkflowFile } from "../src/workflows/file.js";
test('workflow file exposes args as LOBSTER_ARG_* env vars (safe for quotes)', async () => {
test("workflow file exposes args as LOBSTER_ARG_* env vars (safe for quotes)", async () => {
const workflow = {
name: 'args-env',
name: "args-env",
args: {
text: { default: '' },
text: { default: "" },
},
steps: [
{
id: 'echo',
id: "echo",
// Avoid embedding the arg into the shell command; read from env instead.
command:
"node -e \"process.stdout.write(JSON.stringify({text: process.env.LOBSTER_ARG_TEXT}))\"",
'node -e "process.stdout.write(JSON.stringify({text: process.env.LOBSTER_ARG_TEXT}))"',
},
],
};
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-workflow-args-env-'));
const stateDir = path.join(tmpDir, 'state');
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-workflow-args-env-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };
const text = 'hello "world" $HOME `backticks` $(whoami)';
@ -38,10 +38,10 @@ test('workflow file exposes args as LOBSTER_ARG_* env vars (safe for quotes)', a
stdout: process.stdout,
stderr: process.stderr,
env,
mode: 'tool',
mode: "tool",
},
});
assert.equal(result.status, 'ok');
assert.equal(result.status, "ok");
assert.deepEqual(result.output, [{ text }]);
});

Some files were not shown because too many files have changed in this diff Show More