Compare commits
2 Commits
main
...
demo/openc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef048d044c | ||
|
|
7dac9e39f1 |
534
.github/workflows/lobster-npm-release.yml
vendored
534
.github/workflows/lobster-npm-release.yml
vendored
@ -1,534 +0,0 @@
|
||||
name: Lobster NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.1.21, v2026.1.21-beta.1, or fallback v2026.1.21-1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
preflight_run_id:
|
||||
description: Existing successful preflight workflow run id to promote without rebuilding
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag to publish to for stable releases
|
||||
required: true
|
||||
default: beta
|
||||
type: choice
|
||||
options:
|
||||
- beta
|
||||
- latest
|
||||
promote_beta_to_latest:
|
||||
description: Skip publish and promote the stable version already on npm beta to latest
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: lobster-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}-{2}', inputs.tag, inputs.npm_dist_tag, inputs.promote_beta_to_latest) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "9.15.9"
|
||||
|
||||
jobs:
|
||||
preflight_lobster_npm:
|
||||
if: ${{ inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Beta prerelease tags must publish to npm dist-tag beta."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Forbid preflight artifact promotion on validation-only runs
|
||||
if: ${{ inputs.preflight_run_id != '' }}
|
||||
run: |
|
||||
echo "preflight_run_id is only valid for real publish runs."
|
||||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
EXPECTED_VERSION="${RELEASE_TAG#v}"
|
||||
|
||||
if [[ "${PACKAGE_VERSION}" != "${EXPECTED_VERSION}" ]]; then
|
||||
echo "package.json version ${PACKAGE_VERSION} does not match release tag ${RELEASE_TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${PACKAGE_NAME}" != "@clawdbot/lobster" ]]; then
|
||||
echo "Unexpected package name ${PACKAGE_NAME}; update the workflow if the publish target changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Validated ${PACKAGE_NAME}@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Validate changelog entry for release version
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const releaseTag = process.env.RELEASE_TAG;
|
||||
const releaseVersion = releaseTag?.startsWith('v') ? releaseTag.slice(1) : releaseTag;
|
||||
if (!releaseVersion) {
|
||||
console.error('RELEASE_TAG is required to validate CHANGELOG.md.');
|
||||
process.exit(1);
|
||||
}
|
||||
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
|
||||
const lines = changelog.split(/\r?\n/);
|
||||
const heading = `## ${releaseVersion}`;
|
||||
const start = lines.findIndex((line) => line.trim() === heading);
|
||||
|
||||
if (start === -1) {
|
||||
console.error(`CHANGELOG.md is missing a release section for ${releaseVersion}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasBullet = false;
|
||||
for (let idx = start + 1; idx < lines.length; idx += 1) {
|
||||
const trimmed = lines[idx].trim();
|
||||
if (trimmed.startsWith('## ')) break;
|
||||
if (trimmed.startsWith('- ')) {
|
||||
hasBullet = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasBullet) {
|
||||
console.error(`CHANGELOG.md section ${heading} must contain at least one bullet item.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Validated CHANGELOG.md section ${heading}`);
|
||||
NODE
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
|
||||
exit 0
|
||||
fi
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing ${PACKAGE_NAME}@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Pack prepared npm tarball
|
||||
id: packed_tarball
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
echo "$PACK_JSON"
|
||||
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')"
|
||||
if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/lobster-npm-preflight"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
cp "$PACK_PATH" "$ARTIFACT_DIR/"
|
||||
printf '%s\n' "$PACKAGE_NAME" > "$ARTIFACT_DIR/package-name.txt"
|
||||
printf '%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/package-version.txt"
|
||||
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
|
||||
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
|
||||
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lobster-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for publish
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require preflight artifact promotion on real publish
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_lobster_npm:
|
||||
needs: [validate_publish_request]
|
||||
if: ${{ !inputs.preflight_only && !inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_TAG}" == *"-beta."* && "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Beta prerelease tags must publish to npm dist-tag beta."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
check-latest: false
|
||||
|
||||
- name: Verify preflight run metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Lobster NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lobster-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
EXPECTED_VERSION="${RELEASE_TAG#v}"
|
||||
|
||||
if [[ "${PACKAGE_VERSION}" != "${EXPECTED_VERSION}" ]]; then
|
||||
echo "package.json version ${PACKAGE_VERSION} does not match release tag ${RELEASE_TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${PACKAGE_NAME}" != "@clawdbot/lobster" ]]; then
|
||||
echo "Unexpected package name ${PACKAGE_NAME}; update the workflow if the publish target changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate changelog entry for release version
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
const releaseTag = process.env.RELEASE_TAG;
|
||||
const releaseVersion = releaseTag?.startsWith('v') ? releaseTag.slice(1) : releaseTag;
|
||||
if (!releaseVersion) {
|
||||
console.error('RELEASE_TAG is required to validate CHANGELOG.md.');
|
||||
process.exit(1);
|
||||
}
|
||||
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
|
||||
const lines = changelog.split(/\r?\n/);
|
||||
const heading = `## ${releaseVersion}`;
|
||||
const start = lines.findIndex((line) => line.trim() === heading);
|
||||
|
||||
if (start === -1) {
|
||||
console.error(`CHANGELOG.md is missing a release section for ${releaseVersion}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasBullet = false;
|
||||
for (let idx = start + 1; idx < lines.length; idx += 1) {
|
||||
const trimmed = lines[idx].trim();
|
||||
if (trimmed.startsWith('## ')) break;
|
||||
if (trimmed.startsWith('- ')) {
|
||||
hasBullet = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasBullet) {
|
||||
console.error(`CHANGELOG.md section ${heading} must contain at least one bullet item.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Validated CHANGELOG.md section ${heading}`);
|
||||
NODE
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify prepared tarball provenance
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_PACKAGE_NAME="$(node -p "require('./package.json').name")"
|
||||
EXPECTED_PACKAGE_VERSION="$(node -p "require('./package.json').version")"
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
NAME_FILE="preflight-tarball/package-name.txt"
|
||||
VERSION_FILE="preflight-tarball/package-version.txt"
|
||||
TAG_FILE="preflight-tarball/release-tag.txt"
|
||||
SHA_FILE="preflight-tarball/release-sha.txt"
|
||||
NPM_DIST_TAG_FILE="preflight-tarball/release-npm-dist-tag.txt"
|
||||
if [[ ! -f "$NAME_FILE" || ! -f "$VERSION_FILE" || ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$NPM_DIST_TAG_FILE" ]]; then
|
||||
echo "Prepared preflight metadata is missing." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
ARTIFACT_PACKAGE_NAME="$(tr -d '\r\n' < "$NAME_FILE")"
|
||||
ARTIFACT_PACKAGE_VERSION="$(tr -d '\r\n' < "$VERSION_FILE")"
|
||||
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
|
||||
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
|
||||
ARTIFACT_RELEASE_NPM_DIST_TAG="$(tr -d '\r\n' < "$NPM_DIST_TAG_FILE")"
|
||||
if [[ "$ARTIFACT_PACKAGE_NAME" != "$EXPECTED_PACKAGE_NAME" ]]; then
|
||||
echo "Prepared preflight package mismatch: expected $EXPECTED_PACKAGE_NAME, got $ARTIFACT_PACKAGE_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_PACKAGE_VERSION" != "$EXPECTED_PACKAGE_VERSION" ]]; then
|
||||
echo "Prepared preflight version mismatch: expected $EXPECTED_PACKAGE_VERSION, got $ARTIFACT_PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
|
||||
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
|
||||
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_NPM_DIST_TAG" != "$RELEASE_NPM_DIST_TAG" ]]; then
|
||||
echo "Prepared preflight npm dist-tag mismatch: expected $RELEASE_NPM_DIST_TAG, got $ARTIFACT_RELEASE_NPM_DIST_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve publish tarball
|
||||
id: publish_tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
|
||||
if [[ -z "$TARBALL_PATH" ]]; then
|
||||
echo "Prepared preflight tarball not found." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$HOME/.npmrc"
|
||||
npm whoami >/dev/null
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
npm publish "${publish_target}" --access public --tag "${RELEASE_NPM_DIST_TAG}" --provenance
|
||||
|
||||
promote_beta_to_latest:
|
||||
if: ${{ inputs.promote_beta_to_latest }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for promotion
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Promotion runs must be dispatched from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate promotion inputs
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "Promotion mode cannot run with preflight_only=true."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Promotion mode does not use preflight_run_id."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${RELEASE_NPM_DIST_TAG}" != "beta" ]]; then
|
||||
echo "Promotion mode expects npm_dist_tag=beta because it moves beta to latest without publishing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate stable tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid stable release tag format: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
check-latest: false
|
||||
|
||||
- name: Validate npm dist-tags
|
||||
env:
|
||||
PACKAGE_NAME: "@clawdbot/lobster"
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
beta_version="$(npm view "${PACKAGE_NAME}" dist-tags.beta)"
|
||||
latest_version="$(npm view "${PACKAGE_NAME}" dist-tags.latest)"
|
||||
|
||||
echo "Current beta dist-tag: ${beta_version}"
|
||||
echo "Current latest dist-tag: ${latest_version}"
|
||||
|
||||
if [[ "${beta_version}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm beta points at ${beta_version}, expected ${RELEASE_VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${RELEASE_VERSION} is not published on npm." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Promote beta to latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
PACKAGE_NAME: "@clawdbot/lobster"
|
||||
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$HOME/.npmrc"
|
||||
npm whoami >/dev/null
|
||||
npm dist-tag add "${PACKAGE_NAME}@${RELEASE_VERSION}" latest
|
||||
promoted_latest="$(npm view "${PACKAGE_NAME}" dist-tags.latest)"
|
||||
if [[ "${promoted_latest}" != "${RELEASE_VERSION}" ]]; then
|
||||
echo "npm latest points at ${promoted_latest}, expected ${RELEASE_VERSION} after promotion." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Promoted ${PACKAGE_NAME}@${RELEASE_VERSION} from beta to latest."
|
||||
35
AGENTS.md
35
AGENTS.md
@ -1,35 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for coding assistants operating in this repository.
|
||||
|
||||
## When To Use Lobster
|
||||
|
||||
- Prefer `lobster` for multi-step or repeatable workflows.
|
||||
- Use direct shell commands for simple one-off tasks.
|
||||
- Prefer deterministic pipelines/workflows over ad-hoc LLM re-planning loops.
|
||||
|
||||
## Invocation Contract
|
||||
|
||||
- Use tool mode for machine-readable output:
|
||||
- `lobster run --mode tool '<pipeline>'`
|
||||
- `lobster run --mode tool --file <workflow.lobster> --args-json '<json>'`
|
||||
- If `lobster` is not on `PATH`, use:
|
||||
- `node bin/lobster.js ...`
|
||||
|
||||
## Approval And Resume
|
||||
|
||||
- Treat `status: "needs_approval"` as a hard stop.
|
||||
- Never auto-approve on behalf of a user.
|
||||
- Resume only after explicit user decision:
|
||||
- `lobster resume --token <resumeToken> --approve yes|no`
|
||||
|
||||
## Output Handling
|
||||
|
||||
- Parse the tool envelope JSON fields: `ok`, `status`, `output`, `requiresApproval`, `error`.
|
||||
- On `ok: false`, surface the error and stop.
|
||||
|
||||
## Safety And Shell Usage
|
||||
|
||||
- For workflow-file commands, prefer environment variables (`LOBSTER_ARG_*`) for untrusted or quoted values.
|
||||
- Avoid embedding unsafe user strings directly into shell command text.
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@ -4,30 +4,12 @@ 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, 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 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.
|
||||
- Add workflow file runner for `.lobster`/YAML/JSON workflows with args, env, conditions, and approval gates.
|
||||
- Add compact workflow resume tokens backed by Lobster state storage.
|
||||
- Add `exec --stdin raw|json|jsonl` to pipe workflow output into subprocess stdin.
|
||||
- Add `approve --preview-from-stdin --limit N` for approval previews without extra glue.
|
||||
- Add `clawd.invoke --each` to map pipeline input items into tool calls.
|
||||
- Extend CLI to run workflow files with `lobster run <file>` or `--file` + `--args-json`.
|
||||
|
||||
## 2026.1.21-1
|
||||
|
||||
|
||||
208
README.md
208
README.md
@ -1,20 +1,20 @@
|
||||
# Lobster
|
||||
|
||||
An OpenClaw-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.
|
||||
A Moltbot-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.
|
||||
|
||||
|
||||
## Example of Lobster at work
|
||||
OpenClaw (or any other AI agent) can use `lobster` as a workflow engine and avoid re-planning every step — saving tokens while improving determinism and resumability.
|
||||
## Example of lobster at work
|
||||
Moltbot or any other AI agent can use `lobster` as a workflow engine and not construct a query every time - thus saving tokens, providing room for determinism, and resumability.
|
||||
|
||||
### Watching a PR that hasn't had changes
|
||||
```
|
||||
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1152}'"
|
||||
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"moltbot/moltbot\",\"pr\":1152}'"
|
||||
[
|
||||
{
|
||||
"kind": "github.pr.monitor",
|
||||
"repo": "openclaw/openclaw",
|
||||
"repo": "moltbot/moltbot",
|
||||
"prNumber": 1152,
|
||||
"key": "github.pr:openclaw/openclaw#1152",
|
||||
"key": "github.pr:moltbot/moltbot#1152",
|
||||
"changed": false,
|
||||
"summary": {
|
||||
"changedFields": [],
|
||||
@ -36,7 +36,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
|
||||
"state": "OPEN",
|
||||
"title": "feat: Add optional lobster plugin tool (typed workflows, approvals/resume)",
|
||||
"updatedAt": "2026-01-18T20:16:56Z",
|
||||
"url": "https://github.com/openclaw/openclaw/pull/1152"
|
||||
"url": "https://github.com/moltbot/moltbot/pull/1152"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -44,13 +44,13 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
|
||||
### And a PR that has a state change (in this case an approved PR)
|
||||
|
||||
```
|
||||
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1200}'"
|
||||
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"moltbot/moltbot\",\"pr\":1200}'"
|
||||
[
|
||||
{
|
||||
"kind": "github.pr.monitor",
|
||||
"repo": "openclaw/openclaw",
|
||||
"repo": "moltbot/moltbot",
|
||||
"prNumber": 1200,
|
||||
"key": "github.pr:openclaw/openclaw#1200",
|
||||
"key": "github.pr:moltbot/moltbot#1200",
|
||||
"changed": true,
|
||||
"summary": {
|
||||
"changedFields": [
|
||||
@ -76,7 +76,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
|
||||
},
|
||||
"url": {
|
||||
"from": null,
|
||||
"to": "https://github.com/openclaw/openclaw/pull/1200"
|
||||
"to": "https://github.com/moltbot/moltbot/pull/1200"
|
||||
},
|
||||
"state": {
|
||||
"from": null,
|
||||
@ -124,7 +124,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
|
||||
"state": "MERGED",
|
||||
"title": "feat(tui): add syntax highlighting for code blocks",
|
||||
"updatedAt": "2026-01-19T05:06:09Z",
|
||||
"url": "https://github.com/openclaw/openclaw/pull/1200"
|
||||
"url": "https://github.com/moltbot/moltbot/pull/1200"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -136,7 +136,7 @@ node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo
|
||||
- Typed pipelines (objects/arrays), not text pipes.
|
||||
- Local-first execution.
|
||||
- No new auth surface: Lobster must not own OAuth/tokens.
|
||||
- Composable macros that OpenClaw (or any agent) can invoke in one step to save tokens.
|
||||
- Composable macros that Moltbot can invoke in one step to save tokens.
|
||||
|
||||
## Quick start
|
||||
|
||||
@ -159,20 +159,15 @@ From this folder:
|
||||
- `exec --stdin raw|json|jsonl`: feed pipeline input into subprocess stdin
|
||||
- `where`, `pick`, `head`: data shaping
|
||||
- `json`, `table`: renderers
|
||||
- `approve`: approval gate (TTY prompt or `--emit` for OpenClaw integration)
|
||||
- `approve`: approval gate (TTY prompt or `--emit` for Moltbot integration)
|
||||
|
||||
## Next steps
|
||||
|
||||
- OpenClaw integration: ship as an optional OpenClaw plugin tool.
|
||||
- Moltbot integration: ship as an optional Moltbot plugin tool.
|
||||
|
||||
## Workflow files
|
||||
|
||||
Lobster workflow files are meant to read like small scripts:
|
||||
|
||||
- `run:` or `command:` for deterministic shell/CLI steps
|
||||
- `pipeline:` for native Lobster stages like `llm.invoke`
|
||||
- `approval:` for hard workflow gates between steps
|
||||
- `stdin: $step.stdout` or `stdin: $step.json` to pass data forward
|
||||
Lobster can run YAML/JSON workflow files with `steps`, `env`, `condition`, and approval gates.
|
||||
|
||||
```
|
||||
lobster run path/to/workflow.lobster
|
||||
@ -182,162 +177,19 @@ lobster run --file path/to/workflow.lobster --args-json '{"tag":"family"}'
|
||||
Example file:
|
||||
|
||||
```yaml
|
||||
name: jacket-advice
|
||||
args:
|
||||
location:
|
||||
default: Phoenix
|
||||
name: inbox-triage
|
||||
steps:
|
||||
- id: fetch
|
||||
run: weather --json ${location}
|
||||
|
||||
- id: confirm
|
||||
approval: Want jacket advice from the LLM?
|
||||
stdin: $fetch.json
|
||||
|
||||
- id: advice
|
||||
pipeline: >
|
||||
llm.invoke --prompt "Given this weather data, should I wear a jacket?
|
||||
Be concise and return JSON."
|
||||
stdin: $fetch.json
|
||||
when: $confirm.approved
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `run:` and `command:` are equivalent; `run:` is the preferred spelling for new files.
|
||||
- `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
|
||||
|
||||
Use `llm.invoke` from a native `pipeline:` step for model-backed work:
|
||||
|
||||
```bash
|
||||
llm.invoke --prompt 'Summarize this diff'
|
||||
llm.invoke --provider openclaw --prompt 'Summarize this diff'
|
||||
llm.invoke --provider pi --prompt 'Summarize this diff'
|
||||
```
|
||||
|
||||
Provider resolution order:
|
||||
|
||||
- `--provider`
|
||||
- `LOBSTER_LLM_PROVIDER`
|
||||
- auto-detect from environment
|
||||
|
||||
Built-in providers today:
|
||||
|
||||
- `openclaw` via `OPENCLAW_URL` / `OPENCLAW_TOKEN`
|
||||
- `pi` via `LOBSTER_PI_LLM_ADAPTER_URL` (typically supplied by the Pi extension)
|
||||
- `http` via `LOBSTER_LLM_ADAPTER_URL`
|
||||
|
||||
`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.
|
||||
|
||||
If you install Lobster via npm/pnpm, it installs a small shim executable named:
|
||||
|
||||
- `openclaw.invoke` (preferred)
|
||||
- `clawd.invoke` (alias)
|
||||
|
||||
These shims forward to the Lobster pipeline command of the same name.
|
||||
|
||||
### Example: invoke llm-task
|
||||
|
||||
Prereqs:
|
||||
|
||||
- `OPENCLAW_URL` points at a running OpenClaw gateway
|
||||
- optionally `OPENCLAW_TOKEN` if auth is enabled
|
||||
|
||||
```bash
|
||||
export OPENCLAW_URL=http://127.0.0.1:18789
|
||||
# export OPENCLAW_TOKEN=...
|
||||
```
|
||||
|
||||
In a workflow:
|
||||
|
||||
```yaml
|
||||
name: hello-world
|
||||
steps:
|
||||
- id: greeting
|
||||
run: >
|
||||
openclaw.invoke --tool llm-task --action json --args-json '{"prompt":"Hello"}'
|
||||
```
|
||||
|
||||
### Passing data between steps (no temp files)
|
||||
|
||||
Use `stdin: $stepId.stdout` to pipe output from one step into the next.
|
||||
|
||||
## Args and shell-safety
|
||||
|
||||
`${arg}` substitution is a raw string replace into the shell command text.
|
||||
|
||||
For anything that may contain quotes, `$`, backticks, or newlines, prefer env vars:
|
||||
|
||||
- every resolved workflow arg is exposed as `LOBSTER_ARG_<NAME>` (uppercased, non-alnum → `_`)
|
||||
- the full args object is also available as `LOBSTER_ARGS_JSON`
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
args:
|
||||
text:
|
||||
default: ""
|
||||
steps:
|
||||
- id: safe
|
||||
env:
|
||||
TEXT: "$LOBSTER_ARG_TEXT"
|
||||
command: |
|
||||
jq -n --arg text "$TEXT" '{"result": $text}'
|
||||
- id: collect
|
||||
command: inbox list --json
|
||||
- id: categorize
|
||||
command: inbox categorize --json
|
||||
stdin: $collect.stdout
|
||||
- id: approve
|
||||
command: inbox apply --approve
|
||||
stdin: $categorize.stdout
|
||||
approval: required
|
||||
- id: execute
|
||||
command: inbox apply --execute
|
||||
stdin: $categorize.stdout
|
||||
condition: $approve.approved
|
||||
```
|
||||
|
||||
46
VISION.md
46
VISION.md
@ -2,26 +2,26 @@
|
||||
|
||||
## One-liner
|
||||
|
||||
**Lobster is safe automation for OpenClaw — workflows that ask before acting.**
|
||||
**Lobster is safe automation for Clawdbot — workflows that ask before acting.**
|
||||
|
||||
---
|
||||
|
||||
## What is Lobster?
|
||||
|
||||
Lobster is a workflow runtime for OpenClaw. It lets you define multi-step automations that:
|
||||
Lobster is a workflow runtime for Clawdbot. It lets you define multi-step automations that:
|
||||
|
||||
- Run deterministically (no LLM re-planning each step)
|
||||
- Halt at checkpoints and ask for approval before side effects
|
||||
- Resume exactly where they left off
|
||||
- Remember what they've already processed
|
||||
|
||||
Think of it as **IFTTT/Zapier for OpenClaw, but with human checkpoints**.
|
||||
Think of it as **IFTTT/Zapier for Clawdbot, but with human checkpoints**.
|
||||
|
||||
---
|
||||
|
||||
## The Problem Lobster Solves
|
||||
|
||||
### Today's workflow in OpenClaw
|
||||
### Today's workflow in Clawdbot
|
||||
|
||||
```
|
||||
User: "Check my email, draft replies to anything urgent, and send them"
|
||||
@ -46,7 +46,7 @@ What happens:
|
||||
### With Lobster
|
||||
|
||||
```
|
||||
OpenClaw calls: lobster.run("email.triage")
|
||||
Clawdbot calls: lobster.run("email.triage")
|
||||
|
||||
What happens:
|
||||
1. Lobster fetches emails (deterministic)
|
||||
@ -63,7 +63,7 @@ Tomorrow: Lobster remembers cursor, only processes new emails
|
||||
|
||||
---
|
||||
|
||||
## Why Lobster Makes OpenClaw Better
|
||||
## Why Lobster Makes Clawdbot Better
|
||||
|
||||
| Without Lobster | With Lobster |
|
||||
|-----------------|--------------|
|
||||
@ -85,7 +85,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
|
||||
|
||||
---
|
||||
|
||||
## How Lobster Fits with OpenClaw
|
||||
## How Lobster Fits with Clawdbot
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
@ -95,7 +95,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ OpenClaw │
|
||||
│ Clawdbot │
|
||||
│ - Understands intent │
|
||||
│ - Chooses appropriate tool/workflow │
|
||||
│ - Presents results and approval prompts │
|
||||
@ -105,15 +105,15 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Lobster │
|
||||
│ - Executes deterministic pipeline │
|
||||
│ - Calls OpenClaw tools (gmail, trello, etc) │
|
||||
│ - Calls Clawdbot tools (gmail, trello, etc) │
|
||||
│ - Halts at approval checkpoints │
|
||||
│ - Returns structured result + resume token │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key insight:** Lobster doesn't replace OpenClaw. It's the execution layer that makes OpenClaw's automations safe and efficient.
|
||||
**Key insight:** Lobster doesn't replace Clawdbot. It's the execution layer that makes Clawdbot's automations safe and efficient.
|
||||
|
||||
- **OpenClaw** = the brain (understands what you want)
|
||||
- **Clawdbot** = the brain (understands what you want)
|
||||
- **Lobster** = the hands (executes workflows safely)
|
||||
- **Tools/Skills** = the capabilities (gmail, trello, github, etc.)
|
||||
|
||||
@ -121,7 +121,7 @@ Workflows can persist state: "last processed email ID", "last PR SHA seen", etc.
|
||||
|
||||
## Who Should Use Lobster?
|
||||
|
||||
### Average OpenClaw users (invisible benefit)
|
||||
### Average Clawdbot users (invisible benefit)
|
||||
They don't need to know Lobster exists. They just notice:
|
||||
- "Set up daily email triage" works better
|
||||
- Automations ask before sending/posting
|
||||
@ -133,7 +133,7 @@ They can:
|
||||
- Write new workflows for their specific needs
|
||||
- Share workflows with the community
|
||||
|
||||
### The OpenClaw ecosystem
|
||||
### The Clawdbot ecosystem
|
||||
- Workflow recipes become a new category of shareable assets
|
||||
- Skills stay simple (just expose APIs)
|
||||
- Lobster handles the orchestration layer
|
||||
@ -151,21 +151,21 @@ They can:
|
||||
| Temporal | But 80/20 version for personal workflows |
|
||||
|
||||
**Best analogy for most people:**
|
||||
> "Lobster is Zapier for OpenClaw, except it asks you before doing anything irreversible."
|
||||
> "Lobster is Zapier for Clawdbot, except it asks you before doing anything irreversible."
|
||||
|
||||
---
|
||||
|
||||
## Why Not Build This Into OpenClaw Core?
|
||||
## Why Not Build This Into Clawdbot Core?
|
||||
|
||||
It could be. But the plugin architecture is intentional:
|
||||
|
||||
1. **Core stays small** — OpenClaw is already complex
|
||||
1. **Core stays small** — Clawdbot is already complex
|
||||
2. **Faster iteration** — Lobster can evolve without core releases
|
||||
3. **Opt-in** — Not everyone needs workflow automation
|
||||
4. **Community** — Easier to contribute workflows than core changes
|
||||
5. **Ecosystem proof** — If plugins work for Lobster, they work for other capabilities
|
||||
|
||||
OpenClaw explicitly provides a plugin boundary to enable this pattern. Lobster is the first proof that it works.
|
||||
Pete (Clawdbot creator) explicitly built the plugin boundary to enable this pattern. Lobster is the first proof that it works.
|
||||
|
||||
---
|
||||
|
||||
@ -221,9 +221,9 @@ Other automation tools either:
|
||||
- Don't integrate with your AI assistant
|
||||
|
||||
Lobster is:
|
||||
- Native to OpenClaw (uses the same tools you already have)
|
||||
- Native to Clawdbot (uses the same tools you already have)
|
||||
- Safe by default (approvals are a language primitive)
|
||||
- Invisible when you want (OpenClaw uses it automatically)
|
||||
- Invisible when you want (Clawdbot uses it automatically)
|
||||
- Customizable when you need (write your own workflows)
|
||||
|
||||
---
|
||||
@ -232,8 +232,8 @@ Lobster is:
|
||||
|
||||
- **Not a terminal replacement** — You don't switch your shell to Lobster
|
||||
- **Not a general programming language** — It's for workflows, not apps
|
||||
- **Not trying to replace OpenClaw** — It makes OpenClaw better
|
||||
- **Not managing your secrets** — OpenClaw handles auth, Lobster orchestrates
|
||||
- **Not trying to replace Clawdbot** — It makes Clawdbot better
|
||||
- **Not managing your secrets** — Clawdbot handles auth, Lobster orchestrates
|
||||
|
||||
---
|
||||
|
||||
@ -241,9 +241,9 @@ Lobster is:
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| What is it? | Workflow runtime for OpenClaw |
|
||||
| What is it? | Workflow runtime for Clawdbot |
|
||||
| One-liner? | Safe automation — workflows that ask before acting |
|
||||
| Why use it? | Cheaper, safer, stateful automations |
|
||||
| Who uses it? | Everyone (invisibly) or power users (directly) |
|
||||
| Why not in core? | Plugin architecture — core stays small, capabilities are extensions |
|
||||
| Best analogy? | Zapier for OpenClaw, but with approval checkpoints |
|
||||
| Best analogy? | Zapier for Clawdbot, but with approval checkpoints |
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function shellQuote(arg) {
|
||||
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
|
||||
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const pipeline = ['clawd.invoke', ...argv.map(shellQuote)].join(' ');
|
||||
|
||||
const res = spawnSync('lobster', [pipeline], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
process.exit(res.status ?? 1);
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -10,10 +10,9 @@ const __dirname = dirname(__filename);
|
||||
async function load() {
|
||||
const distEntry = join(__dirname, "../dist/src/cli.js");
|
||||
if (existsSync(distEntry)) {
|
||||
return import(pathToFileURL(distEntry).href);
|
||||
return import(distEntry);
|
||||
}
|
||||
const srcEntry = join(__dirname, "../src/cli.js");
|
||||
return import(pathToFileURL(srcEntry).href);
|
||||
return import(join(__dirname, "../src/cli.js"));
|
||||
}
|
||||
|
||||
const mod = await load();
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function shellQuote(arg) {
|
||||
// Conservative POSIX-ish quoting for embedding argv into a single pipeline string.
|
||||
// Lobster's pipeline parser preserves quoted substrings.
|
||||
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
|
||||
// single-quote, escaping embedded single quotes: ' -> '\''
|
||||
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const pipeline = ['openclaw.invoke', ...argv.map(shellQuote)].join(' ');
|
||||
|
||||
const res = spawnSync('lobster', [pipeline], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
process.exit(res.status ?? 1);
|
||||
27
demos/README.md
Normal file
27
demos/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Lobster demos
|
||||
|
||||
## OpenClaw release tweet
|
||||
|
||||
A stage-friendly demo: read OpenClaw commits + changelog and generate a release tweet in one of three styles, with an approval gate and optional posting.
|
||||
|
||||
File: `demos/openclaw-release-tweet.sh`
|
||||
|
||||
### Prereqs
|
||||
- OpenClaw repo checked out locally (default path: `../openclaw`)
|
||||
- `jq`
|
||||
- A running Clawdbot/Moltbot Gateway for `llm_task.invoke`:
|
||||
- `CLAWD_URL` e.g. `http://127.0.0.1:19700/tools/invoke`
|
||||
- `CLAWD_TOKEN` bearer token
|
||||
- Optional: `bird` CLI installed + authenticated (only if `POST=true`)
|
||||
|
||||
### Run
|
||||
```bash
|
||||
cd lobster
|
||||
STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
|
||||
STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
|
||||
STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
|
||||
```
|
||||
|
||||
### Notes
|
||||
- By default it uses commits from `HEAD~30..HEAD`. Override with `SINCE_REF=<tag-or-sha>`.
|
||||
- Set `LINK=https://...` to point at your release notes / site.
|
||||
109
demos/openclaw-release-tweet.sh
Executable file
109
demos/openclaw-release-tweet.sh
Executable file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# OpenClaw release tweet demo (Lobster)
|
||||
#
|
||||
# What it does:
|
||||
# - Reads commits since a ref (defaults to HEAD~30 or STATE_REF if set)
|
||||
# - Reads top section of CHANGELOG.md
|
||||
# - Uses llm_task.invoke to generate ONE tweet in the requested style
|
||||
# - Shows an approval gate
|
||||
# - Optionally posts via bird CLI
|
||||
#
|
||||
# Prereqs:
|
||||
# - OpenClaw repo checked out locally (default: ../openclaw)
|
||||
# - jq installed
|
||||
# - Clawdbot/Moltbot gateway reachable for llm_task.invoke via env:
|
||||
# export CLAWD_URL='http://127.0.0.1:19700/tools/invoke'
|
||||
# export CLAWD_TOKEN='<token>'
|
||||
# - bird CLI installed+authed if POST=true
|
||||
#
|
||||
# Usage:
|
||||
# STYLE=sassy POST=false ./demos/openclaw-release-tweet.sh
|
||||
# STYLE=professional POST=false ./demos/openclaw-release-tweet.sh
|
||||
# STYLE=drybread POST=true ./demos/openclaw-release-tweet.sh
|
||||
|
||||
STYLE=${STYLE:-professional} # sassy|professional|drybread
|
||||
POST=${POST:-false} # true|false
|
||||
REPO_DIR=${REPO_DIR:-../openclaw}
|
||||
SINCE_REF=${SINCE_REF:-}
|
||||
MAX_COMMITS=${MAX_COMMITS:-30}
|
||||
LINK=${LINK:-https://openclaw.dev}
|
||||
|
||||
if [[ ! -d "$REPO_DIR/.git" ]]; then
|
||||
echo "Repo not found: $REPO_DIR (expected a git repo). Set REPO_DIR=..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$STYLE" != "sassy" && "$STYLE" != "professional" && "$STYLE" != "drybread" ]]; then
|
||||
echo "STYLE must be one of: sassy|professional|drybread" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine since ref
|
||||
if [[ -z "$SINCE_REF" ]]; then
|
||||
SINCE_REF="HEAD~${MAX_COMMITS}"
|
||||
fi
|
||||
|
||||
COMMITS=$(cd "$REPO_DIR" && git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE_REF..HEAD" | head -n 80 | jq -R -s -c 'split("\n") | map(select(length>0))')
|
||||
|
||||
CHANGELOG=$(cd "$REPO_DIR" && node - <<'NODE'
|
||||
const fs = require('fs');
|
||||
const t = fs.readFileSync('CHANGELOG.md','utf8');
|
||||
const parts = t.split(/\n## /);
|
||||
if (parts.length > 1) {
|
||||
const top = ('## ' + parts[1]).split(/\n## /)[0];
|
||||
process.stdout.write(top.slice(0, 6000));
|
||||
} else {
|
||||
process.stdout.write(t.slice(0, 6000));
|
||||
}
|
||||
NODE
|
||||
)
|
||||
|
||||
CONTEXT=$(jq -n --arg style "$STYLE" --arg since "$SINCE_REF" --arg link "$LINK" --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}')
|
||||
|
||||
PIPELINE=$(cat <<'EOF'
|
||||
exec --json --shell 'printf "%s" "$CONTEXT"'
|
||||
| llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"}},"required":["tweet"],"additionalProperties":false}' --prompt "You are writing a release tweet for OpenClaw.\n\nInput JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\nWrite ONE tweet in the requested style.\nConstraints:\n- <= 260 chars\n- Include the link exactly once\n- Do not hallucinate features\n- No hashtags unless truly helpful (max 1)\n\nReturn JSON: {\"tweet\":\"...\"}\n\nINPUT:\n{{.}}"
|
||||
| approve --prompt 'Post this tweet?'
|
||||
EOF
|
||||
)
|
||||
|
||||
# Render pipeline with env substitutions via bash (CONTEXT)
|
||||
export CONTEXT
|
||||
|
||||
OUT=$(node bin/lobster.js run --mode tool "$PIPELINE")
|
||||
|
||||
# If needs approval, show prompt + preview
|
||||
STATUS=$(echo "$OUT" | jq -r '.status')
|
||||
|
||||
if [[ "$STATUS" == "needs_approval" ]]; then
|
||||
PROMPT=$(echo "$OUT" | jq -r '.requiresApproval.prompt')
|
||||
echo "\n--- APPROVAL ---\n$PROMPT\n" >&2
|
||||
|
||||
if [[ "$POST" == "true" ]]; then
|
||||
# Approve and resume, then post via bird.
|
||||
TOKEN=$(echo "$OUT" | jq -r '.requiresApproval.resumeToken')
|
||||
DONE=$(node bin/lobster.js resume --token "$TOKEN" --approve yes)
|
||||
TWEET=$(echo "$DONE" | jq -r '.output[0].tweet')
|
||||
echo "\nPosting via bird...\n" >&2
|
||||
bird post --text "$TWEET"
|
||||
echo "\nDone.\n" >&2
|
||||
else
|
||||
echo "(Dry-run) Not posting. Set POST=true to actually post." >&2
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" == "ok" ]]; then
|
||||
echo "$OUT" | jq -r '.output[0].tweet'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$OUT" | jq '.'
|
||||
exit 1
|
||||
76
package.json
76
package.json
@ -1,29 +1,10 @@
|
||||
{
|
||||
"name": "@clawdbot/lobster",
|
||||
"version": "2026.4.6",
|
||||
"version": "2026.1.21-1",
|
||||
"description": "Workflow runtime for AI agents - deterministic pipelines with approval gates",
|
||||
"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"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"clawd.invoke": "bin/clawd.invoke.js",
|
||||
"lobster": "bin/lobster.js",
|
||||
"openclaw.invoke": "bin/openclaw.invoke.js"
|
||||
"lobster": "bin/lobster.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
@ -32,38 +13,49 @@
|
||||
"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 && tsgo -p tsconfig.json",
|
||||
"clean": "rm -rf dist",
|
||||
"build": "pnpm clean && tsc -p tsconfig.json",
|
||||
"prepack": "pnpm build",
|
||||
"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",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "oxlint --tsconfig tsconfig.json src test",
|
||||
"fmt": "oxlint --tsconfig tsconfig.json --fix src test",
|
||||
"test": "pnpm build && node --test dist/test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
"@types/node": "^22.0.0",
|
||||
"oxlint": "^0.15.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"workflow",
|
||||
"automation",
|
||||
"ai-agent",
|
||||
"approval",
|
||||
"pipeline",
|
||||
"lobster",
|
||||
"clawdbot"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/clawdbot/lobster.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clawdbot/lobster/issues"
|
||||
},
|
||||
"homepage": "https://github.com/clawdbot/lobster#readme",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
618
pnpm-lock.yaml
generated
618
pnpm-lock.yaml
generated
@ -9,359 +9,69 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
specifier: ^8.17.1
|
||||
version: 8.17.1
|
||||
yaml:
|
||||
specifier: ^2.8.4
|
||||
version: 2.8.4
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
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
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.7
|
||||
oxlint:
|
||||
specifier: ^1.62.0
|
||||
version: 1.62.0(oxlint-tsgolint@0.22.1)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.22.1
|
||||
version: 0.22.1
|
||||
specifier: ^0.15.0
|
||||
version: 0.15.15
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@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}
|
||||
'@oxlint/darwin-arm64@0.15.15':
|
||||
resolution: {integrity: sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
'@oxlint/darwin-x64@0.15.15':
|
||||
resolution: {integrity: sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@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}
|
||||
'@oxlint/linux-arm64-gnu@0.15.15':
|
||||
resolution: {integrity: sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
'@oxlint/linux-arm64-musl@0.15.15':
|
||||
resolution: {integrity: sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@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}
|
||||
'@oxlint/linux-x64-gnu@0.15.15':
|
||||
resolution: {integrity: sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
'@oxlint/linux-x64-musl@0.15.15':
|
||||
resolution: {integrity: sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@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}
|
||||
'@oxlint/win32-arm64@0.15.15':
|
||||
resolution: {integrity: sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@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}
|
||||
'@oxlint/win32-x64@0.15.15':
|
||||
resolution: {integrity: sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.22.1':
|
||||
resolution: {integrity: sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@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==}
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
@ -372,216 +82,59 @@ packages:
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
oxfmt@0.47.0:
|
||||
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
oxlint@0.15.15:
|
||||
resolution: {integrity: sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA==}
|
||||
engines: {node: '>=8.*'}
|
||||
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'}
|
||||
|
||||
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==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
yaml@2.8.2:
|
||||
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
'@oxlint/darwin-arm64@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
'@oxlint/darwin-x64@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
'@oxlint/linux-arm64-gnu@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
'@oxlint/linux-arm64-musl@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
'@oxlint/linux-x64-gnu@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
'@oxlint/linux-x64-musl@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
'@oxlint/win32-arm64@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
'@oxlint/win32-x64@0.15.15':
|
||||
optional: true
|
||||
|
||||
'@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':
|
||||
'@types/node@22.19.7':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@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:
|
||||
ajv@8.17.1:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
@ -594,68 +147,21 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
oxfmt@0.47.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
oxlint@0.15.15:
|
||||
optionalDependencies:
|
||||
'@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
|
||||
'@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
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
yaml@2.8.2: {}
|
||||
|
||||
661
src/cli.ts
661
src/cli.ts
@ -1,32 +1,19 @@
|
||||
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";
|
||||
import { parsePipeline } from './parser.js';
|
||||
import { createDefaultRegistry } from './commands/registry.js';
|
||||
import { runPipeline } from './runtime.js';
|
||||
import { encodeToken } from './token.js';
|
||||
import { decodeResumeToken, parseResumeArgs } from './resume.js';
|
||||
import { runWorkflowFile } from './workflows/file.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());
|
||||
@ -42,27 +29,22 @@ 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] === "graph") {
|
||||
await handleGraph({ argv: argv.slice(1) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (argv[0] === "run") {
|
||||
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;
|
||||
}
|
||||
@ -71,72 +53,9 @@ 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;
|
||||
const { mode, rest, filePath, argsJson } = parseRunArgs(argv);
|
||||
const normalizedMode = normalizeMode(mode);
|
||||
const { rest, filePath, dryRun } = await resolveRunTarget(parsed);
|
||||
|
||||
const workflowFile = filePath
|
||||
? await resolveWorkflowFile(filePath)
|
||||
@ -147,15 +66,12 @@ 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;
|
||||
}
|
||||
@ -171,72 +87,37 @@ async function handleRun({ argv, registry }) {
|
||||
stderr: process.stderr,
|
||||
env: process.env,
|
||||
mode: normalizedMode,
|
||||
registry,
|
||||
dryRun,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.status === "needs_input") {
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: output.requiresInput ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
status: 'ok',
|
||||
output: output.output,
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
});
|
||||
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");
|
||||
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;
|
||||
}
|
||||
@ -246,17 +127,14 @@ 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;
|
||||
}
|
||||
@ -275,21 +153,40 @@ async function handleRun({ argv, registry }) {
|
||||
stderr: process.stderr,
|
||||
env: process.env,
|
||||
mode: normalizedMode,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
if (normalizedMode === "tool") {
|
||||
const finalized = await finalizePipelineToolRun({
|
||||
env: process.env,
|
||||
pipeline,
|
||||
output,
|
||||
});
|
||||
if (normalizedMode === 'tool') {
|
||||
const approval = output.halted && output.items.length === 1 && output.items[0]?.type === 'approval_request'
|
||||
? output.items[0]
|
||||
: null;
|
||||
|
||||
if (approval) {
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
pipeline,
|
||||
resumeAtIndex: (output.haltedAt?.index ?? -1) + 1,
|
||||
items: approval.items,
|
||||
prompt: approval.prompt,
|
||||
});
|
||||
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: 'needs_approval',
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
...approval,
|
||||
resumeToken,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: finalized.status,
|
||||
output: finalized.output,
|
||||
requiresApproval: finalized.requiresApproval,
|
||||
requiresInput: finalized.requiresInput,
|
||||
status: 'ok',
|
||||
output: output.items,
|
||||
requiresApproval: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -297,14 +194,11 @@ 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;
|
||||
}
|
||||
@ -315,24 +209,14 @@ 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;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
|
||||
// Treat --dry-run as a Lobster flag only before positional command/pipeline
|
||||
// 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) {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tok === "--mode") {
|
||||
if (tok === '--mode') {
|
||||
const value = argv[i + 1];
|
||||
if (value) {
|
||||
mode = value;
|
||||
@ -341,12 +225,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;
|
||||
@ -355,12 +239,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;
|
||||
@ -369,109 +253,25 @@ 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;
|
||||
}
|
||||
|
||||
rest.push(tok);
|
||||
}
|
||||
|
||||
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");
|
||||
if (restWithoutDryRun.length === 1 && restWithoutDryRun.length !== parsed.rest.length) {
|
||||
try {
|
||||
const workflowFile = await resolveWorkflowFile(restWithoutDryRun[0]);
|
||||
return { ...parsed, filePath: workflowFile, rest: [], dryRun: true };
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
return { mode, rest, filePath, argsJson };
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -480,79 +280,31 @@ 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";
|
||||
let approved: boolean | undefined;
|
||||
let response: unknown = undefined;
|
||||
let cancel = false;
|
||||
let payload: any;
|
||||
let resolvedApprovalId: string | null = null;
|
||||
try {
|
||||
const parsed = parseResumeArgs(argv);
|
||||
approved = parsed.approved;
|
||||
response = parsed.response;
|
||||
cancel = parsed.cancel === true;
|
||||
resolvedApprovalId = parsed.approvalId;
|
||||
const mode = 'tool';
|
||||
const { token, approved } = parseResumeArgs(argv);
|
||||
const payload = decodeResumeToken(token);
|
||||
|
||||
// Resolve short approval ID to token if provided
|
||||
let token: string;
|
||||
if (parsed.approvalId) {
|
||||
token = await resolveApprovalId(parsed.approvalId, process.env);
|
||||
} else {
|
||||
token = parsed.token!;
|
||||
}
|
||||
payload = decodeResumeToken(token);
|
||||
} catch (err) {
|
||||
writeToolEnvelope({
|
||||
ok: false,
|
||||
error: { type: "parse_error", message: err?.message ?? String(err) },
|
||||
});
|
||||
process.exitCode = 2;
|
||||
if (!approved) {
|
||||
writeToolEnvelope({ ok: true, status: 'cancelled', output: [], requiresApproval: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper: clean up approval ID index after successful use
|
||||
const cleanupIndex = async () => {
|
||||
if (resolvedApprovalId) {
|
||||
await deleteApprovalId({ env: process.env, approvalId: resolvedApprovalId });
|
||||
} else if (payload.stateKey) {
|
||||
await cleanupApprovalIndexByStateKey({ env: process.env, stateKey: payload.stateKey });
|
||||
}
|
||||
};
|
||||
|
||||
if (cancel === true) {
|
||||
await cleanupIndex();
|
||||
if (payload.kind === "workflow-file" && payload.stateKey) {
|
||||
await deleteStateJson({ env: process.env, key: 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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.kind === "workflow-file") {
|
||||
if (payload.kind === 'workflow-file') {
|
||||
try {
|
||||
const output = await runWorkflowFile({
|
||||
filePath: payload.filePath,
|
||||
@ -561,146 +313,35 @@ async function handleResume({ argv, registry }) {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: process.env,
|
||||
mode: "tool",
|
||||
registry,
|
||||
mode: 'tool',
|
||||
},
|
||||
resume: payload,
|
||||
approved,
|
||||
response,
|
||||
cancel,
|
||||
approved: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.status === "needs_input") {
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: output.requiresInput ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanupIndex();
|
||||
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 });
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowResumeArgumentError) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
const previousStateKey = payload.stateKey;
|
||||
let resumeState;
|
||||
try {
|
||||
resumeState = await loadPipelineResumeState(process.env, previousStateKey);
|
||||
} catch (err) {
|
||||
writeToolEnvelope({
|
||||
ok: false,
|
||||
error: { type: "runtime_error", message: err?.message ?? String(err) },
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (resumeState.haltType === "input_request") {
|
||||
if (approved !== undefined) {
|
||||
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>",
|
||||
},
|
||||
});
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
validatePipelineInputResponse(resumeState.inputSchema, response);
|
||||
} catch (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",
|
||||
},
|
||||
});
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
|
||||
const input = streamFromItems(
|
||||
resumeState.haltType === "input_request" ? [response] : resumeState.items,
|
||||
);
|
||||
const remaining = payload.pipeline.slice(payload.resumeAtIndex);
|
||||
const input = (async function* () {
|
||||
for (const item of payload.items) yield item;
|
||||
})();
|
||||
|
||||
try {
|
||||
const output = await runPipeline({
|
||||
@ -713,49 +354,50 @@ async function handleResume({ argv, registry }) {
|
||||
mode,
|
||||
input,
|
||||
});
|
||||
await cleanupIndex();
|
||||
const finalized = await finalizePipelineToolRun({
|
||||
env: process.env,
|
||||
pipeline: remaining,
|
||||
output,
|
||||
previousStateKey,
|
||||
});
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: finalized.status,
|
||||
output: finalized.output,
|
||||
requiresApproval: finalized.requiresApproval,
|
||||
requiresInput: finalized.requiresInput,
|
||||
});
|
||||
|
||||
const approval = output.halted && output.items.length === 1 && output.items[0]?.type === 'approval_request'
|
||||
? output.items[0]
|
||||
: null;
|
||||
|
||||
if (approval) {
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
pipeline: remaining,
|
||||
resumeAtIndex: (output.haltedAt?.index ?? -1) + 1,
|
||||
items: approval.items,
|
||||
prompt: approval.prompt,
|
||||
});
|
||||
|
||||
writeToolEnvelope({
|
||||
ok: true,
|
||||
status: 'needs_approval',
|
||||
output: [],
|
||||
requiresApproval: { ...approval, resumeToken },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
writeToolEnvelope({ ok: true, status: 'ok', output: output.items, requiresApproval: null });
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
function streamFromItems(items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
})();
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -778,7 +420,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;
|
||||
@ -786,17 +428,14 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -806,30 +445,20 @@ 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 — Clawdbot-native typed shell\n\n` +
|
||||
`Usage:\n` +
|
||||
` lobster '<pipeline>'\n` +
|
||||
` lobster run --mode tool '<pipeline>'\n` +
|
||||
` lobster run path/to/workflow.lobster\n` +
|
||||
` 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` +
|
||||
` lobster doctor\n` +
|
||||
` lobster version\n` +
|
||||
` lobster help <command>\n\n` +
|
||||
`Flags:\n` +
|
||||
` --dry-run Validate and print the execution plan without running anything\n\n` +
|
||||
`Modes:\n` +
|
||||
` - human (default): renderers can write to stdout\n` +
|
||||
` - tool: prints a single JSON envelope for easy integration\n\n` +
|
||||
@ -837,19 +466,5 @@ 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, 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`
|
||||
);
|
||||
` exec, head, json, pick, table, where, approve, clawd.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`;
|
||||
}
|
||||
|
||||
@ -1,28 +1,26 @@
|
||||
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` +
|
||||
`Usage:\n` +
|
||||
` commands.list\n\n` +
|
||||
`Notes:\n` +
|
||||
` - Intended for agents (e.g. OpenClaw) to discover available pipeline stages dynamically.\n` +
|
||||
` - Intended for agents (e.g. Clawdbot) to discover available pipeline stages dynamically.\n` +
|
||||
` - Output includes name/description plus optional metadata (argsSchema/examples/sideEffects) when provided by commands.\n`
|
||||
);
|
||||
},
|
||||
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 }) {
|
||||
@ -34,7 +32,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 {
|
||||
|
||||
@ -10,9 +10,7 @@ import { templateCommand } from "./stdlib/template.js";
|
||||
import { mapCommand } from "./stdlib/map.js";
|
||||
import { groupByCommand } from "./stdlib/group_by.js";
|
||||
import { approveCommand } from "./stdlib/approve.js";
|
||||
import { askCommand } from "./stdlib/ask.js";
|
||||
import { clawdInvokeCommand, openclawInvokeCommand } from "./stdlib/openclaw_invoke.js";
|
||||
import { llmInvokeCommand } from "./stdlib/llm_invoke.js";
|
||||
import { clawdInvokeCommand } from "./stdlib/clawd_invoke.js";
|
||||
import { llmTaskInvokeCommand } from "./stdlib/llm_task_invoke.js";
|
||||
import { stateGetCommand, stateSetCommand } from "./stdlib/state.js";
|
||||
import { diffLastCommand } from "./stdlib/diff_last.js";
|
||||
@ -39,10 +37,7 @@ export function createDefaultRegistry() {
|
||||
mapCommand,
|
||||
groupByCommand,
|
||||
approveCommand,
|
||||
askCommand,
|
||||
openclawInvokeCommand,
|
||||
clawdInvokeCommand,
|
||||
llmInvokeCommand,
|
||||
llmTaskInvokeCommand,
|
||||
stateGetCommand,
|
||||
stateSetCommand,
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
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 +21,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 +39,7 @@ export const approveCommand = {
|
||||
halt: true,
|
||||
output: (async function* () {
|
||||
yield {
|
||||
type: "approval_request",
|
||||
type: 'approval_request',
|
||||
prompt,
|
||||
items,
|
||||
...(preview ? { preview } : null),
|
||||
@ -51,12 +49,10 @@ export const approveCommand = {
|
||||
}
|
||||
|
||||
ctx.stdout.write(`${prompt} [y/N] `);
|
||||
const answer = await readLineFromStream(ctx.stdin, {
|
||||
timeoutMs: parseApprovalTimeoutMs(ctx.env),
|
||||
});
|
||||
const answer = await readLine(ctx.stdin);
|
||||
|
||||
if (!/^y(es)?$/i.test(String(answer).trim())) {
|
||||
throw new Error("Not approved");
|
||||
throw new Error('Not approved');
|
||||
}
|
||||
|
||||
return { output: asStream(items) };
|
||||
@ -64,18 +60,28 @@ 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);
|
||||
}
|
||||
|
||||
function parseApprovalTimeoutMs(env) {
|
||||
const raw = env?.LOBSTER_APPROVAL_INPUT_TIMEOUT_MS;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return Math.floor(value);
|
||||
function readLine(stdin) {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
|
||||
const onData = (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
const idx = buf.indexOf('\n');
|
||||
if (idx !== -1) {
|
||||
stdin.off('data', onData);
|
||||
resolve(buf.slice(0, idx));
|
||||
}
|
||||
};
|
||||
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function* asStream(items) {
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
import { sharedAjv } from "../../validation.js";
|
||||
|
||||
function isInteractive(stdin) {
|
||||
return Boolean(stdin.isTTY);
|
||||
}
|
||||
|
||||
function compileAskValidator(schema) {
|
||||
try {
|
||||
return sharedAjv.compile(schema);
|
||||
} catch {
|
||||
throw new Error("ask response schema is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
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}` : "";
|
||||
throw new Error(`ask response failed schema validation at ${pathValue}:${reason}`);
|
||||
}
|
||||
|
||||
function parseInteractiveCandidates(text) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return [text, { decision: text }];
|
||||
}
|
||||
if (typeof parsed === "string") {
|
||||
return [parsed, { decision: parsed }];
|
||||
}
|
||||
return [parsed];
|
||||
}
|
||||
|
||||
export const askCommand = {
|
||||
name: "ask",
|
||||
meta: {
|
||||
description: "Pause and request structured input from the user",
|
||||
argsSchema: {
|
||||
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" } },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: [],
|
||||
},
|
||||
help() {
|
||||
return [
|
||||
"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");
|
||||
},
|
||||
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 items = [];
|
||||
for await (const item of input) items.push(item);
|
||||
|
||||
const defaultSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
decision: { type: "string", enum: ["approve", "reject", "redraft"] },
|
||||
feedback: { type: "string", description: "Feedback for redraft" },
|
||||
},
|
||||
required: ["decision"],
|
||||
};
|
||||
|
||||
let responseSchema = defaultSchema;
|
||||
if (schemaRaw) {
|
||||
let parsedSchema;
|
||||
try {
|
||||
parsedSchema = JSON.parse(schemaRaw);
|
||||
} catch {
|
||||
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");
|
||||
}
|
||||
responseSchema = parsedSchema;
|
||||
}
|
||||
const responseValidator = compileAskValidator(responseSchema);
|
||||
|
||||
let subject;
|
||||
if (subjectFromStdin && items.length > 0) {
|
||||
const preview = items
|
||||
.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);
|
||||
if (emit) {
|
||||
return {
|
||||
halt: true,
|
||||
output: (async function* () {
|
||||
yield {
|
||||
type: "input_request",
|
||||
prompt,
|
||||
responseSchema,
|
||||
...(subject ? { subject } : null),
|
||||
items,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.stdout.write(`${prompt}\n> `);
|
||||
const { readLineFromStream } = await import("../../read_line.js");
|
||||
const raw = await readLineFromStream(ctx.stdin, { timeoutMs: 0 });
|
||||
const text = String(raw ?? "").trim();
|
||||
|
||||
let lastError;
|
||||
for (const candidate of parseInteractiveCandidates(text)) {
|
||||
try {
|
||||
validateAskResponse(responseValidator, candidate);
|
||||
return {
|
||||
output: (async function* () {
|
||||
yield candidate;
|
||||
})(),
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("ask response failed schema validation");
|
||||
},
|
||||
};
|
||||
131
src/commands/stdlib/clawd_invoke.ts
Normal file
131
src/commands/stdlib/clawd_invoke.ts
Normal file
@ -0,0 +1,131 @@
|
||||
export const clawdInvokeCommand = {
|
||||
name: 'clawd.invoke',
|
||||
meta: {
|
||||
description: 'Call a local Clawdbot tool endpoint',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Clawdbot control URL (or CLAWD_URL)' },
|
||||
token: { type: 'string', description: 'Bearer token (or 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' },
|
||||
_: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['tool', 'action'],
|
||||
},
|
||||
sideEffects: ['calls_clawd_tool'],
|
||||
},
|
||||
help() {
|
||||
return `clawd.invoke — call a local Clawdbot tool endpoint\n\n` +
|
||||
`Usage:\n` +
|
||||
` clawd.invoke --tool message --action send --args-json '{"provider":"telegram","to":"...","message":"..."}'\n` +
|
||||
` clawd.invoke --tool message --action send --args-json '{...}' --dry-run\n` +
|
||||
` ... | clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'\n\n` +
|
||||
`Config:\n` +
|
||||
` - Uses CLAWD_URL env var by default (or pass --url).\n` +
|
||||
` - Optional Bearer token via CLAWD_TOKEN env var (or pass --token).\n` +
|
||||
` - Optional attribution via --session-key <sessionKey>.\n\n` +
|
||||
`Notes:\n` +
|
||||
` - This is a thin transport bridge. Lobster should not own OAuth/secrets.\n`;
|
||||
},
|
||||
async run({ input, args, ctx }) {
|
||||
const each = Boolean(args.each);
|
||||
const itemKey = String(args.itemKey ?? args['item-key'] ?? 'item');
|
||||
|
||||
const url = String(args.url ?? ctx.env.CLAWD_URL ?? '').trim();
|
||||
if (!url) throw new Error('clawd.invoke requires --url or CLAWD_URL');
|
||||
|
||||
const tool = args.tool;
|
||||
const action = args.action;
|
||||
if (!tool || !action) throw new Error('clawd.invoke requires --tool and --action');
|
||||
|
||||
const token = String(args.token ?? ctx.env.CLAWD_TOKEN ?? '').trim();
|
||||
|
||||
let toolArgs = {};
|
||||
if (args['args-json']) {
|
||||
try {
|
||||
toolArgs = JSON.parse(String(args['args-json']));
|
||||
} catch (_err) {
|
||||
throw new Error('clawd.invoke --args-json must be valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
if (each && (toolArgs === null || typeof toolArgs !== 'object' || Array.isArray(toolArgs))) {
|
||||
throw new Error('clawd.invoke --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 invokeOnce = async (argsValue) => {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : null),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: String(tool),
|
||||
action: String(action),
|
||||
args: argsValue,
|
||||
...(sessionKey ? { sessionKey: String(sessionKey) } : null),
|
||||
...(dryRun !== null ? { dryRun: Boolean(dryRun) } : null),
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`clawd.invoke failed (${res.status}): ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch (_err) {
|
||||
throw new Error('clawd.invoke expected JSON response');
|
||||
}
|
||||
|
||||
// Preferred: { ok: true, result: ... }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
|
||||
if (parsed.ok !== true) {
|
||||
const msg = parsed?.error?.message ?? 'Unknown error';
|
||||
throw new Error(`clawd.invoke tool error: ${msg}`);
|
||||
}
|
||||
const result = parsed.result;
|
||||
return Array.isArray(result) ? result : [result];
|
||||
}
|
||||
|
||||
// Compatibility: raw JSON result
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
};
|
||||
|
||||
if (!each) {
|
||||
// Drain input: for now we don't stream input into clawd calls.
|
||||
for await (const _item of input) {
|
||||
// no-op
|
||||
}
|
||||
const items = await invokeOnce(toolArgs);
|
||||
return { output: asStream(items) };
|
||||
}
|
||||
|
||||
return {
|
||||
output: (async function* () {
|
||||
for await (const item of input) {
|
||||
const argsValue = { ...toolArgs, [itemKey]: item };
|
||||
const items = await invokeOnce(argsValue);
|
||||
for (const outputItem of items) yield outputItem;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function* asStream(items) {
|
||||
for (const item of items) yield item;
|
||||
}
|
||||
@ -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,17 +10,14 @@ 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: [],
|
||||
},
|
||||
@ -37,7 +34,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,19 +8,17 @@ 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 {
|
||||
@ -37,10 +35,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')
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,16 +47,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;
|
||||
@ -80,7 +78,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 {
|
||||
@ -92,9 +90,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;
|
||||
}
|
||||
@ -117,7 +115,7 @@ function buildDeterministicReport(emails: NormalizedEmail[]): EmailTriageReport
|
||||
fyi: buckets.fyi.map((x) => x.id),
|
||||
},
|
||||
emails,
|
||||
mode: "deterministic",
|
||||
mode: 'deterministic',
|
||||
};
|
||||
}
|
||||
|
||||
@ -147,66 +145,52 @@ 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_task.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_task.invoke for categorization + draft replies (requires LLM_TASK_URL)' },
|
||||
model: { type: 'string', description: 'Model for llm_task.invoke (required when --llm true)' },
|
||||
url: { type: 'string', description: 'llm-task base URL (or LLM_TASK_URL)' },
|
||||
token: { type: 'string', description: 'Bearer token (or LLM_TASK_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_task.invoke' },
|
||||
_: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
@ -223,12 +207,12 @@ export const emailTriageCommand = {
|
||||
` ... | email.triage --llm --model <model> --emit drafts | approve --prompt 'Send replies?' | gog.gmail.send\n\n` +
|
||||
`Notes:\n` +
|
||||
` - Read-only by default: does not send anything.\n` +
|
||||
` - LLM mode uses llm.invoke (and its cache/resume semantics).\n`
|
||||
` - LLM mode uses llm_task.invoke (and its cache/resume semantics).\n`
|
||||
);
|
||||
},
|
||||
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) {
|
||||
@ -238,26 +222,22 @@ 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(),
|
||||
);
|
||||
const hasClawdUrl = Boolean(String(env.CLAWD_URL ?? '').trim());
|
||||
|
||||
if (!wantLlm || !hasLlmProvider) {
|
||||
if (!wantLlm || !hasClawdUrl) {
|
||||
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();
|
||||
// Model is optional when running under Clawdbot (llm_task.invoke will use Clawdbot defaults).
|
||||
|
||||
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_task.invoke');
|
||||
if (!llmCmd) throw new Error('email.triage requires llm_task.invoke to be registered');
|
||||
|
||||
const llmRes = await llmCmd.run({
|
||||
input: streamOf(emails),
|
||||
@ -267,11 +247,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);
|
||||
@ -281,17 +261,12 @@ 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 = {
|
||||
@ -303,18 +278,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),
|
||||
});
|
||||
}
|
||||
@ -323,7 +298,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) {
|
||||
@ -340,7 +315,7 @@ export const emailTriageCommand = {
|
||||
emails,
|
||||
decisions,
|
||||
drafts,
|
||||
mode: "llm",
|
||||
mode: 'llm',
|
||||
};
|
||||
|
||||
return { output: streamOf([report]) };
|
||||
|
||||
@ -1,24 +1,22 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveInlineShellCommand } from "../../shell.js";
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
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 /bin/sh -lc 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` +
|
||||
@ -27,18 +25,16 @@ 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 /bin/sh -lc.\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) {
|
||||
@ -53,27 +49,15 @@ 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 runProcess('/bin/sh', ['-lc', shellLine ?? cmd[0] ?? ''], { env: ctx.env, cwd: process.cwd(), stdin: stdinPayload })
|
||||
: await runProcess(cmd[0], cmd.slice(1), { env: ctx.env, cwd: process.cwd(), stdin: stdinPayload });
|
||||
|
||||
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 {
|
||||
@ -86,54 +70,44 @@ export const execCommand = {
|
||||
},
|
||||
};
|
||||
|
||||
function runProcess(command, argv, { env, cwd, stdin, signal }) {
|
||||
function runProcess(command, argv, { env, cwd, stdin }) {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const child = spawn(command, argv, {
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runShellLine(commandLine, { env, cwd, stdin, signal }) {
|
||||
const shell = resolveInlineShellCommand({ command: commandLine, env });
|
||||
return runProcess(shell.command, shell.argv, { 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})`);
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ function run(cmd: string, argv: string[], env: Record<string, string | undefined
|
||||
|
||||
child.on("error", (err: any) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gogcli)"));
|
||||
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gog)"));
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
@ -41,7 +41,7 @@ export const gogGmailSearchCommand = {
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: ["reads_email"],
|
||||
sideEffects: ['reads_email'],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
@ -49,7 +49,7 @@ export const gogGmailSearchCommand = {
|
||||
`Usage:\n` +
|
||||
` gog.gmail.search --query 'newer_than:1d' --max 20\n\n` +
|
||||
`Notes:\n` +
|
||||
` - Requires the gog CLI: https://github.com/steipete/gogcli\n` +
|
||||
` - Requires the gog CLI: https://github.com/steipete/gog\n` +
|
||||
` - Set GOG_BIN to override the executable used (default: gog).\n` +
|
||||
` - This command outputs an array of message objects (as a stream).\n`
|
||||
);
|
||||
|
||||
@ -15,7 +15,7 @@ function run(cmd: string, argv: string[], env: Record<string, string | undefined
|
||||
|
||||
child.on("error", (err: any) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gogcli)"));
|
||||
reject(new Error("gog not found on PATH (install: https://github.com/steipete/gog)"));
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
@ -57,7 +57,7 @@ export const gogGmailSendCommand = {
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: ["sends_email"],
|
||||
sideEffects: ['sends_email'],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
@ -67,7 +67,7 @@ export const gogGmailSendCommand = {
|
||||
`Input:\n` +
|
||||
` Stream of draft objects: { to, subject, body }\n\n` +
|
||||
`Notes:\n` +
|
||||
` - Requires the gog CLI: https://github.com/steipete/gogcli\n` +
|
||||
` - Requires the gog CLI: https://github.com/steipete/gog\n` +
|
||||
` - Set GOG_BIN to override the executable used (default: gog).\n`
|
||||
);
|
||||
},
|
||||
|
||||
@ -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,938 +0,0 @@
|
||||
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";
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
|
||||
const artifactSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string" },
|
||||
role: { type: "string" },
|
||||
name: { type: "string" },
|
||||
mimeType: { type: "string" },
|
||||
text: { type: "string" },
|
||||
data: {},
|
||||
uri: { type: "string" },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const payloadSchema = {
|
||||
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" },
|
||||
retryContext: {
|
||||
type: "object",
|
||||
properties: {
|
||||
attempt: { type: "number" },
|
||||
validationErrors: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["prompt", "artifacts", "artifactHashes"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const responseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
result: {
|
||||
type: "object",
|
||||
properties: {
|
||||
runId: { type: "string" },
|
||||
model: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
status: { type: "string" },
|
||||
output: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
data: {},
|
||||
format: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
usage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
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 },
|
||||
},
|
||||
required: ["output"],
|
||||
additionalProperties: true,
|
||||
},
|
||||
error: { type: "object", additionalProperties: true },
|
||||
},
|
||||
required: ["ok"],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const validatePayload = ajv.compile(payloadSchema);
|
||||
const validateResponseEnvelope = ajv.compile(responseSchema);
|
||||
|
||||
const DEFAULT_MAX_VALIDATION_RETRIES = 1;
|
||||
const STATE_VERSION = 1;
|
||||
|
||||
type BuiltInProvider = "openclaw" | "pi" | "http";
|
||||
type SupportedProvider = BuiltInProvider | string;
|
||||
|
||||
type LlmResponseEnvelope = {
|
||||
ok: boolean;
|
||||
result?: LlmResponse | null;
|
||||
error?: { message?: string } | null;
|
||||
};
|
||||
|
||||
type LlmResponse = {
|
||||
runId?: string | null;
|
||||
model?: string | null;
|
||||
prompt?: string | null;
|
||||
status?: string | null;
|
||||
output?: {
|
||||
text?: string | null;
|
||||
data?: any;
|
||||
format?: string | null;
|
||||
} | null;
|
||||
usage?: Record<string, unknown> | null;
|
||||
warnings?: string[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
diagnostics?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type NormalizedInvocationItem = {
|
||||
kind: string;
|
||||
runId: string | null;
|
||||
prompt: string | null;
|
||||
model: string | null;
|
||||
schemaVersion: string | null;
|
||||
status: string;
|
||||
cacheKey: string;
|
||||
artifactHashes: string[];
|
||||
output: { format: string | null; text: string | null; data: any };
|
||||
usage: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
warnings: string[] | null;
|
||||
diagnostics: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
source: string;
|
||||
cached: boolean;
|
||||
attemptCount: number;
|
||||
};
|
||||
|
||||
type CacheEntry = {
|
||||
items: NormalizedInvocationItem[];
|
||||
cacheKey: string;
|
||||
storedAt: string;
|
||||
};
|
||||
|
||||
type CommandConfig = {
|
||||
name: string;
|
||||
itemKind: string;
|
||||
stateType: string;
|
||||
cacheNamespace: string;
|
||||
defaultProvider?: SupportedProvider | null;
|
||||
description: string;
|
||||
helpTitle: string;
|
||||
helpConfig: string[];
|
||||
helpExamples: string[];
|
||||
sourceForProvider?: (provider: SupportedProvider) => string;
|
||||
legacyEnvCompat?: boolean;
|
||||
};
|
||||
|
||||
type Adapter = {
|
||||
provider: SupportedProvider;
|
||||
source: string;
|
||||
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>)
|
||||
| {
|
||||
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",
|
||||
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",
|
||||
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.",
|
||||
],
|
||||
helpExamples: [
|
||||
"llm.invoke --prompt 'Write summary'",
|
||||
"llm.invoke --provider openclaw --model claude-3-sonnet --prompt 'Write summary'",
|
||||
"cat artifacts.json | llm.invoke --provider pi --prompt 'Score each item'",
|
||||
"... | llm.invoke --prompt 'Plan next steps' --output-schema '{\"type\":\"object\"}'",
|
||||
],
|
||||
sourceForProvider(provider) {
|
||||
return provider;
|
||||
},
|
||||
legacyEnvCompat: true,
|
||||
});
|
||||
|
||||
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",
|
||||
helpConfig: [
|
||||
"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'",
|
||||
"llm_task.invoke --model claude-3-sonnet --prompt 'Write summary'",
|
||||
"cat artifacts.json | llm_task.invoke --prompt 'Score each item'",
|
||||
],
|
||||
sourceForProvider() {
|
||||
return "clawd";
|
||||
},
|
||||
legacyEnvCompat: true,
|
||||
});
|
||||
|
||||
export function createLlmInvokeCommand(config: CommandConfig): LobsterCommand {
|
||||
return {
|
||||
name: config.name,
|
||||
meta: {
|
||||
description: config.description,
|
||||
argsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
provider: {
|
||||
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.",
|
||||
},
|
||||
prompt: { type: "string", description: "Primary prompt / instructions" },
|
||||
model: {
|
||||
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" } },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: ["calls_llm"],
|
||||
},
|
||||
help() {
|
||||
const lines = [
|
||||
config.helpTitle,
|
||||
"",
|
||||
"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:",
|
||||
...config.helpConfig.map((line) => ` - ${line}`),
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
},
|
||||
async run({ input, args, ctx }) {
|
||||
return runLlmInvoke({ input, args, ctx, config });
|
||||
},
|
||||
} satisfies LobsterCommand;
|
||||
}
|
||||
|
||||
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 });
|
||||
const prompt = extractPrompt(args);
|
||||
if (!prompt) throw new Error(`${config.name} requires --prompt or positional text`);
|
||||
|
||||
const model = resolveModel(args, env, config.legacyEnvCompat);
|
||||
const schemaVersion = resolveEnvString(
|
||||
args["schema-version"],
|
||||
["LOBSTER_LLM_SCHEMA_VERSION", ...(config.legacyEnvCompat ? ["LLM_TASK_SCHEMA_VERSION"] : [])],
|
||||
env,
|
||||
"v1",
|
||||
);
|
||||
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 maxValidationRetriesRaw =
|
||||
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,
|
||||
)
|
||||
: 0;
|
||||
const disableCache = flag(args["disable-cache"]);
|
||||
const forceRefresh = flag(
|
||||
args.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 inputArtifacts: any[] = [];
|
||||
for await (const item of input) inputArtifacts.push(item);
|
||||
|
||||
const normalizedArtifacts = [...inputArtifacts, ...providedArtifacts].map(normalizeArtifact);
|
||||
const artifactHashes = normalizedArtifacts.map(hashArtifact);
|
||||
const cacheKey = computeCacheKey({
|
||||
provider,
|
||||
prompt,
|
||||
model,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
outputSchema: userOutputSchema,
|
||||
});
|
||||
|
||||
if (stateKey && !forceRefresh) {
|
||||
const stored = await readStateJson({ env, key: stateKey }).catch(() => null);
|
||||
const reused = pickReusableState(stored, cacheKey, config.stateType);
|
||||
if (reused) {
|
||||
return {
|
||||
output: streamOf(
|
||||
reused.items.map((item) => ({ ...item, source: "run_state", cached: true })),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!disableCache && !forceRefresh) {
|
||||
const cache = await readCacheEntry(env, cacheKey, config.cacheNamespace);
|
||||
if (cache) {
|
||||
return {
|
||||
output: streamOf(cache.items.map((item) => ({ ...item, source: "cache", cached: true }))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
prompt,
|
||||
...(model ? { model } : null),
|
||||
artifacts: normalizedArtifacts,
|
||||
artifactHashes,
|
||||
};
|
||||
if (metadataObject) payload.metadata = metadataObject;
|
||||
if (userOutputSchema) payload.outputSchema = userOutputSchema;
|
||||
if (schemaVersion) payload.schemaVersion = schemaVersion;
|
||||
if (Number.isFinite(maxOutputTokens ?? NaN)) payload.maxOutputTokens = Number(maxOutputTokens);
|
||||
if (Number.isFinite(temperature ?? NaN)) payload.temperature = Number(temperature);
|
||||
|
||||
if (!validatePayload(payload)) {
|
||||
throw new Error(`${config.name} payload invalid: ${ajv.errorsText(validatePayload.errors)}`);
|
||||
}
|
||||
|
||||
const validator = userOutputSchema ? ajv.compile(userOutputSchema) : null;
|
||||
let attempt = 0;
|
||||
let lastValidationErrors: string[] = [];
|
||||
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
if (attempt > 1) {
|
||||
payload.retryContext = {
|
||||
attempt,
|
||||
...(lastValidationErrors.length ? { validationErrors: lastValidationErrors } : null),
|
||||
};
|
||||
} else {
|
||||
delete payload.retryContext;
|
||||
}
|
||||
|
||||
let responseEnvelope: LlmResponseEnvelope;
|
||||
try {
|
||||
responseEnvelope = await adapter.invoke({ env, args, payload });
|
||||
} catch (err: any) {
|
||||
throw new Error(`${config.name} request failed: ${err?.message ?? String(err)}`);
|
||||
}
|
||||
|
||||
if (!validateResponseEnvelope(responseEnvelope)) {
|
||||
throw new Error(`${config.name} received invalid response envelope`);
|
||||
}
|
||||
|
||||
if (responseEnvelope.ok !== true) {
|
||||
const message = responseEnvelope.error?.message ?? "llm adapter returned an error";
|
||||
throw new Error(`${config.name} remote error: ${message}`);
|
||||
}
|
||||
|
||||
const normalized = normalizeResult({
|
||||
envelope: responseEnvelope,
|
||||
cacheKey,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
source: adapter.source,
|
||||
attempt,
|
||||
itemKind: config.itemKind,
|
||||
});
|
||||
|
||||
if (!validator) {
|
||||
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,
|
||||
});
|
||||
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("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
return explicit;
|
||||
}
|
||||
if (getDirectAdapter(ctx, explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
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))
|
||||
: [];
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAdapter({
|
||||
provider,
|
||||
env,
|
||||
args,
|
||||
config,
|
||||
ctx,
|
||||
}: {
|
||||
provider: SupportedProvider;
|
||||
env: any;
|
||||
args: any;
|
||||
config: CommandConfig;
|
||||
ctx: any;
|
||||
}): Adapter {
|
||||
const direct = getDirectAdapter(ctx, provider);
|
||||
if (direct) {
|
||||
const invoke = typeof direct === "function" ? direct : direct.invoke;
|
||||
return {
|
||||
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 (!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();
|
||||
return {
|
||||
provider,
|
||||
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 (!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();
|
||||
return {
|
||||
provider,
|
||||
source: config.sourceForProvider?.(provider) ?? "pi",
|
||||
async invoke({ payload }) {
|
||||
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
return {
|
||||
provider,
|
||||
source: config.sourceForProvider?.(provider) ?? "http",
|
||||
async invoke({ payload }) {
|
||||
return invokeHttpAdapter({ endpoint: buildAdapterEndpoint(adapterUrl), token, payload });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getDirectAdapter(ctx: any, provider: string): DirectAdapter | null {
|
||||
const adapters = ctx?.llmAdapters;
|
||||
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") {
|
||||
return adapter as DirectAdapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAdapterEndpoint(rawUrl: string) {
|
||||
const endpoint = new URL(rawUrl);
|
||||
if (endpoint.pathname === "/" || endpoint.pathname === "") {
|
||||
endpoint.pathname = "/invoke";
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
async function invokeOpenClawAdapter({
|
||||
endpoint,
|
||||
token,
|
||||
payload,
|
||||
}: {
|
||||
endpoint: URL;
|
||||
token: string;
|
||||
payload: any;
|
||||
}) {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : null),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: "llm-task",
|
||||
action: "invoke",
|
||||
args: payload,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error("Response was not JSON");
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
|
||||
if (parsed.ok !== true) {
|
||||
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) {
|
||||
return inner as LlmResponseEnvelope;
|
||||
}
|
||||
return { ok: true, result: inner } as LlmResponseEnvelope;
|
||||
}
|
||||
|
||||
return { ok: true, result: parsed } as LlmResponseEnvelope;
|
||||
}
|
||||
|
||||
async function invokeHttpAdapter({
|
||||
endpoint,
|
||||
token,
|
||||
payload,
|
||||
}: {
|
||||
endpoint: URL;
|
||||
token: string;
|
||||
payload: any;
|
||||
}) {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : null),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error("Response was not JSON");
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
|
||||
return parsed as LlmResponseEnvelope;
|
||||
}
|
||||
return { ok: true, result: parsed } as LlmResponseEnvelope;
|
||||
}
|
||||
|
||||
function resolveModel(args: any, env: any, legacyEnvCompat: boolean | undefined) {
|
||||
return resolveEnvString(
|
||||
args.model,
|
||||
["LOBSTER_LLM_MODEL", ...(legacyEnvCompat ? ["LLM_TASK_MODEL"] : [])],
|
||||
env,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEnvString(raw: any, envKeys: string[], env: any, fallback: string) {
|
||||
if (raw !== undefined && raw !== null && String(raw).trim()) return String(raw).trim();
|
||||
const fromEnv = getFirstEnv(env, envKeys);
|
||||
if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getFirstEnv(env: any, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
if (env?.[key] !== undefined && env?.[key] !== null && String(env[key]).trim()) {
|
||||
return env[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPrompt(args: any) {
|
||||
if (args.prompt) return String(args.prompt);
|
||||
if (Array.isArray(args._) && args._.length) {
|
||||
return args._.join(" ");
|
||||
}
|
||||
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");
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} must be a JSON array`);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: any) {
|
||||
if (value === undefined || value === null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function flag(value: any) {
|
||||
if (value === undefined || value === null) return false;
|
||||
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;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeArtifact(raw: any) {
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return { kind: "text", text: raw };
|
||||
}
|
||||
if (typeof raw === "number" || typeof raw === "boolean") {
|
||||
return { kind: "text", text: String(raw) };
|
||||
}
|
||||
return { kind: "json", data: raw };
|
||||
}
|
||||
|
||||
function hashArtifact(artifact: any) {
|
||||
const stable = stableStringify(artifact);
|
||||
return createHash("sha256").update(stable).digest("hex");
|
||||
}
|
||||
|
||||
function computeCacheKey({
|
||||
provider,
|
||||
prompt,
|
||||
model,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
outputSchema,
|
||||
}: {
|
||||
provider: SupportedProvider;
|
||||
prompt: string;
|
||||
model: string;
|
||||
schemaVersion: string;
|
||||
artifactHashes: string[];
|
||||
outputSchema: any;
|
||||
}) {
|
||||
const payload = {
|
||||
provider,
|
||||
prompt,
|
||||
model: model || `${provider}-default`,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
outputSchema: outputSchema ?? null,
|
||||
};
|
||||
return createHash("sha256").update(stableStringify(payload)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeResult({
|
||||
envelope,
|
||||
cacheKey,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
source,
|
||||
attempt,
|
||||
itemKind,
|
||||
}: {
|
||||
envelope: LlmResponseEnvelope;
|
||||
cacheKey: string;
|
||||
schemaVersion: string;
|
||||
artifactHashes: string[];
|
||||
source: string;
|
||||
attempt: number;
|
||||
itemKind: string;
|
||||
}): NormalizedInvocationItem[] {
|
||||
const result = envelope.result ?? {};
|
||||
const output = result.output ?? {};
|
||||
const item: NormalizedInvocationItem = {
|
||||
kind: itemKind,
|
||||
runId: (result.runId ?? null) as any,
|
||||
prompt: (result.prompt ?? null) as any,
|
||||
model: (result.model ?? null) as any,
|
||||
schemaVersion,
|
||||
status: String(result.status ?? "completed"),
|
||||
cacheKey,
|
||||
artifactHashes,
|
||||
output: {
|
||||
format: (output.format ?? (output.data ? "json" : "text")) as any,
|
||||
text: (output.text ?? null) as any,
|
||||
data: (output.data ?? null) as any,
|
||||
},
|
||||
usage: (result.usage ?? null) as any,
|
||||
metadata: (result.metadata ?? null) as any,
|
||||
warnings: (result.warnings ?? null) as any,
|
||||
diagnostics: (result.diagnostics ?? null) as any,
|
||||
createdAt: new Date().toISOString(),
|
||||
source,
|
||||
cached:
|
||||
source !== "remote" &&
|
||||
source !== "openclaw" &&
|
||||
source !== "clawd" &&
|
||||
source !== "pi" &&
|
||||
source !== "http",
|
||||
attemptCount: attempt,
|
||||
};
|
||||
return [item];
|
||||
}
|
||||
|
||||
async function persistOutputs({
|
||||
env,
|
||||
stateKey,
|
||||
cacheKey,
|
||||
items,
|
||||
stateType,
|
||||
}: {
|
||||
env: any;
|
||||
stateKey: string | null;
|
||||
cacheKey: string;
|
||||
items: NormalizedInvocationItem[];
|
||||
stateType: string;
|
||||
}) {
|
||||
if (!stateKey) return;
|
||||
const record = {
|
||||
type: stateType,
|
||||
version: STATE_VERSION,
|
||||
cacheKey,
|
||||
items,
|
||||
storedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeStateJson({ env, key: stateKey, value: record });
|
||||
}
|
||||
|
||||
function pickReusableState(stored: any, cacheKey: string, stateType: string) {
|
||||
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;
|
||||
return { items: stored.items as NormalizedInvocationItem[] };
|
||||
}
|
||||
|
||||
function collectAjvErrors(errors: ErrorObject[] | null | undefined) {
|
||||
if (!errors?.length) return [];
|
||||
return errors.map((err) => `${err.instancePath || "/"} ${err.message ?? ""}`.trim());
|
||||
}
|
||||
|
||||
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");
|
||||
return JSON.parse(text) as CacheEntry;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
await fsp.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ items, cacheKey: key, storedAt: new Date().toISOString() }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function getCacheDir(env: any) {
|
||||
if (env?.LOBSTER_CACHE_DIR) return String(env.LOBSTER_CACHE_DIR);
|
||||
return path.join(process.cwd(), ".lobster-cache");
|
||||
}
|
||||
|
||||
async function* streamOf(items: any[]) {
|
||||
for (const item of items) yield item;
|
||||
}
|
||||
@ -1 +1,595 @@
|
||||
export { llmTaskInvokeCommand } from "./llm_invoke.js";
|
||||
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';
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
|
||||
const artifactSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string' },
|
||||
role: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
mimeType: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
data: {},
|
||||
uri: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const payloadSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prompt: { type: 'string', minLength: 1 },
|
||||
// In direct mode, the remote likely requires model; in CLAWD mode, Clawdbot can default.
|
||||
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',
|
||||
properties: {
|
||||
attempt: { type: 'number' },
|
||||
validationErrors: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['prompt', 'artifacts', 'artifactHashes'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const responseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
result: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
data: {},
|
||||
format: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
usage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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 },
|
||||
},
|
||||
required: ['output'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
error: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['ok'],
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
const validatePayload = ajv.compile(payloadSchema);
|
||||
const validateResponseEnvelope = ajv.compile(responseSchema);
|
||||
|
||||
const DEFAULT_MAX_VALIDATION_RETRIES = 1;
|
||||
|
||||
const STATE_VERSION = 1;
|
||||
|
||||
type LlmTaskResponseEnvelope = {
|
||||
ok: boolean;
|
||||
result?: LlmTaskResponse | null;
|
||||
error?: { message?: string } | null;
|
||||
};
|
||||
|
||||
type LlmTaskResponse = {
|
||||
runId?: string | null;
|
||||
model?: string | null;
|
||||
prompt?: string | null;
|
||||
status?: string | null;
|
||||
output?: {
|
||||
text?: string | null;
|
||||
data?: any;
|
||||
format?: string | null;
|
||||
} | null;
|
||||
usage?: Record<string, unknown> | null;
|
||||
warnings?: string[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
diagnostics?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type NormalizedInvocationItem = {
|
||||
kind: 'llm_task.invoke';
|
||||
runId: string | null;
|
||||
prompt: string | null;
|
||||
model: string | null;
|
||||
schemaVersion: string | null;
|
||||
status: string;
|
||||
cacheKey: string;
|
||||
artifactHashes: string[];
|
||||
output: { format: string | null; text: string | null; data: any };
|
||||
usage: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
warnings: string[] | null;
|
||||
diagnostics: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
source: string;
|
||||
cached: boolean;
|
||||
attemptCount: number;
|
||||
};
|
||||
|
||||
type CacheEntry = {
|
||||
items: NormalizedInvocationItem[];
|
||||
cacheKey: string;
|
||||
storedAt: string;
|
||||
};
|
||||
|
||||
type Transport = 'clawd';
|
||||
|
||||
export const llmTaskInvokeCommand = {
|
||||
name: 'llm_task.invoke',
|
||||
meta: {
|
||||
description: 'Call Clawdbot llm-task tool with typed payloads and caching',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'Bearer token (or CLAWD_TOKEN). Optional if unauthenticated.',
|
||||
},
|
||||
prompt: { type: 'string', description: 'Primary prompt / instructions' },
|
||||
model: { type: 'string', description: 'Model identifier (optional; Clawdbot default will be used if omitted in CLAWD mode)' },
|
||||
'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_task'],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
`llm_task.invoke — call Clawdbot llm-task tool with caching and schema validation\n\n` +
|
||||
`Usage:\n` +
|
||||
` llm_task.invoke --prompt 'Write summary'\n` +
|
||||
` llm_task.invoke --model claude-3-sonnet --prompt 'Write summary'\n` +
|
||||
` cat artifacts.json | llm_task.invoke --prompt 'Score each item'\n` +
|
||||
` ... | llm_task.invoke --prompt 'Plan next steps' --output-schema '{"type":"object"}'\n\n` +
|
||||
`Config:\n` +
|
||||
` - Requires CLAWD_URL (Clawdbot gateway).\n` +
|
||||
` - Optional CLAWD_TOKEN for auth.\n\n` +
|
||||
`Features:\n` +
|
||||
` - Typed payload validation before invoking tool.\n` +
|
||||
` - Run-state + file cache so resumes do not re-call the LLM.\n` +
|
||||
` - Optional JSON-schema enforcement with bounded retries.\n`
|
||||
);
|
||||
},
|
||||
async run({ input, args, ctx }) {
|
||||
const env = ctx.env ?? process.env;
|
||||
|
||||
const clawdUrl = String(env.CLAWD_URL ?? '').trim();
|
||||
const transport: Transport = 'clawd';
|
||||
if (!clawdUrl) {
|
||||
throw new Error('llm_task.invoke requires CLAWD_URL (run via Clawdbot gateway)');
|
||||
}
|
||||
|
||||
const prompt = extractPrompt(args);
|
||||
if (!prompt) throw new Error('llm_task.invoke requires --prompt or positional text');
|
||||
|
||||
const model = String(args.model ?? env.LLM_TASK_MODEL ?? '').trim();
|
||||
// Model is optional in Clawdbot mode (Clawdbot llm-task tool can use its default model).
|
||||
|
||||
const schemaVersion = args['schema-version']
|
||||
? String(args['schema-version']).trim()
|
||||
: env.LLM_TASK_SCHEMA_VERSION
|
||||
? String(env.LLM_TASK_SCHEMA_VERSION).trim()
|
||||
: 'v1';
|
||||
|
||||
const maxOutputTokens = parseOptionalNumber(args['max-output-tokens']);
|
||||
const temperature = parseOptionalNumber(args.temperature);
|
||||
|
||||
const providedArtifacts = parseJsonArray(args['artifacts-json'], 'llm_task.invoke --artifacts-json');
|
||||
const metadataObject = parseJsonObject(args['metadata-json'], 'llm_task.invoke --metadata-json');
|
||||
const userOutputSchema = parseJsonObject(args['output-schema'], 'llm_task.invoke --output-schema');
|
||||
|
||||
const maxValidationRetriesRaw = args['max-validation-retries'] ?? env.LLM_TASK_VALIDATION_RETRIES;
|
||||
const maxValidationRetries = userOutputSchema
|
||||
? Math.max(0, Number.isFinite(Number(maxValidationRetriesRaw))
|
||||
? Number(maxValidationRetriesRaw)
|
||||
: DEFAULT_MAX_VALIDATION_RETRIES)
|
||||
: 0;
|
||||
|
||||
const disableCache = flag(args['disable-cache']);
|
||||
const forceRefresh = flag(args.refresh ?? env.LLM_TASK_FORCE_REFRESH);
|
||||
|
||||
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);
|
||||
|
||||
const normalizedArtifacts = [...inputArtifacts, ...providedArtifacts].map(normalizeArtifact);
|
||||
const artifactHashes = normalizedArtifacts.map(hashArtifact);
|
||||
|
||||
const cacheKey = computeCacheKey({ prompt, model, schemaVersion, artifactHashes, outputSchema: userOutputSchema });
|
||||
|
||||
if (stateKey && !forceRefresh) {
|
||||
const stored = await readStateJson({ env, key: stateKey }).catch(() => null);
|
||||
const reused = pickReusableState(stored, cacheKey);
|
||||
if (reused) {
|
||||
return {
|
||||
output: streamOf(reused.items.map((item) => ({ ...item, source: 'run_state', cached: true }))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!disableCache && !forceRefresh) {
|
||||
const cache = await readCacheEntry(env, cacheKey);
|
||||
if (cache) {
|
||||
return {
|
||||
output: streamOf(cache.items.map((item: any) => ({ ...item, source: 'cache', cached: true }))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
prompt,
|
||||
...(model ? { model } : null),
|
||||
artifacts: normalizedArtifacts,
|
||||
artifactHashes,
|
||||
};
|
||||
|
||||
if (metadataObject) payload.metadata = metadataObject;
|
||||
if (userOutputSchema) payload.outputSchema = userOutputSchema;
|
||||
if (schemaVersion) payload.schemaVersion = schemaVersion;
|
||||
if (Number.isFinite(maxOutputTokens ?? NaN)) payload.maxOutputTokens = Number(maxOutputTokens);
|
||||
if (Number.isFinite(temperature ?? NaN)) payload.temperature = Number(temperature);
|
||||
|
||||
if (!validatePayload(payload)) {
|
||||
throw new Error(`llm_task.invoke payload invalid: ${ajv.errorsText(validatePayload.errors)}`);
|
||||
}
|
||||
|
||||
const endpoint = buildClawdEndpoint(clawdUrl);
|
||||
const token = String(args.token ?? env.CLAWD_TOKEN ?? '').trim();
|
||||
|
||||
const validator = userOutputSchema ? ajv.compile(userOutputSchema) : null;
|
||||
|
||||
let attempt = 0;
|
||||
let lastValidationErrors: string[] = [];
|
||||
|
||||
while (true) {
|
||||
attempt++;
|
||||
if (attempt > 1) {
|
||||
payload.retryContext = {
|
||||
attempt,
|
||||
...(lastValidationErrors.length ? { validationErrors: lastValidationErrors } : null),
|
||||
};
|
||||
} else {
|
||||
delete payload.retryContext;
|
||||
}
|
||||
|
||||
let responseEnvelope: LlmTaskResponseEnvelope;
|
||||
try {
|
||||
responseEnvelope = await invokeRemoteViaClawd({ endpoint, token, payload });
|
||||
} catch (err: any) {
|
||||
throw new Error(`llm_task.invoke request failed: ${err?.message ?? String(err)}`);
|
||||
}
|
||||
|
||||
if (!validateResponseEnvelope(responseEnvelope)) {
|
||||
throw new Error(`llm_task.invoke received invalid response envelope`);
|
||||
}
|
||||
|
||||
if (responseEnvelope.ok !== true) {
|
||||
const message = responseEnvelope.error?.message ?? 'llm-task returned an error';
|
||||
throw new Error(`llm_task.invoke remote error: ${message}`);
|
||||
}
|
||||
|
||||
const normalized = normalizeResult({
|
||||
envelope: responseEnvelope,
|
||||
cacheKey,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
source: 'clawd',
|
||||
attempt,
|
||||
});
|
||||
|
||||
if (!validator) {
|
||||
await persistOutputs({ env, stateKey, cacheKey, items: normalized });
|
||||
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized);
|
||||
return { output: streamOf(normalized) };
|
||||
}
|
||||
|
||||
const structured = normalized[0]?.output?.data ?? null;
|
||||
if (validator(structured)) {
|
||||
await persistOutputs({ env, stateKey, cacheKey, items: normalized });
|
||||
if (!disableCache) await writeCacheEntry(env, cacheKey, normalized);
|
||||
return { output: streamOf(normalized) };
|
||||
}
|
||||
|
||||
lastValidationErrors = collectAjvErrors(validator.errors);
|
||||
if (attempt > maxValidationRetries + 1) {
|
||||
throw new Error(`llm_task.invoke output failed schema validation: ${lastValidationErrors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function extractPrompt(args: any) {
|
||||
if (args.prompt) return String(args.prompt);
|
||||
if (Array.isArray(args._) && args._.length) {
|
||||
return args._.join(' ');
|
||||
}
|
||||
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');
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} must be a JSON array`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: any) {
|
||||
if (value === undefined || value === null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function flag(value: any) {
|
||||
if (value === undefined || value === null) return false;
|
||||
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;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeArtifact(raw: any) {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return { kind: 'text', text: raw };
|
||||
}
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') {
|
||||
return { kind: 'text', text: String(raw) };
|
||||
}
|
||||
return { kind: 'json', data: raw };
|
||||
}
|
||||
|
||||
function hashArtifact(artifact: any) {
|
||||
const stable = stableStringify(artifact);
|
||||
return createHash('sha256').update(stable).digest('hex');
|
||||
}
|
||||
|
||||
function computeCacheKey({
|
||||
prompt,
|
||||
model,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
outputSchema,
|
||||
}: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
schemaVersion: string;
|
||||
artifactHashes: string[];
|
||||
outputSchema: any;
|
||||
}) {
|
||||
const payload = {
|
||||
prompt,
|
||||
// If model is omitted (Clawdbot default), keep caching stable but explicit.
|
||||
model: model || 'clawd-default',
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
outputSchema: outputSchema ?? null,
|
||||
};
|
||||
return createHash('sha256').update(stableStringify(payload)).digest('hex');
|
||||
}
|
||||
|
||||
function buildClawdEndpoint(clawdUrl: string) {
|
||||
return new URL('/tools/invoke', clawdUrl);
|
||||
}
|
||||
|
||||
async function invokeRemoteViaClawd({ endpoint, token, payload }: { endpoint: URL; token: string; payload: any }) {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : null),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: 'llm-task',
|
||||
action: 'invoke',
|
||||
args: payload,
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error('Response was not JSON');
|
||||
}
|
||||
|
||||
// Clawdbot tool router envelope: { ok, result, error }
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'ok' in parsed) {
|
||||
if (parsed.ok !== true) {
|
||||
const msg = parsed?.error?.message ?? 'Unknown error';
|
||||
throw new Error(`clawd tool error: ${msg}`);
|
||||
}
|
||||
|
||||
const inner = parsed.result;
|
||||
|
||||
// llm-task tool likely returns its own { ok, result } envelope.
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner) && 'ok' in inner) {
|
||||
return inner as LlmTaskResponseEnvelope;
|
||||
}
|
||||
|
||||
// Otherwise treat it as raw result.
|
||||
return { ok: true, result: inner } as LlmTaskResponseEnvelope;
|
||||
}
|
||||
|
||||
// Compatibility: raw JSON
|
||||
return { ok: true, result: parsed } as LlmTaskResponseEnvelope;
|
||||
}
|
||||
|
||||
function normalizeResult({
|
||||
envelope,
|
||||
cacheKey,
|
||||
schemaVersion,
|
||||
artifactHashes,
|
||||
source,
|
||||
attempt,
|
||||
}: {
|
||||
envelope: LlmTaskResponseEnvelope;
|
||||
cacheKey: string;
|
||||
schemaVersion: string;
|
||||
artifactHashes: string[];
|
||||
source: string;
|
||||
attempt: number;
|
||||
}): NormalizedInvocationItem[] {
|
||||
const result = envelope.result ?? {};
|
||||
const output = result.output ?? {};
|
||||
const item: NormalizedInvocationItem = {
|
||||
kind: 'llm_task.invoke',
|
||||
runId: (result.runId ?? null) as any,
|
||||
prompt: (result.prompt ?? null) as any,
|
||||
model: (result.model ?? null) as any,
|
||||
schemaVersion,
|
||||
status: String(result.status ?? 'completed'),
|
||||
cacheKey,
|
||||
artifactHashes,
|
||||
output: {
|
||||
format: (output.format ?? (output.data ? 'json' : 'text')) as any,
|
||||
text: (output.text ?? null) as any,
|
||||
data: (output.data ?? null) as any,
|
||||
},
|
||||
usage: (result.usage ?? null) as any,
|
||||
metadata: (result.metadata ?? null) as any,
|
||||
warnings: (result.warnings ?? null) as any,
|
||||
diagnostics: (result.diagnostics ?? null) as any,
|
||||
createdAt: new Date().toISOString(),
|
||||
source,
|
||||
cached: source !== 'remote' && source !== 'clawd',
|
||||
attemptCount: attempt,
|
||||
};
|
||||
return [item];
|
||||
}
|
||||
|
||||
async function persistOutputs({
|
||||
env,
|
||||
stateKey,
|
||||
cacheKey,
|
||||
items,
|
||||
}: {
|
||||
env: any;
|
||||
stateKey: string | null;
|
||||
cacheKey: string;
|
||||
items: NormalizedInvocationItem[];
|
||||
}) {
|
||||
if (!stateKey) return;
|
||||
const record = {
|
||||
type: 'llm_task.invoke',
|
||||
version: STATE_VERSION,
|
||||
cacheKey,
|
||||
items,
|
||||
storedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeStateJson({ env, key: stateKey, value: record });
|
||||
}
|
||||
|
||||
function pickReusableState(stored: any, cacheKey: string) {
|
||||
if (!stored || typeof stored !== 'object') return null;
|
||||
if (stored.type !== 'llm_task.invoke') return null;
|
||||
if (stored.cacheKey !== cacheKey) return null;
|
||||
if (!Array.isArray(stored.items)) return null;
|
||||
return { items: stored.items as NormalizedInvocationItem[] };
|
||||
}
|
||||
|
||||
function collectAjvErrors(errors: ErrorObject[] | null | undefined) {
|
||||
if (!errors?.length) return [];
|
||||
return errors.map((err) => `${err.instancePath || '/'} ${err.message ?? ''}`.trim());
|
||||
}
|
||||
|
||||
async function readCacheEntry(env: any, key: string): Promise<CacheEntry | null> {
|
||||
const filePath = path.join(getCacheDir(env), 'llm_task.invoke', `${key}.json`);
|
||||
try {
|
||||
const text = await fsp.readFile(filePath, 'utf8');
|
||||
return JSON.parse(text) as CacheEntry;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCacheEntry(env: any, key: string, items: NormalizedInvocationItem[]) {
|
||||
const dir = path.join(getCacheDir(env), 'llm_task.invoke');
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${key}.json`);
|
||||
await fsp.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ items, cacheKey: key, storedAt: new Date().toISOString() }, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
function getCacheDir(env: any) {
|
||||
if (env?.LOBSTER_CACHE_DIR) return String(env.LOBSTER_CACHE_DIR);
|
||||
return path.join(process.cwd(), '.lobster-cache');
|
||||
}
|
||||
|
||||
async function* streamOf(items: any[]) {
|
||||
for (const item of items) yield item;
|
||||
}
|
||||
|
||||
@ -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,19 +34,15 @@ 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: [],
|
||||
},
|
||||
@ -65,11 +61,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* () {
|
||||
@ -77,7 +73,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;
|
||||
@ -88,7 +84,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 };
|
||||
}
|
||||
|
||||
@ -1,150 +0,0 @@
|
||||
function createInvokeCommand(commandName: string) {
|
||||
return {
|
||||
name: commandName,
|
||||
meta: {
|
||||
description: "Call a local OpenClaw tool endpoint",
|
||||
argsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
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" } },
|
||||
},
|
||||
required: ["tool", "action"],
|
||||
},
|
||||
sideEffects: ["calls_clawd_tool"],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
`${commandName} — call a local OpenClaw tool endpoint\n\n` +
|
||||
`Usage:\n` +
|
||||
` ${commandName} --tool message --action send --args-json '{"provider":"telegram","to":"...","message":"..."}'\n` +
|
||||
` ${commandName} --tool message --action send --args-json '{...}' --dry-run\n` +
|
||||
` ... | ${commandName} --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'\n\n` +
|
||||
`Config:\n` +
|
||||
` - Uses OPENCLAW_URL env var by default (or pass --url).\n` +
|
||||
` - Backward compatible: CLAWD_URL is also supported.\n` +
|
||||
` - Optional Bearer token via OPENCLAW_TOKEN env var (or pass --token).\n` +
|
||||
` - Backward compatible: CLAWD_TOKEN is also supported.\n` +
|
||||
` - Optional attribution via --session-key <sessionKey>.\n\n` +
|
||||
`Notes:\n` +
|
||||
` - This is a thin transport bridge. Lobster should not own OAuth/secrets.\n`
|
||||
);
|
||||
},
|
||||
async run({ input, args, ctx }) {
|
||||
const each = Boolean(args.each);
|
||||
const itemKey = String(args.itemKey ?? args["item-key"] ?? "item");
|
||||
|
||||
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();
|
||||
|
||||
let toolArgs: any = {};
|
||||
if (args["args-json"]) {
|
||||
try {
|
||||
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))) {
|
||||
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 invokeOnce = async (argsValue: unknown) => {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : null),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: String(tool),
|
||||
action: String(action),
|
||||
args: argsValue,
|
||||
...(sessionKey ? { sessionKey: String(sessionKey) } : null),
|
||||
...(dryRun !== null ? { dryRun: Boolean(dryRun) } : null),
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${commandName} failed (${res.status}): ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch (_err) {
|
||||
throw new Error(`${commandName} expected JSON response`);
|
||||
}
|
||||
|
||||
// Preferred: { ok: true, result: ... }
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "ok" in parsed) {
|
||||
if (parsed.ok !== true) {
|
||||
const msg = parsed?.error?.message ?? "Unknown error";
|
||||
throw new Error(`${commandName} tool error: ${msg}`);
|
||||
}
|
||||
const result = parsed.result;
|
||||
return Array.isArray(result) ? result : [result];
|
||||
}
|
||||
|
||||
// Compatibility: raw JSON result
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
};
|
||||
|
||||
if (!each) {
|
||||
// Drain input: for now we don't stream input into clawd calls.
|
||||
for await (const _item of input) {
|
||||
// no-op
|
||||
}
|
||||
const items = await invokeOnce(toolArgs);
|
||||
return { output: asStream(items) };
|
||||
}
|
||||
|
||||
const out: any[] = [];
|
||||
for await (const item of input) {
|
||||
const argsValue = { ...(toolArgs as any), [itemKey]: item };
|
||||
const items = await invokeOnce(argsValue);
|
||||
out.push(...items);
|
||||
}
|
||||
|
||||
return { output: asStream(out) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function* asStream(items: any[]) {
|
||||
for (const item of items) yield item;
|
||||
}
|
||||
|
||||
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,16 +20,13 @@ 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,26 +18,23 @@ 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);
|
||||
const bStr = String(b);
|
||||
if (aStr < bStr) return -1;
|
||||
if (aStr > bStr) return 1;
|
||||
return 0;
|
||||
// date-ish compare if both look like ISO/date strings?
|
||||
// Keep it simple: string compare for everything else.
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
|
||||
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 +53,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,22 +39,21 @@ 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,9 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { applyFilters } from "../../core/filters.js";
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
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;
|
||||
@ -12,72 +11,26 @@ 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 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);
|
||||
const key = String(expr ?? '').trim();
|
||||
const val = getByPath(ctx, key);
|
||||
if (val === undefined || val === null) return '';
|
||||
if (typeof val === 'string') return 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}}, {{path | filter}}, {{.}} for the whole item)",
|
||||
},
|
||||
file: { type: "string", description: "Template file path" },
|
||||
_: { type: "array", items: { type: "string" } },
|
||||
text: { type: 'string', description: 'Template text (supports {{path}}; {{.}} for the whole item)' },
|
||||
file: { type: 'string', description: 'Template file path' },
|
||||
_: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
@ -92,28 +45,23 @@ export const templateCommand = {
|
||||
`Template syntax:\n` +
|
||||
` - {{field}} or {{nested.field}}\n` +
|
||||
` - {{.}} for the whole item\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`
|
||||
` - Missing values render as empty string\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,37 +24,30 @@ 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: [],
|
||||
},
|
||||
@ -63,7 +56,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,14 +1,14 @@
|
||||
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() {
|
||||
return `workflows.list — list available Lobster workflows\n\nUsage:\n workflows.list\n\nNotes:\n - Intended for OpenClaw to discover workflows dynamically.\n`;
|
||||
return `workflows.list — list available Lobster workflows\n\nUsage:\n workflows.list\n\nNotes:\n - Intended for Clawdbot to discover workflows dynamically.\n`;
|
||||
},
|
||||
async run({ input }) {
|
||||
// Drain input.
|
||||
|
||||
@ -1,34 +1,35 @@
|
||||
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';
|
||||
import { runOpenclawReleasePostWorkflow, runOpenclawReleaseTweetWorkflow } from '../../workflows/openclaw_release.js';
|
||||
|
||||
const runners = {
|
||||
"github.pr.monitor": runGithubPrMonitorWorkflow,
|
||||
"github.pr.monitor.notify": runGithubPrMonitorNotifyWorkflow,
|
||||
'openclaw.release.tweet': runOpenclawReleaseTweetWorkflow,
|
||||
'openclaw.release.post': runOpenclawReleasePostWorkflow,
|
||||
'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: [],
|
||||
},
|
||||
help() {
|
||||
return `workflows.run — run a named Lobster workflow\n\nUsage:\n workflows.run --name <workflow> [--args-json '{...}']\n\nExample:\n workflows.run --name github.pr.monitor.notify --args-json '{"repo":"openclaw/openclaw","pr":1152}'\n`;
|
||||
return `workflows.run — run a named Lobster workflow\n\nUsage:\n workflows.run --name <workflow> [--args-json '{...}']\n\nExample:\n workflows.run --name github.pr.monitor.notify --args-json '{"repo":"clawdbot/clawdbot","pr":1152}'\n`;
|
||||
},
|
||||
async run({ input, args, ctx }) {
|
||||
// Drain input.
|
||||
@ -37,17 +38,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 });
|
||||
@ -62,11 +63,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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";
|
||||
@ -1,100 +0,0 @@
|
||||
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,383 +0,0 @@
|
||||
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 {
|
||||
finalizePipelineToolRun,
|
||||
loadPipelineResumeState,
|
||||
validatePipelineInputResponse,
|
||||
} from "../pipeline_resume_state.js";
|
||||
|
||||
type ToolRunContext = {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
mode?: "tool" | "human" | "sdk";
|
||||
stdin?: NodeJS.ReadableStream;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
registry?: any;
|
||||
llmAdapters?: Record<string, any>;
|
||||
};
|
||||
|
||||
type ToolEnvelope = {
|
||||
protocolVersion: 1;
|
||||
ok: boolean;
|
||||
status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
|
||||
output?: unknown[];
|
||||
requiresApproval?: {
|
||||
type?: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
preview?: string;
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
} | null;
|
||||
requiresInput?: {
|
||||
type?: "input_request";
|
||||
prompt: string;
|
||||
responseSchema: unknown;
|
||||
defaults?: unknown;
|
||||
subject?: unknown;
|
||||
resumeToken?: string;
|
||||
} | null;
|
||||
error?: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function runToolRequest({
|
||||
pipeline,
|
||||
filePath,
|
||||
args,
|
||||
ctx = {},
|
||||
}: {
|
||||
pipeline?: string;
|
||||
filePath?: string;
|
||||
args?: Record<string, unknown>;
|
||||
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;
|
||||
|
||||
if (!hasPipeline && !hasFile) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (hasFile) {
|
||||
let resolvedFilePath: string;
|
||||
try {
|
||||
resolvedFilePath = await resolveWorkflowFile(filePath!, runtime.cwd);
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("parse_error", err?.message ?? String(err));
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await runWorkflowFile({
|
||||
filePath: resolvedFilePath,
|
||||
args,
|
||||
ctx: runtime,
|
||||
});
|
||||
|
||||
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 === "cancelled") {
|
||||
return okEnvelope("cancelled", [], null, null);
|
||||
}
|
||||
return okEnvelope("ok", output.output, null, null);
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("runtime_error", err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parsePipeline(String(pipeline));
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("parse_error", err?.message ?? String(err));
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await runPipeline({
|
||||
pipeline: parsed,
|
||||
registry: runtime.registry,
|
||||
input: [],
|
||||
stdin: runtime.stdin,
|
||||
stdout: runtime.stdout,
|
||||
stderr: runtime.stderr,
|
||||
env: runtime.env,
|
||||
mode: "tool",
|
||||
cwd: runtime.cwd,
|
||||
llmAdapters: runtime.llmAdapters,
|
||||
signal: runtime.signal,
|
||||
});
|
||||
|
||||
const finalized = await finalizePipelineToolRun({
|
||||
env: runtime.env,
|
||||
pipeline: parsed,
|
||||
output,
|
||||
});
|
||||
return okEnvelope(
|
||||
finalized.status,
|
||||
finalized.output,
|
||||
finalized.requiresApproval,
|
||||
finalized.requiresInput,
|
||||
);
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("runtime_error", err?.message ?? String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeToolRequest({
|
||||
token,
|
||||
approvalId,
|
||||
approved,
|
||||
response,
|
||||
cancel,
|
||||
ctx = {},
|
||||
}: {
|
||||
token?: string;
|
||||
approvalId?: string;
|
||||
approved?: boolean;
|
||||
response?: unknown;
|
||||
cancel?: boolean;
|
||||
ctx?: ToolRunContext;
|
||||
}): Promise<ToolEnvelope> {
|
||||
const runtime = createToolContext(ctx);
|
||||
let payload: any;
|
||||
let resolvedApprovalId = approvalId ?? null;
|
||||
|
||||
try {
|
||||
// Resolve short approval ID to token if provided
|
||||
let resolvedToken: string;
|
||||
if (approvalId) {
|
||||
const stateKey = await findStateKeyByApprovalId({ env: runtime.env, approvalId });
|
||||
if (!stateKey) {
|
||||
return errorEnvelope("parse_error", `Approval ID "${approvalId}" not found or expired`);
|
||||
}
|
||||
const kind = kindFromStateKey(stateKey);
|
||||
resolvedToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
kind,
|
||||
stateKey,
|
||||
});
|
||||
} else if (token) {
|
||||
resolvedToken = token;
|
||||
} else {
|
||||
return errorEnvelope("parse_error", "resume requires token or approvalId");
|
||||
}
|
||||
payload = decodeResumeToken(resolvedToken);
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("parse_error", err?.message ?? String(err));
|
||||
}
|
||||
|
||||
// Helper: clean up approval ID index after successful use
|
||||
const cleanupIndex = async () => {
|
||||
if (resolvedApprovalId) {
|
||||
await deleteApprovalId({ env: runtime.env, approvalId: resolvedApprovalId });
|
||||
} else if (payload?.stateKey) {
|
||||
await cleanupApprovalIndexByStateKey({ env: runtime.env, stateKey: payload.stateKey });
|
||||
}
|
||||
};
|
||||
|
||||
if (cancel === true) {
|
||||
await cleanupIndex();
|
||||
if (payload.kind === "workflow-file" && payload.stateKey) {
|
||||
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
|
||||
}
|
||||
if (payload.kind === "pipeline-resume" && payload.stateKey) {
|
||||
await deleteStateJson({ env: runtime.env, key: payload.stateKey });
|
||||
}
|
||||
return okEnvelope("cancelled", [], null, null);
|
||||
}
|
||||
|
||||
if (payload.kind === "workflow-file") {
|
||||
try {
|
||||
const output = await runWorkflowFile({
|
||||
filePath: payload.filePath,
|
||||
ctx: runtime,
|
||||
resume: payload,
|
||||
approved,
|
||||
response,
|
||||
cancel,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
if (output.status === "needs_input") {
|
||||
return okEnvelope("needs_input", [], null, output.requiresInput ?? null);
|
||||
}
|
||||
await cleanupIndex();
|
||||
if (output.status === "cancelled") {
|
||||
return okEnvelope("cancelled", [], null, null);
|
||||
}
|
||||
return okEnvelope("ok", output.output, null, null);
|
||||
} catch (err: any) {
|
||||
if (err instanceof WorkflowResumeArgumentError) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
let resumeState;
|
||||
try {
|
||||
resumeState = await loadPipelineResumeState(runtime.env, payload.stateKey);
|
||||
} catch (err: any) {
|
||||
return errorEnvelope("runtime_error", err?.message ?? String(err));
|
||||
}
|
||||
|
||||
if (resumeState.haltType === "input_request") {
|
||||
if (approved !== undefined) {
|
||||
return errorEnvelope("parse_error", "pipeline input resumes require response");
|
||||
}
|
||||
if (response === undefined) {
|
||||
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));
|
||||
}
|
||||
} else {
|
||||
if (response !== undefined) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = resumeState.pipeline.slice(resumeState.resumeAtIndex);
|
||||
const input = streamFromItems(
|
||||
resumeState.haltType === "input_request" ? [response] : resumeState.items,
|
||||
);
|
||||
|
||||
try {
|
||||
const output = await runPipeline({
|
||||
pipeline: remaining,
|
||||
registry: runtime.registry,
|
||||
stdin: runtime.stdin,
|
||||
stdout: runtime.stdout,
|
||||
stderr: runtime.stderr,
|
||||
env: runtime.env,
|
||||
mode: "tool",
|
||||
cwd: runtime.cwd,
|
||||
llmAdapters: runtime.llmAdapters,
|
||||
signal: runtime.signal,
|
||||
input,
|
||||
});
|
||||
|
||||
await cleanupIndex();
|
||||
const finalized = await finalizePipelineToolRun({
|
||||
env: runtime.env,
|
||||
pipeline: remaining,
|
||||
output,
|
||||
previousStateKey: payload.stateKey,
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolContext(ctx: ToolRunContext = {}) {
|
||||
return {
|
||||
cwd: ctx.cwd ?? process.cwd(),
|
||||
env: { ...process.env, ...ctx.env },
|
||||
mode: "tool" as const,
|
||||
stdin: ctx.stdin ?? process.stdin,
|
||||
stdout: ctx.stdout ?? createCaptureStream(),
|
||||
stderr: ctx.stderr ?? createCaptureStream(),
|
||||
signal: ctx.signal,
|
||||
registry: ctx.registry ?? createDefaultRegistry(),
|
||||
llmAdapters: ctx.llmAdapters,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCaptureStream() {
|
||||
return new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function okEnvelope(
|
||||
status: "ok" | "needs_approval" | "needs_input" | "cancelled",
|
||||
output: unknown[],
|
||||
requiresApproval: ToolEnvelope["requiresApproval"],
|
||||
requiresInput: ToolEnvelope["requiresInput"],
|
||||
) {
|
||||
return {
|
||||
protocolVersion: 1 as const,
|
||||
ok: true,
|
||||
status,
|
||||
output,
|
||||
requiresApproval,
|
||||
requiresInput,
|
||||
};
|
||||
}
|
||||
|
||||
function errorEnvelope(type: string, message: string): ToolEnvelope {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
ok: false,
|
||||
error: { type, message },
|
||||
};
|
||||
}
|
||||
|
||||
function streamFromItems(items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function resolveWorkflowFile(candidate: string, cwd: string) {
|
||||
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");
|
||||
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");
|
||||
}
|
||||
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,70 +32,46 @@ 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++) {
|
||||
const ch = input[i];
|
||||
|
||||
if (quote) {
|
||||
if (quote === "'") {
|
||||
if (ch === "\\" && input[i + 1] === quote) {
|
||||
current += quote;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
current += next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (next === "\n") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
@ -113,7 +89,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 +100,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 +111,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 +128,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,229 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { encodeToken } from "./token.js";
|
||||
import {
|
||||
cleanupApprovalIndexByStateKey,
|
||||
createApprovalIndex,
|
||||
deleteStateJson,
|
||||
readStateJson,
|
||||
writeStateJson,
|
||||
} 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";
|
||||
inputSchema?: unknown;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PipelineApprovalRequest = {
|
||||
type: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
preview?: string;
|
||||
};
|
||||
|
||||
export type PipelineInputRequest = {
|
||||
type: "input_request";
|
||||
prompt: string;
|
||||
responseSchema: unknown;
|
||||
defaults?: unknown;
|
||||
subject?: unknown;
|
||||
items?: unknown[];
|
||||
};
|
||||
|
||||
export type PipelineRunOutput = {
|
||||
items: unknown[];
|
||||
halted?: boolean;
|
||||
haltedAt?: { index: number } | null;
|
||||
};
|
||||
|
||||
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_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;
|
||||
};
|
||||
|
||||
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"];
|
||||
output: PipelineRunOutput;
|
||||
previousStateKey?: string;
|
||||
}): Promise<PipelineToolRunResolution> {
|
||||
const { approval, inputRequest } = extractPipelineHalt(params.output);
|
||||
if (approval) {
|
||||
const nextStateKey = await savePipelineResumeState(params.env, {
|
||||
pipeline: params.pipeline,
|
||||
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
|
||||
items: approval.items,
|
||||
haltType: "approval_request",
|
||||
prompt: approval.prompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (params.previousStateKey) {
|
||||
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
|
||||
await deleteStateJson({ env: params.env, key: params.previousStateKey });
|
||||
}
|
||||
let approvalId: string;
|
||||
try {
|
||||
approvalId = await createApprovalIndex({ env: params.env, stateKey: nextStateKey });
|
||||
} catch (err) {
|
||||
await deleteStateJson({ env: params.env, key: nextStateKey }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
kind: "pipeline-resume",
|
||||
stateKey: nextStateKey,
|
||||
});
|
||||
return {
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
...approval,
|
||||
resumeToken,
|
||||
approvalId,
|
||||
},
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputRequest) {
|
||||
const nextStateKey = await savePipelineResumeState(params.env, {
|
||||
pipeline: params.pipeline,
|
||||
resumeAtIndex: (params.output.haltedAt?.index ?? -1) + 1,
|
||||
items: [],
|
||||
haltType: "input_request",
|
||||
inputSchema: inputRequest.responseSchema,
|
||||
prompt: inputRequest.prompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (params.previousStateKey) {
|
||||
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
|
||||
await deleteStateJson({ env: params.env, key: params.previousStateKey });
|
||||
}
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
kind: "pipeline-resume",
|
||||
stateKey: nextStateKey,
|
||||
});
|
||||
return {
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: {
|
||||
type: "input_request",
|
||||
prompt: inputRequest.prompt,
|
||||
responseSchema: inputRequest.responseSchema,
|
||||
...(inputRequest.defaults !== undefined ? { defaults: inputRequest.defaults } : null),
|
||||
...(inputRequest.subject !== undefined ? { subject: inputRequest.subject } : null),
|
||||
resumeToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (params.previousStateKey) {
|
||||
await cleanupApprovalIndexByStateKey({ env: params.env, stateKey: params.previousStateKey });
|
||||
await deleteStateJson({ env: params.env, key: params.previousStateKey });
|
||||
}
|
||||
return {
|
||||
status: "ok",
|
||||
output: params.output.items,
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function savePipelineResumeState(
|
||||
env: Record<string, string | undefined>,
|
||||
state: PipelineResumeState,
|
||||
) {
|
||||
const stateKey = `pipeline_resume_${randomUUID()}`;
|
||||
await writeStateJson({ env, key: stateKey, value: state });
|
||||
return stateKey;
|
||||
}
|
||||
|
||||
export async function loadPipelineResumeState(
|
||||
env: Record<string, string | undefined>,
|
||||
stateKey: string,
|
||||
) {
|
||||
const stored = await readStateJson({ env, key: stateKey });
|
||||
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");
|
||||
}
|
||||
return data as PipelineResumeState;
|
||||
}
|
||||
|
||||
export function validatePipelineInputResponse(schema: unknown, response: unknown) {
|
||||
if (schema === undefined) {
|
||||
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");
|
||||
}
|
||||
const ok = validator(response);
|
||||
if (ok) return;
|
||||
const first = validator.errors?.[0];
|
||||
const pathValue = first?.instancePath || "/";
|
||||
const reason = first?.message ? ` ${first.message}` : "";
|
||||
throw new Error(`pipeline input response failed schema validation at ${pathValue}:${reason}`);
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
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 timer: NodeJS.Timeout | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("end", onEnd);
|
||||
stream.off("close", onClose);
|
||||
stream.off("error", onError);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
|
||||
const finish = (value: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const fail = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
buf += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
const idx = buf.indexOf("\n");
|
||||
if (idx !== -1) {
|
||||
finish(buf.slice(0, idx));
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => finish(buf);
|
||||
const onClose = () => finish(buf);
|
||||
const onError = (err: Error) => fail(err);
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
fail(new Error(`Timed out waiting for input (${timeoutMs}ms)`));
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
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,62 +108,56 @@ 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",
|
||||
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",
|
||||
return [{
|
||||
kind: 'github.pr.monitor',
|
||||
repo,
|
||||
pr: Number(pr),
|
||||
key,
|
||||
changed,
|
||||
summary,
|
||||
prSnapshot: current,
|
||||
},
|
||||
];
|
||||
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,
|
||||
}];
|
||||
})
|
||||
.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' },
|
||||
},
|
||||
});
|
||||
|
||||
@ -172,15 +166,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 },
|
||||
},
|
||||
};
|
||||
|
||||
@ -198,8 +192,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 }))
|
||||
@ -208,7 +202,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;
|
||||
@ -222,31 +216,29 @@ 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,
|
||||
},
|
||||
summary,
|
||||
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,
|
||||
}];
|
||||
})
|
||||
.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' },
|
||||
},
|
||||
});
|
||||
|
||||
@ -255,12 +247,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,34 +20,30 @@ 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 {
|
||||
@ -69,24 +65,16 @@ 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,
|
||||
|
||||
@ -96,7 +84,12 @@ 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;
|
||||
|
||||
@ -104,7 +97,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 {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Recipe entrypoints.
|
||||
*
|
||||
* Core Lobster intentionally keeps only OpenClaw-first recipes here.
|
||||
* Core Lobster intentionally keeps only Clawdbot-first recipes here.
|
||||
*/
|
||||
|
||||
export * from "./github/index.js";
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
177
src/resume.ts
177
src/resume.ts
@ -1,186 +1,55 @@
|
||||
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";
|
||||
// Fallback for unknown prefixes — workflow-file is the original behavior
|
||||
return "workflow-file";
|
||||
}
|
||||
|
||||
export type PipelineResumePayload = {
|
||||
protocolVersion: 1;
|
||||
v: 1;
|
||||
kind: "pipeline-resume";
|
||||
stateKey: string;
|
||||
};
|
||||
import { decodeToken } from './token.js';
|
||||
import { decodeWorkflowResumePayload } from './workflows/file.js';
|
||||
|
||||
export function parseResumeArgs(argv) {
|
||||
const args = { decision: null, token: null, approvalId: null, responseJson: null, cancel: false };
|
||||
const args = { decision: null, token: null };
|
||||
|
||||
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") {
|
||||
args.approvalId = argv[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (tok.startsWith("--id=")) {
|
||||
args.approvalId = tok.slice("--id=".length);
|
||||
continue;
|
||||
}
|
||||
if (tok === "--response-json") {
|
||||
args.responseJson = argv[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (tok.startsWith("--response-json=")) {
|
||||
args.responseJson = tok.slice("--response-json=".length);
|
||||
continue;
|
||||
}
|
||||
if (tok === "--cancel") {
|
||||
const next = argv[i + 1];
|
||||
if (typeof next === "string" && !next.startsWith("--")) {
|
||||
const parsed = parseBooleanArg(next);
|
||||
if (parsed === null) {
|
||||
throw new Error("resume --cancel must be true or false");
|
||||
}
|
||||
args.cancel = parsed;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
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");
|
||||
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 (intentCount > 1) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (args.cancel) {
|
||||
return {
|
||||
token: args.token ? String(args.token) : null,
|
||||
approvalId: args.approvalId ? String(args.approvalId) : null,
|
||||
cancel: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (args.responseJson !== null) {
|
||||
try {
|
||||
return {
|
||||
token: args.token ? String(args.token) : null,
|
||||
approvalId: args.approvalId ? String(args.approvalId) : null,
|
||||
response: JSON.parse(String(args.responseJson)),
|
||||
};
|
||||
} catch {
|
||||
throw new Error("resume --response-json must be valid JSON");
|
||||
}
|
||||
}
|
||||
if (!args.token) throw new Error('resume requires --token');
|
||||
if (!args.decision) throw new Error('resume requires --approve yes|no');
|
||||
|
||||
const decision = String(args.decision).toLowerCase();
|
||||
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",
|
||||
};
|
||||
}
|
||||
if (!['yes', 'y', 'no', 'n'].includes(decision)) throw new Error('resume --approve must be yes or no');
|
||||
|
||||
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;
|
||||
return 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> {
|
||||
const stateKey = await findStateKeyByApprovalId({ env, approvalId });
|
||||
if (!stateKey) {
|
||||
throw new Error(`Approval ID "${approvalId}" not found or expired`);
|
||||
}
|
||||
|
||||
const kind = kindFromStateKey(stateKey);
|
||||
|
||||
return encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
kind,
|
||||
stateKey,
|
||||
});
|
||||
return { token: String(args.token), approved: decision === 'yes' || decision === 'y' };
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function decodePipelineResumePayload(payload: unknown): PipelineResumePayload | 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");
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
kind: "pipeline-resume",
|
||||
stateKey: data.stateKey,
|
||||
};
|
||||
if (!Array.isArray(payload.pipeline)) throw new Error('Invalid token');
|
||||
if (typeof payload.resumeAtIndex !== 'number') throw new Error('Invalid token');
|
||||
if (!Array.isArray(payload.items)) throw new Error('Invalid token');
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@ -1,36 +1,6 @@
|
||||
import { createJsonRenderer } from "./renderers/json.js";
|
||||
|
||||
export async function runPipeline({
|
||||
pipeline,
|
||||
registry,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
env,
|
||||
mode = "human",
|
||||
input,
|
||||
cwd = undefined,
|
||||
llmAdapters = undefined,
|
||||
signal = undefined,
|
||||
dryRun = false,
|
||||
}: {
|
||||
pipeline: any[];
|
||||
registry: any;
|
||||
stdin: any;
|
||||
stdout: any;
|
||||
stderr: any;
|
||||
env: any;
|
||||
mode?: string;
|
||||
input?: any;
|
||||
cwd?: string | undefined;
|
||||
llmAdapters?: Record<string, any> | undefined;
|
||||
signal?: AbortSignal | undefined;
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
if (dryRun) {
|
||||
return dryRunPipeline({ pipeline, registry, stderr });
|
||||
}
|
||||
import { createJsonRenderer } from './renderers/json.js';
|
||||
|
||||
export async function runPipeline({ pipeline, registry, stdin, stdout, stderr, env, mode = 'human', input }) {
|
||||
let stream = input ?? emptyStream();
|
||||
let rendered = false;
|
||||
let halted = false;
|
||||
@ -43,9 +13,6 @@ export async function runPipeline({
|
||||
env,
|
||||
registry,
|
||||
mode,
|
||||
cwd,
|
||||
llmAdapters,
|
||||
signal,
|
||||
render: createJsonRenderer(stdout),
|
||||
};
|
||||
|
||||
@ -78,48 +45,4 @@ export async function runPipeline({
|
||||
return { items, rendered, halted, haltedAt };
|
||||
}
|
||||
|
||||
function dryRunPipeline({
|
||||
pipeline,
|
||||
registry,
|
||||
stderr,
|
||||
}: {
|
||||
pipeline: any[];
|
||||
registry: any;
|
||||
stderr: any;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
lines.push(`[DRY RUN] Pipeline (${pipeline.length} stage${pipeline.length !== 1 ? "s" : ""}):`);
|
||||
|
||||
for (let idx = 0; idx < pipeline.length; idx++) {
|
||||
const stage = pipeline[idx];
|
||||
const command = registry.get(stage.name);
|
||||
if (!command) {
|
||||
throw new Error(`Unknown command: ${stage.name}`);
|
||||
}
|
||||
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"));
|
||||
// Return rendered:true so the CLI does not print an empty JSON array to stdout.
|
||||
return { items: [], rendered: true, halted: false, haltedAt: null };
|
||||
}
|
||||
|
||||
function formatStageArgs(args: Record<string, unknown>) {
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async function* emptyStream() {}
|
||||
|
||||
@ -1,32 +1,15 @@
|
||||
import { runPipelineInternal } from "./runtime.js";
|
||||
import { encodeToken, decodeToken } from "./token.js";
|
||||
import { sharedAjv } from "../validation.js";
|
||||
|
||||
type SdkResumePayload = {
|
||||
protocolVersion: 1;
|
||||
v: 1;
|
||||
stageIndex?: number;
|
||||
resumeAtIndex: number;
|
||||
items?: unknown[];
|
||||
prompt?: string;
|
||||
inputSchema?: unknown;
|
||||
inputSubject?: unknown;
|
||||
};
|
||||
import { runPipelineInternal } from './runtime.js';
|
||||
import { encodeToken, decodeToken } from './token.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} LobsterResult
|
||||
* @property {boolean} ok - Whether the workflow completed successfully
|
||||
* @property {'ok' | 'needs_approval' | 'needs_input' | 'cancelled' | 'error'} status - Workflow status
|
||||
* @property {'ok' | 'needs_approval' | 'cancelled' | 'error'} status - Workflow status
|
||||
* @property {any[]} output - Output items from the workflow
|
||||
* @property {Object|null} requiresApproval - Approval request if halted
|
||||
* @property {string} [requiresApproval.prompt] - Approval prompt
|
||||
* @property {any[]} [requiresApproval.items] - Items pending approval
|
||||
* @property {string} [requiresApproval.resumeToken] - Token to resume workflow
|
||||
* @property {Object|null} requiresInput - Input request if halted
|
||||
* @property {string} [requiresInput.prompt] - Input prompt
|
||||
* @property {Object} [requiresInput.responseSchema] - JSON Schema for response
|
||||
* @property {any} [requiresInput.subject] - Subject shown to the human
|
||||
* @property {string} [requiresInput.resumeToken] - Token to resume workflow
|
||||
* @property {Object} [error] - Error details if failed
|
||||
*/
|
||||
|
||||
@ -36,40 +19,94 @@ type SdkResumePayload = {
|
||||
* @property {string} [stateDir] - State directory override
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lobster - Fluent workflow builder for AI agents
|
||||
*
|
||||
* @example
|
||||
* const workflow = new Lobster()
|
||||
* .pipe(exec('gh pr view 123 --repo owner/repo --json title,url'))
|
||||
* .pipe(approve({ prompt: 'Continue?' }))
|
||||
* .run();
|
||||
*/
|
||||
export class Lobster {
|
||||
/** @type {Array<Function|Object>} */
|
||||
#stages = [];
|
||||
|
||||
/** @type {any} */
|
||||
#options: any = {} as any;
|
||||
|
||||
/** @type {Object|null} */
|
||||
#meta = null;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
/**
|
||||
* Create a new Lobster workflow builder
|
||||
* @param {LobsterOptions} [options]
|
||||
*/
|
||||
constructor(options: any = {}) {
|
||||
this.#options = {
|
||||
env: options.env ?? process.env,
|
||||
stateDir: options.stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a stage to the pipeline
|
||||
*
|
||||
* Stages can be:
|
||||
* - A function: (items: any[]) => any[] | AsyncIterable
|
||||
* - An async generator function: async function* (input) { ... }
|
||||
* - A stage object with { run: Function }
|
||||
* - A primitive from lobster-sdk (approve, exec, etc.)
|
||||
*
|
||||
* @param {Function|Object} stage - Stage to add
|
||||
* @returns {Lobster} - Returns this for chaining
|
||||
*
|
||||
* @example
|
||||
* new Lobster()
|
||||
* .pipe(exec('gh pr view 123 --repo owner/repo --json title,url'))
|
||||
* .pipe(items => items)
|
||||
* .pipe(approve({ prompt: 'Proceed?' }))
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for this workflow (for recipe discovery)
|
||||
* @param {Object} meta
|
||||
* @param {string} meta.name - Workflow name
|
||||
* @param {string} meta.description - Description
|
||||
* @param {string[]} [meta.requires] - Required CLI tools
|
||||
* @param {Object} [meta.args] - Argument schema
|
||||
* @returns {Lobster}
|
||||
*/
|
||||
meta(meta) {
|
||||
this.#meta = meta;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow metadata
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getMeta() {
|
||||
return this.#meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the workflow
|
||||
* @param {any[]} [initialInput] - Optional initial input items
|
||||
* @returns {Promise<LobsterResult>}
|
||||
*/
|
||||
async run(initialInput = []) {
|
||||
const ctx = {
|
||||
env: this.#options.env,
|
||||
stateDir: this.#options.stateDir,
|
||||
mode: "sdk",
|
||||
mode: 'sdk',
|
||||
};
|
||||
|
||||
try {
|
||||
@ -79,11 +116,8 @@ export class Lobster {
|
||||
input: initialInput,
|
||||
});
|
||||
|
||||
if (
|
||||
result.halted &&
|
||||
result.items.length === 1 &&
|
||||
result.items[0]?.type === "approval_request"
|
||||
) {
|
||||
// Check for approval halt
|
||||
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
|
||||
const approval = result.items[0];
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
@ -92,152 +126,70 @@ export class Lobster {
|
||||
resumeAtIndex: (result.haltedAt?.index ?? -1) + 1,
|
||||
items: approval.items,
|
||||
prompt: approval.prompt,
|
||||
// Note: We can't serialize the stages themselves, so resume requires
|
||||
// the caller to maintain the workflow reference
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
status: 'needs_approval',
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
prompt: approval.prompt,
|
||||
items: approval.items,
|
||||
resumeToken,
|
||||
},
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
|
||||
const input = result.items[0];
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
stageIndex: result.haltedAt?.index ?? -1,
|
||||
resumeAtIndex: (result.haltedAt?.index ?? -1) + 1,
|
||||
items: [],
|
||||
inputSchema: input.responseSchema,
|
||||
inputSubject: input.subject,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: {
|
||||
prompt: input.prompt,
|
||||
responseSchema: input.responseSchema,
|
||||
defaults: input.defaults,
|
||||
subject: input.subject,
|
||||
resumeToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
status: 'ok',
|
||||
output: result.items,
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
};
|
||||
} 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async resume(
|
||||
token: string,
|
||||
options: { approved?: boolean; response?: unknown; cancel?: boolean } = {},
|
||||
) {
|
||||
const { approved, response, cancel } = options;
|
||||
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");
|
||||
}
|
||||
if (intentCount === 0) {
|
||||
throw new Error("resume requires approved, response, or cancel");
|
||||
}
|
||||
|
||||
const payload = decodeSdkResumePayload(token);
|
||||
|
||||
if (cancel === true) {
|
||||
/**
|
||||
* Resume a halted workflow after approval
|
||||
* @param {string} token - Resume token from previous run
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.approved - Whether the approval was granted
|
||||
* @returns {Promise<LobsterResult>}
|
||||
*/
|
||||
async resume(token, { approved }) {
|
||||
if (!approved) {
|
||||
return {
|
||||
ok: true,
|
||||
status: "cancelled",
|
||||
status: 'cancelled',
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
|
||||
const expectsInput = payload.inputSchema !== undefined;
|
||||
if (expectsInput) {
|
||||
if (approved !== undefined) {
|
||||
throw new Error("resume token expects an input response, not approved");
|
||||
}
|
||||
if (response === undefined) {
|
||||
throw new Error("resume token expects response");
|
||||
}
|
||||
} else {
|
||||
if (response !== undefined) {
|
||||
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 (approved === false) {
|
||||
return {
|
||||
ok: true,
|
||||
status: "cancelled",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payload = decodeToken(token);
|
||||
const resumeIndex = payload.resumeAtIndex ?? 0;
|
||||
let resumeItems = payload.items ?? [];
|
||||
if (response !== undefined) {
|
||||
const schema = payload.inputSchema;
|
||||
if (schema === undefined) {
|
||||
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");
|
||||
}
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
resumeItems = [response];
|
||||
}
|
||||
const resumeItems = payload.items ?? [];
|
||||
|
||||
// Get remaining stages
|
||||
const remainingStages = this.#stages.slice(resumeIndex);
|
||||
|
||||
const ctx = {
|
||||
env: this.#options.env,
|
||||
stateDir: this.#options.stateDir,
|
||||
mode: "sdk",
|
||||
mode: 'sdk',
|
||||
};
|
||||
|
||||
try {
|
||||
@ -247,11 +199,8 @@ export class Lobster {
|
||||
input: resumeItems,
|
||||
});
|
||||
|
||||
if (
|
||||
result.halted &&
|
||||
result.items.length === 1 &&
|
||||
result.items[0]?.type === "approval_request"
|
||||
) {
|
||||
// Check for another approval halt
|
||||
if (result.halted && result.items.length === 1 && result.items[0]?.type === 'approval_request') {
|
||||
const approval = result.items[0];
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
@ -264,66 +213,40 @@ export class Lobster {
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
status: 'needs_approval',
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
prompt: approval.prompt,
|
||||
items: approval.items,
|
||||
resumeToken,
|
||||
},
|
||||
requiresInput: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.halted && result.items.length === 1 && result.items[0]?.type === "input_request") {
|
||||
const input = result.items[0];
|
||||
const resumeToken = encodeToken({
|
||||
protocolVersion: 1,
|
||||
v: 1,
|
||||
stageIndex: resumeIndex + (result.haltedAt?.index ?? 0),
|
||||
resumeAtIndex: resumeIndex + (result.haltedAt?.index ?? 0) + 1,
|
||||
items: [],
|
||||
inputSchema: input.responseSchema,
|
||||
inputSubject: input.subject,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: {
|
||||
prompt: input.prompt,
|
||||
responseSchema: input.responseSchema,
|
||||
defaults: input.defaults,
|
||||
subject: input.subject,
|
||||
resumeToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
status: 'ok',
|
||||
output: result.items,
|
||||
requiresApproval: null,
|
||||
requiresInput: null,
|
||||
};
|
||||
} 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone this workflow (for creating variants)
|
||||
* @returns {Lobster}
|
||||
*/
|
||||
clone() {
|
||||
const cloned = new Lobster(this.#options);
|
||||
cloned.#stages = [...this.#stages];
|
||||
@ -331,25 +254,3 @@ export class Lobster {
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSdkResumePayload(token: string): SdkResumePayload {
|
||||
const payload = decodeToken(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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
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,12 +54,8 @@ 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;
|
||||
});
|
||||
@ -77,12 +73,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 }) {
|
||||
@ -100,10 +96,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;
|
||||
}
|
||||
}
|
||||
@ -113,11 +109,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,
|
||||
@ -128,7 +124,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 };
|
||||
})(),
|
||||
};
|
||||
}
|
||||
@ -156,10 +152,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;
|
||||
}
|
||||
}
|
||||
@ -169,7 +165,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,7 @@
|
||||
* .pipe(items => items.filter(e => e.unread))
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveInlineShellCommand } from "../../shell.js";
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Run a process and capture output
|
||||
@ -24,28 +23,24 @@ 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 {
|
||||
@ -63,14 +58,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;
|
||||
@ -88,10 +83,10 @@ function parseCommand(cmdString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
if (ch === ' ' || ch === '\t') {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@ -123,7 +118,7 @@ export function exec(cmdString, options: any = {}) {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
|
||||
return {
|
||||
type: "exec",
|
||||
type: 'exec',
|
||||
command: cmdString,
|
||||
|
||||
async run({ input, ctx }) {
|
||||
@ -138,8 +133,7 @@ export function exec(cmdString, options: any = {}) {
|
||||
|
||||
if (useShell) {
|
||||
// Shell execution
|
||||
const shell = resolveInlineShellCommand({ command: cmdString, env });
|
||||
const result = await runProcess(shell.command, shell.argv, { env, cwd });
|
||||
const result = await runProcess('/bin/sh', ['-c', cmdString], { env, cwd });
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
// Direct execution
|
||||
@ -152,7 +146,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,11 +83,10 @@ 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
|
||||
@ -98,7 +97,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 {
|
||||
@ -125,16 +124,7 @@ 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;
|
||||
|
||||
60
src/shell.ts
60
src/shell.ts
@ -1,60 +0,0 @@
|
||||
export function resolveInlineShellCommand({
|
||||
command,
|
||||
env,
|
||||
platform = process.platform,
|
||||
}: {
|
||||
command: string;
|
||||
env: Record<string, string | undefined>;
|
||||
platform?: string;
|
||||
}) {
|
||||
const shellOverride = String(env?.LOBSTER_SHELL ?? "").trim();
|
||||
const isWindows = platform === "win32";
|
||||
|
||||
if (shellOverride) {
|
||||
return {
|
||||
command: shellOverride,
|
||||
argv: buildShellArgs({ shellCommand: shellOverride, command, isWindows }),
|
||||
};
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
const comspec = String(env?.ComSpec ?? env?.COMSPEC ?? "cmd.exe").trim() || "cmd.exe";
|
||||
return {
|
||||
command: comspec,
|
||||
argv: ["/d", "/s", "/c", command],
|
||||
};
|
||||
}
|
||||
|
||||
// Keep default behavior deterministic and POSIX-compatible across environments.
|
||||
const shell = "/bin/sh";
|
||||
return {
|
||||
command: shell,
|
||||
argv: ["-lc", command],
|
||||
};
|
||||
}
|
||||
|
||||
function buildShellArgs({
|
||||
shellCommand,
|
||||
command,
|
||||
isWindows,
|
||||
}: {
|
||||
shellCommand: string;
|
||||
command: string;
|
||||
isWindows: boolean;
|
||||
}) {
|
||||
const lowered = shellCommand.toLowerCase();
|
||||
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");
|
||||
|
||||
if (looksLikePowerShell) {
|
||||
return ["-NoProfile", "-Command", command];
|
||||
}
|
||||
if (looksLikeCmd || isWindows) {
|
||||
return ["/d", "/s", "/c", command];
|
||||
}
|
||||
return ["-lc", command];
|
||||
}
|
||||
@ -1,33 +1,28 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
});
|
||||
@ -38,10 +33,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;
|
||||
}
|
||||
}
|
||||
@ -51,161 +46,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");
|
||||
}
|
||||
|
||||
export async function deleteStateJson({ env, key }) {
|
||||
const stateDir = defaultStateDir(env);
|
||||
const filePath = keyToPath(stateDir, key);
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeApprovalId(approvalId: string): string {
|
||||
return approvalId.replace(/[^a-f0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short, human-friendly approval ID (8 hex chars).
|
||||
* These are easy to copy/paste in chat interfaces where full
|
||||
* base64url resume tokens are unwieldy.
|
||||
*/
|
||||
export function generateApprovalId(): string {
|
||||
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,
|
||||
}: {
|
||||
env: Record<string, string | undefined>;
|
||||
stateKey: string;
|
||||
approvalId: string;
|
||||
}) {
|
||||
const stateDir = defaultStateDir(env);
|
||||
const safe = sanitizeApprovalId(approvalId);
|
||||
if (!safe) return;
|
||||
await fsp.mkdir(stateDir, { recursive: true });
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique approval ID index without ever overwriting an existing mapping.
|
||||
*/
|
||||
export async function createApprovalIndex({
|
||||
env,
|
||||
stateKey,
|
||||
}: {
|
||||
env: Record<string, string | undefined>;
|
||||
stateKey: string;
|
||||
}) {
|
||||
for (let attempt = 0; attempt < 16; attempt++) {
|
||||
const approvalId = generateApprovalId();
|
||||
try {
|
||||
await writeApprovalIndex({ env, stateKey, approvalId });
|
||||
return approvalId;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EEXIST") continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
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,
|
||||
}: {
|
||||
env: Record<string, string | undefined>;
|
||||
approvalId: string;
|
||||
}): Promise<string | null> {
|
||||
const stateDir = defaultStateDir(env);
|
||||
const safe = sanitizeApprovalId(approvalId);
|
||||
if (!safe) return null;
|
||||
const indexPath = path.join(stateDir, `approval_${safe}.json`);
|
||||
try {
|
||||
const text = await fsp.readFile(indexPath, "utf8");
|
||||
const data = JSON.parse(text);
|
||||
return typeof data?.stateKey === "string" ? data.stateKey : null;
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the approval ID index file (cleanup after resume or cancel).
|
||||
*/
|
||||
export async function deleteApprovalId({
|
||||
env,
|
||||
approvalId,
|
||||
}: {
|
||||
env: Record<string, string | undefined>;
|
||||
approvalId: string;
|
||||
}) {
|
||||
const stateDir = defaultStateDir(env);
|
||||
const safe = sanitizeApprovalId(approvalId);
|
||||
if (!safe) return;
|
||||
const indexPath = path.join(stateDir, `approval_${safe}.json`);
|
||||
try {
|
||||
await fsp.unlink(indexPath);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any approval index file that points to the given stateKey.
|
||||
* 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,
|
||||
}: {
|
||||
env: Record<string, string | undefined>;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const stateDir = defaultStateDir(env);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fsp.readdir(stateDir);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("approval_") || !file.endsWith(".json")) continue;
|
||||
try {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
await fsp.writeFile(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
export async function diffAndStore({ env, key, value }) {
|
||||
|
||||
@ -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,8 +0,0 @@
|
||||
import { Ajv } from "ajv";
|
||||
|
||||
export const sharedAjv = new Ajv({
|
||||
allErrors: false,
|
||||
strict: false,
|
||||
// User-provided schemas may repeat `$id` across runs/resumes.
|
||||
addUsedSchema: false,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,41 +1,37 @@
|
||||
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,
|
||||
@ -76,45 +72,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,
|
||||
@ -127,7 +123,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
|
||||
|
||||
if (summaryOnly) {
|
||||
return {
|
||||
kind: "github.pr.monitor",
|
||||
kind: 'github.pr.monitor',
|
||||
repo,
|
||||
prNumber: Number(pr),
|
||||
key,
|
||||
@ -144,7 +140,7 @@ export async function runGithubPrMonitorWorkflow({ args, ctx }) {
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "github.pr.monitor",
|
||||
kind: 'github.pr.monitor',
|
||||
repo,
|
||||
prNumber: Number(pr),
|
||||
key,
|
||||
@ -165,14 +161,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),
|
||||
|
||||
@ -1,253 +0,0 @@
|
||||
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);
|
||||
}
|
||||
118
src/workflows/openclaw_release.ts
Normal file
118
src/workflows/openclaw_release.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { parsePipeline } from '../parser.js';
|
||||
import { runPipeline } from '../runtime.js';
|
||||
|
||||
function assertStyle(value: unknown): 'sassy' | 'professional' | 'drybread' {
|
||||
const v = String(value ?? 'professional').trim().toLowerCase();
|
||||
if (v === 'sassy' || v === 'professional' || v === 'drybread') return v;
|
||||
return 'professional';
|
||||
}
|
||||
|
||||
function jsonEscape(s: string) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function buildContextShell({ repoDir, sinceRef, maxCommits, link, style }: any) {
|
||||
// Produce a single JSON object on stdout.
|
||||
// Uses jq for safe JSON construction.
|
||||
return [
|
||||
`cd '${jsonEscape(repoDir)}'`,
|
||||
`SINCE='${jsonEscape(sinceRef || `HEAD~${Number(maxCommits) || 30}`)}'`,
|
||||
`COMMITS=$(git log --no-merges --pretty=format:'%h %s (%an)' "$SINCE..HEAD" | head -n 80 | jq -R -s -c 'split("\\n")|map(select(length>0))')`,
|
||||
// Grab the topmost changelog section (first "## " header block)
|
||||
`CHANGELOG=$(node -e "const fs=require('fs'); const t=fs.readFileSync('CHANGELOG.md','utf8'); const parts=t.split(/\\n## /); const top=(parts.length>1?('## '+parts[1]).split(/\\n## /)[0]:t); process.stdout.write(top.slice(0,6000));")`,
|
||||
`jq -n --arg repo 'openclaw' --arg style '${jsonEscape(style)}' --arg since "$SINCE" --arg link '${jsonEscape(link)}' --argjson commits "$COMMITS" --arg changelog "$CHANGELOG" '{repo:$repo,style:$style,since:$since,link:$link,commits:$commits,changelog:$changelog}'`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
function buildTweetPrompt() {
|
||||
return (
|
||||
`You are writing a release tweet for OpenClaw.\n\n` +
|
||||
`Input JSON has: style (sassy|professional|drybread), since, link, commits[], changelog.\n\n` +
|
||||
`Write ONE tweet in the requested style.\n` +
|
||||
`Constraints:\n` +
|
||||
`- <= 260 characters\n` +
|
||||
`- Include the link exactly once (use the provided link field)\n` +
|
||||
`- Don\'t hallucinate features\n` +
|
||||
`- No hashtags unless truly helpful (max 1)\n\n` +
|
||||
`Return JSON: {"tweet":"...","style":"..."}.\n\n` +
|
||||
`INPUT:\n{{.}}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function runOpenclawReleaseTweetWorkflow({ args, ctx }: any) {
|
||||
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
|
||||
const style = assertStyle(args.style);
|
||||
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
|
||||
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
|
||||
const link = String(args.link ?? 'https://openclaw.dev');
|
||||
|
||||
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
|
||||
|
||||
const pipelineString = [
|
||||
`exec --json --shell '${jsonEscape(contextShell)}'`,
|
||||
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
|
||||
// Human approval (interactive by default; emits approval_request in tool/non-tty)
|
||||
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet?'`,
|
||||
// Output tweet object (for display or piping)
|
||||
`pick tweet,style`,
|
||||
].join(' | ');
|
||||
|
||||
const pipeline = parsePipeline(pipelineString);
|
||||
const output = await runPipeline({
|
||||
pipeline,
|
||||
registry: ctx.registry,
|
||||
input: [],
|
||||
stdin: ctx.stdin,
|
||||
stdout: ctx.stdout,
|
||||
stderr: ctx.stderr,
|
||||
env: ctx.env,
|
||||
mode: ctx.mode,
|
||||
});
|
||||
|
||||
// In human mode, approve passes items through. In tool mode, approve halts; cli will wrap.
|
||||
return {
|
||||
kind: 'openclaw.release.tweet',
|
||||
style,
|
||||
since: sinceRef || `HEAD~${maxCommits}`,
|
||||
items: output.items,
|
||||
halted: output.halted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runOpenclawReleasePostWorkflow({ args, ctx }: any) {
|
||||
const repoDir = String(args.repo_dir ?? args.repoDir ?? '../openclaw');
|
||||
const style = assertStyle(args.style);
|
||||
const sinceRef = String(args.since_ref ?? args.sinceRef ?? '').trim();
|
||||
const maxCommits = Number(args.max_commits ?? args.maxCommits ?? 30);
|
||||
const link = String(args.link ?? 'https://openclaw.dev');
|
||||
|
||||
const contextShell = buildContextShell({ repoDir, sinceRef, maxCommits, link, style });
|
||||
|
||||
const pipelineString = [
|
||||
`exec --json --shell '${jsonEscape(contextShell)}'`,
|
||||
`llm_task.invoke --schema '{"type":"object","properties":{"tweet":{"type":"string"},"style":{"type":"string"}},"required":["tweet","style"],"additionalProperties":false}' --prompt '${jsonEscape(buildTweetPrompt())}'`,
|
||||
`approve --preview-from-stdin --limit 1 --prompt 'Post this release tweet to X?'`,
|
||||
// Post to X via bird; uses stdin json array from previous stage.
|
||||
`exec --stdin json --shell 'T=$(cat | jq -r ".[0].tweet"); bird post --text "$T"; echo "{\\"posted\\":true}"' --json`,
|
||||
].join(' | ');
|
||||
|
||||
const pipeline = parsePipeline(pipelineString);
|
||||
const output = await runPipeline({
|
||||
pipeline,
|
||||
registry: ctx.registry,
|
||||
input: [],
|
||||
stdin: ctx.stdin,
|
||||
stdout: ctx.stdout,
|
||||
stderr: ctx.stderr,
|
||||
env: ctx.env,
|
||||
mode: ctx.mode,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: 'openclaw.release.post',
|
||||
style,
|
||||
since: sinceRef || `HEAD~${maxCommits}`,
|
||||
items: output.items,
|
||||
halted: output.halted,
|
||||
};
|
||||
}
|
||||
@ -1,44 +1,87 @@
|
||||
export const workflowRegistry = {
|
||||
"github.pr.monitor": {
|
||||
name: "github.pr.monitor",
|
||||
description: "Fetch PR state via gh, diff against last run, emit only on change.",
|
||||
'openclaw.release.tweet': {
|
||||
name: 'openclaw.release.tweet',
|
||||
description:
|
||||
'Generate an OpenClaw release tweet (sassy/professional/drybread) from commits + changelog, with approval.',
|
||||
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_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
|
||||
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
|
||||
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
|
||||
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
|
||||
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
|
||||
},
|
||||
required: ["repo", "pr"],
|
||||
required: [],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { repo: "openclaw/openclaw", pr: 1152 },
|
||||
description: "Monitor a PR and report when it changes.",
|
||||
args: { style: 'sassy', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
|
||||
description: 'Generate a sassy tweet from last 30 commits.',
|
||||
},
|
||||
],
|
||||
sideEffects: [],
|
||||
},
|
||||
"github.pr.monitor.notify": {
|
||||
name: "github.pr.monitor.notify",
|
||||
description: "Monitor a PR and emit a single human-friendly message when it changes.",
|
||||
'openclaw.release.post': {
|
||||
name: 'openclaw.release.post',
|
||||
description:
|
||||
'Generate an OpenClaw release tweet and (after approval) post to X via bird CLI.',
|
||||
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_dir: { type: 'string', description: 'Path to OpenClaw repo (default: ../openclaw)' },
|
||||
since_ref: { type: 'string', description: 'Start ref (tag/sha). Default: HEAD~max_commits' },
|
||||
max_commits: { type: 'number', description: 'Commit window when since_ref is empty (default: 30)' },
|
||||
link: { type: 'string', description: 'Release notes link to include (default: https://openclaw.dev)' },
|
||||
style: { type: 'string', description: 'sassy|professional|drybread (default: professional)' },
|
||||
},
|
||||
required: ["repo", "pr"],
|
||||
required: [],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { repo: "openclaw/openclaw", pr: 1152 },
|
||||
args: { style: 'professional', since_ref: 'HEAD~30', link: 'https://openclaw.dev' },
|
||||
description: 'Approve and post a professional tweet.',
|
||||
},
|
||||
],
|
||||
sideEffects: ['local_exec'],
|
||||
},
|
||||
'github.pr.monitor': {
|
||||
name: 'github.pr.monitor',
|
||||
description: 'Fetch PR state via gh, diff against last run, emit only on change.',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo: { type: 'string', description: 'owner/repo (e.g. clawdbot/clawdbot)' },
|
||||
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'],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { repo: 'clawdbot/clawdbot', 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.',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
repo: { type: 'string', description: 'owner/repo (e.g. clawdbot/clawdbot)' },
|
||||
pr: { type: 'number', description: 'Pull request number' },
|
||||
key: { type: 'string', description: 'Optional state key override.' },
|
||||
},
|
||||
required: ['repo', 'pr'],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
args: { repo: 'clawdbot/clawdbot', pr: 1152 },
|
||||
description: 'Emit "PR updated" message only when changed.',
|
||||
},
|
||||
],
|
||||
|
||||
@ -1,215 +0,0 @@
|
||||
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";
|
||||
|
||||
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",
|
||||
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");
|
||||
|
||||
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 });
|
||||
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");
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
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 });
|
||||
assert.equal(first.status, 0);
|
||||
const firstJson = JSON.parse(first.stdout);
|
||||
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,
|
||||
});
|
||||
assert.equal(resumed.status, 0, `stderr: ${resumed.stderr}`);
|
||||
const resumedJson = JSON.parse(resumed.stdout);
|
||||
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");
|
||||
});
|
||||
|
||||
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 firstJson = JSON.parse(first.stdout);
|
||||
const approvalId = firstJson.requiresApproval.approvalId;
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
// 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}`,
|
||||
);
|
||||
});
|
||||
|
||||
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 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");
|
||||
|
||||
// Step 2: Resume using --token (NOT --id)
|
||||
const resumed = runCli(
|
||||
["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");
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
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 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,
|
||||
});
|
||||
assert.equal(resumed.status, 0);
|
||||
const resumedJson = JSON.parse(resumed.stdout);
|
||||
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 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}`,
|
||||
);
|
||||
});
|
||||
|
||||
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 firstJson = JSON.parse(first.stdout);
|
||||
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"],
|
||||
{ LOBSTER_STATE_DIR: stateDir },
|
||||
);
|
||||
assert.equal(resumed.status, 0);
|
||||
const resumedJson = JSON.parse(resumed.stdout);
|
||||
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");
|
||||
const env = { LOBSTER_STATE_DIR: stateDir };
|
||||
|
||||
await writeApprovalIndex({
|
||||
env,
|
||||
stateKey: "workflow_resume_original",
|
||||
approvalId: "deadbeef",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
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");
|
||||
});
|
||||
@ -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('clawd.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('clawd.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('clawd.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('clawd.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,20 +110,17 @@ 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('clawd.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('clawd.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,37 +0,0 @@
|
||||
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("."),
|
||||
env: { ...process.env, ...(opts?.env ?? undefined) },
|
||||
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");
|
||||
|
||||
// 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");
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(String(res.stdout).trim());
|
||||
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,14 @@ 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('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);
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
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,194 +0,0 @@
|
||||
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";
|
||||
|
||||
function createDirectAdapter(resultText: string) {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
calls,
|
||||
adapter: {
|
||||
source: "test",
|
||||
async invoke({ payload }: { payload: Record<string, unknown> }) {
|
||||
calls.push(payload);
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runId: "adapter_1",
|
||||
model: "test/model",
|
||||
prompt: payload.prompt,
|
||||
status: "completed",
|
||||
output: {
|
||||
format: "json",
|
||||
text: resultText,
|
||||
data: JSON.parse(resultText),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("runToolRequest executes pipeline with injected llm adapter", async () => {
|
||||
const { adapter, calls } = createDirectAdapter('{"recommendation":"no jacket"}');
|
||||
const envelope = await runToolRequest({
|
||||
pipeline:
|
||||
'exec --json=true node -e "process.stdout.write(JSON.stringify({location:\'Phoenix\',temp_f:73.8}))" | llm.invoke --provider pi --prompt "Should I wear a jacket?" --disable-cache',
|
||||
ctx: {
|
||||
env: {
|
||||
...process.env,
|
||||
LOBSTER_LLM_PROVIDER: "pi",
|
||||
LOBSTER_LLM_MODEL: "test/model",
|
||||
},
|
||||
llmAdapters: {
|
||||
pi: adapter,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(envelope.ok, true);
|
||||
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(calls.length, 1);
|
||||
assert.equal((calls[0] as any).model, "test/model");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
await fsp.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
steps: [
|
||||
{
|
||||
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: "advice",
|
||||
pipeline: 'llm.invoke --provider pi --prompt "Return JSON." --disable-cache',
|
||||
stdin: "$fetch.json",
|
||||
when: "$confirm.approved",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
|
||||
LOBSTER_LLM_PROVIDER: "pi",
|
||||
LOBSTER_LLM_MODEL: "test/model",
|
||||
};
|
||||
|
||||
const first = await runToolRequest({
|
||||
filePath,
|
||||
ctx: {
|
||||
cwd: tmpDir,
|
||||
env,
|
||||
llmAdapters: { pi: adapter },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.status, "needs_approval");
|
||||
assert.ok(first.requiresApproval?.resumeToken);
|
||||
|
||||
const resumed = await resumeToolRequest({
|
||||
token: first.requiresApproval?.resumeToken ?? "",
|
||||
approved: true,
|
||||
ctx: {
|
||||
cwd: tmpDir,
|
||||
env,
|
||||
llmAdapters: { pi: adapter },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resumed.ok, true);
|
||||
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");
|
||||
|
||||
await fsp.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
steps: [
|
||||
{
|
||||
id: "draft",
|
||||
run: "node -e \"process.stdout.write(JSON.stringify({text:'hello'}))\"",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
input: {
|
||||
prompt: "Review draft?",
|
||||
responseSchema: {
|
||||
type: "object",
|
||||
properties: { decision: { type: "string" } },
|
||||
required: ["decision"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
|
||||
};
|
||||
|
||||
const first = await runToolRequest({
|
||||
filePath,
|
||||
ctx: { cwd: tmpDir, env },
|
||||
});
|
||||
|
||||
assert.equal(first.ok, true);
|
||||
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" },
|
||||
ctx: { cwd: tmpDir, env },
|
||||
});
|
||||
|
||||
assert.equal(resumed.ok, true);
|
||||
assert.equal(resumed.status, "ok");
|
||||
assert.deepEqual(resumed.output, [{ decision: "approve", subject: "hello" }]);
|
||||
});
|
||||
@ -1,147 +0,0 @@
|
||||
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,36 +15,34 @@ 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,24 +11,16 @@ 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);
|
||||
@ -36,16 +28,8 @@ 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);
|
||||
@ -53,16 +37,8 @@ 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,618 +0,0 @@
|
||||
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";
|
||||
|
||||
function runLobster(args: string[], opts?: { env?: Record<string, string | undefined> }) {
|
||||
const res = spawnSync(process.execPath, [path.join("bin", "lobster.js"), ...args], {
|
||||
cwd: path.resolve("."),
|
||||
env: { ...process.env, ...(opts?.env ?? undefined) },
|
||||
encoding: "utf8",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
getStdout: () => stdoutData,
|
||||
getStderr: () => stderrData,
|
||||
};
|
||||
}
|
||||
|
||||
test("dry-run of a 3-step workflow file (shell steps)", async () => {
|
||||
const workflow = {
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
const result = await runWorkflowFile({
|
||||
filePath,
|
||||
ctx: {
|
||||
stdin: process.stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
env: { ...process.env },
|
||||
mode: "human",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
assert.deepEqual(result.output, []);
|
||||
|
||||
const output = getStderr();
|
||||
assert.match(output, /\[DRY RUN\]/);
|
||||
assert.match(output, /Would execute 3 steps/);
|
||||
assert.match(output, /fetch-data\s+\[shell\]/);
|
||||
assert.match(output, /run: curl https:\/\/example\.com/);
|
||||
assert.match(output, /transform\s+\[shell\]/);
|
||||
assert.match(output, /upload\s+\[shell\]/);
|
||||
});
|
||||
|
||||
test("dry-run of a workflow with an approval step", async () => {
|
||||
const workflow = {
|
||||
steps: [
|
||||
{ id: "collect", run: "echo hello" },
|
||||
{
|
||||
id: "approve_step",
|
||||
run: "echo check",
|
||||
approval: "Proceed with deployment?",
|
||||
},
|
||||
{ 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 { stdout, stderr, getStderr } = createStreams();
|
||||
|
||||
const result = await runWorkflowFile({
|
||||
filePath,
|
||||
ctx: {
|
||||
stdin: process.stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
env: { ...process.env },
|
||||
mode: "tool",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
assert.deepEqual(result.output, []);
|
||||
|
||||
const output = getStderr();
|
||||
assert.match(output, /approve_step\s+\[shell\]/);
|
||||
assert.match(output, /\[approval required\]/);
|
||||
});
|
||||
|
||||
test("dry-run of a workflow with a conditional step that would be skipped", async () => {
|
||||
const workflow = {
|
||||
steps: [
|
||||
{ id: "gate", run: "echo gate" },
|
||||
{
|
||||
id: "conditional",
|
||||
run: "echo conditional",
|
||||
condition: false,
|
||||
},
|
||||
{ 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 { 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, /conditional\s+\[skipped — condition: false\]/);
|
||||
assert.match(output, /gate\s+\[shell\]/);
|
||||
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',
|
||||
]);
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
test("dry-run exits 0 on valid input", async () => {
|
||||
const workflow = {
|
||||
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 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.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}`);
|
||||
});
|
||||
|
||||
test("normal run still works (no regression)", async () => {
|
||||
const workflow = {
|
||||
steps: [
|
||||
{
|
||||
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 res = runLobster(["run", "--file", filePath]);
|
||||
|
||||
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" }]);
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
// 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 () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const workflow = {
|
||||
steps: [
|
||||
{ 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 { 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");
|
||||
assert.deepEqual(result.output, []);
|
||||
|
||||
const output = getStderr();
|
||||
assert.match(output, /fetch\s+\[shell\]/);
|
||||
assert.match(output, /process\s+\[pipeline\]/);
|
||||
assert.match(output, /pipeline: exec --json "echo \[1\]" \| head 1/);
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
/Unknown step reference: missing\.stdout/,
|
||||
);
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
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 { 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\]/);
|
||||
});
|
||||
@ -139,7 +139,7 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
|
||||
bodyLog.push(parsed);
|
||||
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
// OpenClaw tool router envelope -> llm-task tool envelope
|
||||
// Clawdbot tool router envelope -> llm-task tool envelope
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
@ -178,9 +178,7 @@ 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,
|
||||
@ -239,88 +237,3 @@ test("email.triage --llm uses llm_task.invoke to draft replies (and can emit dra
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test("email.triage --llm honors OPENCLAW_URL (not just CLAWD_URL)", async () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), "lobster-cache-"));
|
||||
|
||||
const emails = [
|
||||
{
|
||||
id: "m1",
|
||||
threadId: "t1",
|
||||
from: "Alice <alice@example.com>",
|
||||
subject: "Quick question",
|
||||
date: "2026-01-22T07:00:00Z",
|
||||
snippet: "Hey, can you take a look?",
|
||||
labels: ["INBOX", "UNREAD"],
|
||||
},
|
||||
];
|
||||
|
||||
let callCount = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/tools/invoke") {
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
|
||||
callCount++;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
result: {
|
||||
runId: "triage_openclaw_url",
|
||||
output: {
|
||||
data: {
|
||||
decisions: [
|
||||
{
|
||||
id: "m1",
|
||||
category: "needs_reply",
|
||||
reply: { body: "Absolutely — I can help." },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
|
||||
try {
|
||||
const input = (async function* () {
|
||||
for (const e of emails) yield e;
|
||||
})();
|
||||
|
||||
const result = await runPipeline({
|
||||
pipeline: [{ name: "email.triage", args: { llm: true, limit: 20 }, raw: "" }],
|
||||
registry,
|
||||
input,
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_URL: `http://127.0.0.1:${port}`,
|
||||
LOBSTER_CACHE_DIR: cacheDir,
|
||||
LLM_TASK_FORCE_REFRESH: "1",
|
||||
},
|
||||
mode: "tool",
|
||||
} as any);
|
||||
|
||||
assert.equal(callCount, 1);
|
||||
assert.equal(result.items.length, 1);
|
||||
assert.equal(result.items[0].mode, "llm");
|
||||
assert.equal(result.items[0].drafts.length, 1);
|
||||
} finally {
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
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,37 +15,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("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,185 +0,0 @@
|
||||
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";
|
||||
|
||||
function streamOf(items: any[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
})();
|
||||
}
|
||||
|
||||
async function collect(iterable: AsyncIterable<any>) {
|
||||
const items = [];
|
||||
for await (const item of iterable) items.push(item);
|
||||
return items;
|
||||
}
|
||||
|
||||
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 bodyLog: any[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/tools/invoke") {
|
||||
res.writeHead(404);
|
||||
res.end("nope");
|
||||
return;
|
||||
}
|
||||
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.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
ok: true,
|
||||
result: {
|
||||
runId: "invoke_1",
|
||||
model: parsed.args?.model,
|
||||
prompt: parsed.args?.prompt,
|
||||
output: { data: { summary: "hello" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
|
||||
try {
|
||||
const result = await cmd.run({
|
||||
input: streamOf([{ kind: "text", text: "doc" }]),
|
||||
args: {
|
||||
_: [],
|
||||
model: "claude-3-sonnet",
|
||||
prompt: "Summarize",
|
||||
},
|
||||
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(bodyLog.length, 1);
|
||||
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 () => {
|
||||
const registry = createDefaultRegistry();
|
||||
const cmd = registry.get("llm.invoke");
|
||||
assert.ok(cmd);
|
||||
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") {
|
||||
res.writeHead(404);
|
||||
res.end("nope");
|
||||
return;
|
||||
}
|
||||
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.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
runId: "pi_1",
|
||||
model: parsed.model,
|
||||
prompt: parsed.prompt,
|
||||
output: {
|
||||
format: "json",
|
||||
text: '{"decision":"reply"}',
|
||||
data: { decision: "reply" },
|
||||
},
|
||||
diagnostics: { adapter: "pi" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const result = await cmd.run({
|
||||
input: streamOf([{ kind: "text", text: "draft this" }]),
|
||||
args: {
|
||||
_: [],
|
||||
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_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, "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].artifacts.length, 1);
|
||||
} finally {
|
||||
await rm(cacheDir, { recursive: true, force: true });
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
function baseCtx(envOverrides: Record<string, string>, registry?: any) {
|
||||
return {
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
registry: registry ?? null,
|
||||
mode: "tool",
|
||||
render: { json() {}, lines() {} },
|
||||
};
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server) {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
@ -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,39 +62,36 @@ 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 {
|
||||
@ -103,15 +100,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;
|
||||
@ -124,38 +121,35 @@ 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 });
|
||||
@ -163,68 +157,65 @@ 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 });
|
||||
@ -232,35 +223,32 @@ 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}` };
|
||||
|
||||
@ -269,13 +257,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);
|
||||
|
||||
@ -283,14 +271,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 });
|
||||
@ -298,38 +286,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" });
|
||||
// This is the Clawdbot tool router envelope.
|
||||
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' } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
@ -339,34 +327,31 @@ 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 });
|
||||
@ -381,7 +366,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,31 +15,29 @@ 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,59 +1,41 @@
|
||||
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';
|
||||
|
||||
function runTool(pipeline, env) {
|
||||
const bin = path.join(process.cwd(), "bin", "lobster.js");
|
||||
const res = spawnSync("node", [bin, "run", "--mode", "tool", pipeline], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
function runTool(pipeline) {
|
||||
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
|
||||
const res = spawnSync('node', [bin, 'run', '--mode', 'tool', pipeline], { encoding: 'utf8' });
|
||||
assert.equal(res.status, 0);
|
||||
return JSON.parse(res.stdout);
|
||||
}
|
||||
|
||||
function resume(token, approve) {
|
||||
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
|
||||
const res = spawnSync('node', [bin, 'resume', '--token', token, '--approve', approve ? 'yes' : 'no'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(res.status, 0);
|
||||
return JSON.parse(res.stdout);
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
);
|
||||
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");
|
||||
const env = { LOBSTER_STATE_DIR: stateDir };
|
||||
|
||||
test('two approve gates can be resumed sequentially', () => {
|
||||
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?");
|
||||
const first = runTool(pipeline);
|
||||
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?");
|
||||
const second = resume(first.requiresApproval.resumeToken, true);
|
||||
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");
|
||||
const done = resume(second.requiresApproval.resumeToken, true);
|
||||
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_"));
|
||||
assert.deepEqual(pipelineResumeFiles, []);
|
||||
});
|
||||
|
||||
@ -1,194 +0,0 @@
|
||||
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 +0,0 @@
|
||||
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 () => {
|
||||
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");
|
||||
});
|
||||
@ -1,203 +0,0 @@
|
||||
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,45 +1,20 @@
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 parsed = JSON.parse(raw);
|
||||
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"}\'');
|
||||
assert.equal(p.length, 1);
|
||||
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"}\'');
|
||||
assert.equal(p.length, 1);
|
||||
const raw = p[0].args["args-json"];
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.equal(parsed.prompt, "don't");
|
||||
assert.deepEqual(p[0].args._, ['echo', 'a|b']);
|
||||
});
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { readLineFromStream } from "../src/read_line.js";
|
||||
|
||||
test("readLineFromStream resolves on newline", async () => {
|
||||
const input = new PassThrough();
|
||||
const promise = readLineFromStream(input);
|
||||
input.write("yes\n");
|
||||
input.end();
|
||||
|
||||
const value = await promise;
|
||||
assert.equal(value, "yes");
|
||||
});
|
||||
|
||||
test("readLineFromStream resolves on end without newline", async () => {
|
||||
const input = new PassThrough();
|
||||
const promise = readLineFromStream(input);
|
||||
input.write("partial");
|
||||
input.end();
|
||||
|
||||
const value = await promise;
|
||||
assert.equal(value, "partial");
|
||||
});
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user