Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4514ecad5 | ||
|
|
0af9ff116f | ||
|
|
a95f133c2e | ||
|
|
14e46b388c | ||
|
|
595f862d73 | ||
|
|
7d6ecec694 | ||
|
|
04e01b5027 | ||
|
|
6d397c51c1 | ||
|
|
7d6a22a0c3 | ||
|
|
60c976571a | ||
|
|
933fb49f6f | ||
|
|
503a2f6053 | ||
|
|
373c447e39 | ||
|
|
05e34d741f | ||
|
|
425198e09d | ||
|
|
47eb7e81b3 | ||
|
|
b9a1b1b2a5 |
26
CHANGELOG.md
26
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
49
README.md
49
README.md
@ -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.
|
||||
|
||||
71
package.json
71
package.json
@ -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
618
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
444
src/cli.ts
444
src/cli.ts
@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
})(),
|
||||
};
|
||||
},
|
||||
|
||||
@ -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]) };
|
||||
|
||||
@ -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})`);
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ export const gogGmailSearchCommand = {
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: ['reads_email'],
|
||||
sideEffects: ["reads_email"],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
|
||||
@ -57,7 +57,7 @@ export const gogGmailSendCommand = {
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: ['sends_email'],
|
||||
sideEffects: ["sends_email"],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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* () {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -1 +1 @@
|
||||
export { llmTaskInvokeCommand } from './llm_invoke.js';
|
||||
export { llmTaskInvokeCommand } from "./llm_invoke.js";
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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]) };
|
||||
},
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@ -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* () {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
111
src/core/cost_tracker.ts
Normal 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
101
src/core/filters.ts
Normal 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;
|
||||
}
|
||||
@ -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
100
src/core/retry.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
103
src/resume.ts
103
src/resume.ts
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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() {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
28
src/shell.ts
28
src/shell.ts
@ -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];
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
253
src/workflows/graph.ts
Normal 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);
|
||||
}
|
||||
@ -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.',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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" }]);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
139
test/condition_comparison.test.ts
Normal file
139
test/condition_comparison.test.ts
Normal 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"]);
|
||||
});
|
||||
@ -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
147
test/cost_tracker.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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\]/);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {} },
|
||||
},
|
||||
});
|
||||
|
||||
14
test/fixtures/mock-gog.mjs
vendored
14
test/fixtures/mock-gog.mjs
vendored
@ -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
262
test/for_each.test.ts
Normal 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/);
|
||||
});
|
||||
@ -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"));
|
||||
});
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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() {} },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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() {} },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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" }]);
|
||||
});
|
||||
|
||||
@ -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
194
test/on_error.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@ -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
203
test/parallel.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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" }]);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
|
||||
@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
@ -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
283
test/step_retry.test.ts
Normal 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
207
test/step_timeout.test.ts
Normal 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/);
|
||||
});
|
||||
@ -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"]);
|
||||
});
|
||||
|
||||
130
test/template_filters.test.ts
Normal file
130
test/template_filters.test.ts
Normal 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"]);
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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"}' });
|
||||
});
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user