Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e705c69ba8 | |||
| 8f0a15d906 | |||
|
|
849972ab5c | ||
|
|
d396760a4e | ||
|
|
6ec2c10924 | ||
|
|
d5f0e9f437 | ||
|
|
baddb2f13d | ||
|
|
1fe544c984 | ||
| 48c05db842 | |||
|
|
c34217e70d | ||
|
|
80bbce0955 | ||
|
|
df3aab97be | ||
|
|
0682fbf7cf | ||
|
|
bec9567f13 | ||
|
|
dbb7cbfc93 | ||
|
|
e75176db39 | ||
|
|
fc6a02ac1a | ||
|
|
35379f2fb0 | ||
|
|
f0ffa62f52 | ||
|
|
8aaae6a8db | ||
|
|
48dab93794 | ||
|
|
d4bb8b9ee1 | ||
|
|
96468db304 | ||
|
|
4cf4ae49af | ||
|
|
529965aa3b | ||
|
|
d887df0f64 | ||
|
|
d5ffc79098 | ||
|
|
ba7b447af6 | ||
|
|
cd3b645abe | ||
| 8c4dcb45bc | |||
|
|
8eab228b8a | ||
|
|
74b350b962 | ||
|
|
d3c36d9001 | ||
|
|
9d37a79960 | ||
|
|
1448a4c421 | ||
|
|
34be232931 | ||
|
|
c462ed6cce | ||
|
|
af9313bf14 | ||
|
|
e03f59c2a4 | ||
|
|
2695c38612 | ||
|
|
3bc137be6b | ||
|
|
1e0658e004 | ||
|
|
9bc52e7861 | ||
|
|
260380531b | ||
|
|
c8f379847b | ||
|
|
ccbad4934e | ||
|
|
961e8391db | ||
|
|
5e36cee987 | ||
|
|
c6ddcfd22b | ||
|
|
56845ab79f | ||
|
|
b68fc811c9 | ||
|
|
29e09701b8 | ||
|
|
5c686f95b2 | ||
|
|
0ee969ea16 | ||
|
|
097a62e2fa | ||
|
|
88b073d9d3 | ||
|
|
bbc21098f7 | ||
|
|
b9c05e3657 | ||
|
|
aa0c2323ea | ||
|
|
1d9887e77b | ||
|
|
6d481d3152 | ||
|
|
a8b75f0aa8 | ||
|
|
4d1f0aa429 | ||
|
|
06978b7dd4 | ||
|
|
74ea384374 | ||
|
|
ac3a285dc2 | ||
|
|
a89d8c3759 | ||
|
|
856c9a6f3d |
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@ -1,38 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Verify npm package contents
|
||||
run: pnpm pack:check
|
||||
406
.github/workflows/pipeline.yml
vendored
Normal file
406
.github/workflows/pipeline.yml
vendored
Normal file
@ -0,0 +1,406 @@
|
||||
name: Pipeline
|
||||
|
||||
# 单一流水线,三个串联 stage:build -> publish(npm) -> deploy。
|
||||
# build : 安装/测试/类型检查/包内容校验(PR 与 push 都跑)。
|
||||
# publish : 发布到 npm(仅 release / 版本 tag / 手动触发;needs build)。
|
||||
# deploy : SSH 安装到 ubuntu@openclaw.svc.plus(needs publish)。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Plugin version to install (e.g. 2026.6.1). Leave blank to use package.json."
|
||||
required: false
|
||||
default: ""
|
||||
force:
|
||||
description: "Reinstall even if the same version is already installed."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
|
||||
concurrency:
|
||||
group: pipeline-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ───────────────────────── Stage 1: build ─────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Verify npm package contents
|
||||
run: pnpm pack:check
|
||||
|
||||
# ──────────────────────── Stage 2: publish ────────────────────────
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: build
|
||||
if: >-
|
||||
github.event_name == 'release' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-openclaw-multi-session-plugins
|
||||
jwtGithubAudience: vault
|
||||
secrets: |
|
||||
kv/data/github-actions/openclaw-multi-session-plugins NPM_TOKEN | NPM_TOKEN
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve package metadata
|
||||
id: meta
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify npm publish access
|
||||
shell: bash
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ steps.vault.outputs.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
user="$(npm whoami 2>/dev/null || true)"
|
||||
if [ -z "${user}" ]; then
|
||||
echo "::error::NPM_TOKEN is not valid for npm publish. Create an npm automation token for an account that can publish ${name}, then store it in Vault as NPM_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
if npm view "${name}" name >/dev/null 2>&1; then
|
||||
echo "::notice::Publishing ${name}@${version} as npm user ${user}; package already exists."
|
||||
else
|
||||
echo "::notice::Publishing ${name}@${version} as npm user ${user}; npm will create this public package on first publish."
|
||||
fi
|
||||
|
||||
- name: Check published version
|
||||
id: published
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
if npm view "${name}@${version}" version >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "${name}@${version} is already published; skipping npm publish."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.published.outputs.exists != 'true'
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ steps.vault.outputs.NPM_TOKEN }}
|
||||
|
||||
# ───────────────────────── Stage 3: deploy ────────────────────────
|
||||
deploy:
|
||||
name: Update plugin on ubuntu@openclaw.svc.plus
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: openclaw-deploy
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
SSH_HOST: ubuntu@openclaw.svc.plus
|
||||
PLUGIN_NAME: openclaw-multi-session-plugins
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-openclaw-multi-session-plugins
|
||||
jwtGithubAudience: vault
|
||||
secrets: |
|
||||
kv/data/github-actions/openclaw-multi-session-plugins OPENCLAW_SSH_KEY | OPENCLAW_SSH_KEY ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins OPENCLAW_SSH_KEY_B64 | OPENCLAW_SSH_KEY_B64 ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins SINGLE_NODE_VPS_SSH_PRIVATE_KEY | SINGLE_NODE_VPS_SSH_PRIVATE_KEY ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 | SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64
|
||||
|
||||
- name: Resolve target version
|
||||
id: version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PUBLISH_VERSION: ${{ needs.publish.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_VERSION}" ]; then
|
||||
value="${INPUT_VERSION}"
|
||||
elif [ -n "${PUBLISH_VERSION}" ]; then
|
||||
value="${PUBLISH_VERSION}"
|
||||
else
|
||||
value="$(node -p "require('./package.json').version")"
|
||||
fi
|
||||
value="${value##*/}"
|
||||
value="${value#v}"
|
||||
if ! [[ "${value}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Resolved value '${value}' is not a valid X.Y.Z version"
|
||||
exit 1
|
||||
fi
|
||||
echo "value=${value}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved plugin version: ${value}"
|
||||
|
||||
- name: Resolve install source
|
||||
id: install
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
FORCE: ${{ inputs.force || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
if [ "${FORCE}" != "true" ] && npm view "${PACKAGE}" version >/dev/null 2>&1; then
|
||||
PUBLISHED="$(npm view "${PACKAGE}" version)"
|
||||
echo "::notice::${PLUGIN_NAME}@${PUBLISHED} is available on npm"
|
||||
echo "source=npm" >> "$GITHUB_OUTPUT"
|
||||
echo "install_spec=${PACKAGE}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
install_spec="/tmp/${PLUGIN_NAME}-${VERSION}-${GITHUB_SHA}.tgz"
|
||||
echo "::warning::Building and installing ${install_spec} from the checked-out source"
|
||||
echo "source=archive" >> "$GITHUB_OUTPUT"
|
||||
echo "install_spec=${install_spec}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Build archive install source
|
||||
id: archive
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm pack
|
||||
tarball="${PLUGIN_NAME}-${VERSION}.tgz"
|
||||
test -f "${tarball}"
|
||||
echo "tarball=${tarball}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
OPENCLAW_SSH_KEY: ${{ steps.vault.outputs.OPENCLAW_SSH_KEY }}
|
||||
OPENCLAW_SSH_KEY_B64: ${{ steps.vault.outputs.OPENCLAW_SSH_KEY_B64 }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSH_KEY=""
|
||||
if [ -n "${OPENCLAW_SSH_KEY_B64:-}" ]; then
|
||||
SSH_KEY="$(printf '%s' "${OPENCLAW_SSH_KEY_B64}" | base64 -d)"
|
||||
elif [ -n "${OPENCLAW_SSH_KEY:-}" ]; then
|
||||
SSH_KEY="${OPENCLAW_SSH_KEY}"
|
||||
fi
|
||||
if [ -z "${SSH_KEY}" ] && [ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]; then
|
||||
SSH_KEY="$(printf '%s' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64}" | base64 -d)"
|
||||
elif [ -z "${SSH_KEY}" ] && [ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}" ]; then
|
||||
SSH_KEY="${SINGLE_NODE_VPS_SSH_PRIVATE_KEY}"
|
||||
fi
|
||||
|
||||
if [ -z "${SSH_KEY}" ]; then
|
||||
echo "::error::Neither OPENCLAW_SSH_KEY nor SINGLE_NODE_VPS_SSH_PRIVATE_KEY is set."
|
||||
exit 1
|
||||
fi
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "${SSH_KEY}" > ~/.ssh/openclaw_ed25519
|
||||
chmod 600 ~/.ssh/openclaw_ed25519
|
||||
ssh-keyscan -H openclaw.svc.plus >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Verify SSH connectivity
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
"${SSH_HOST}" 'echo "connected to $(hostname) as $(whoami)"'
|
||||
|
||||
- name: Upload archive install source
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
run: |
|
||||
scp -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes \
|
||||
"${{ steps.archive.outputs.tarball }}" "${SSH_HOST}:${{ steps.install.outputs.install_spec }}"
|
||||
|
||||
- name: Install or update plugin on remote host
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
INSTALL_SPEC: ${{ steps.install.outputs.install_spec }}
|
||||
INSTALL_SOURCE: ${{ steps.install.outputs.source }}
|
||||
FORCE: ${{ inputs.force || 'false' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ServerAliveInterval=30 \
|
||||
"${SSH_HOST}" bash -s -- "${PLUGIN_NAME}" "${VERSION}" "${INSTALL_SPEC}" "${INSTALL_SOURCE}" "${FORCE}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
PLUGIN_NAME="$1"
|
||||
VERSION="$2"
|
||||
INSTALL_SPEC="$3"
|
||||
INSTALL_SOURCE="$4"
|
||||
FORCE="$5"
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
STATE_DIR="/tmp/openclaw-deploy"
|
||||
mkdir -p "${STATE_DIR}"
|
||||
|
||||
echo "==> Installing ${PACKAGE} from ${INSTALL_SOURCE} on $(hostname) (force=${FORCE})"
|
||||
echo "==> Install spec: ${INSTALL_SPEC}"
|
||||
|
||||
# Record the previously installed version for rollback.
|
||||
PREVIOUS_VERSION=""
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
PREVIOUS_VERSION="$(npm ls -g "${PLUGIN_NAME}" --depth=0 2>/dev/null \
|
||||
| awk -F'[@:]' '/'"${PLUGIN_NAME}"'@/ {print $2; exit}' || true)"
|
||||
fi
|
||||
echo "==> Previously installed version: ${PREVIOUS_VERSION:-<none>}"
|
||||
|
||||
# Skip when the requested version is already present unless forced.
|
||||
if [ "${FORCE}" != "true" ] && [ "${PREVIOUS_VERSION}" = "${VERSION}" ]; then
|
||||
echo "==> ${PACKAGE} already installed and force=false; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "${PREVIOUS_VERSION}" > "${STATE_DIR}/previous-version"
|
||||
|
||||
rollback() {
|
||||
local rc=$?
|
||||
echo "::remote-error::Install failed (exit ${rc}); attempting rollback"
|
||||
local prev
|
||||
prev="$(cat "${STATE_DIR}/previous-version" 2>/dev/null || true)"
|
||||
if [ -n "${prev}" ] && [ "${prev}" != "${VERSION}" ]; then
|
||||
echo "::remote-warning::Reinstalling ${PLUGIN_NAME}@${prev}"
|
||||
npm install -g "${PLUGIN_NAME}@${prev}" || true
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins enable "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
else
|
||||
echo "::remote-warning::No previous version recorded; leaving host as-is"
|
||||
fi
|
||||
exit "${rc}"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
install_plugin() {
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins install --force "${INSTALL_SPEC}" \
|
||||
|| openclaw plugins install "${INSTALL_SPEC}" \
|
||||
|| openclaw plugins update "${INSTALL_SPEC}" \
|
||||
|| npm install -g "${INSTALL_SPEC}"
|
||||
else
|
||||
npm install -g "${INSTALL_SPEC}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_plugin
|
||||
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins enable "${PLUGIN_NAME}" || true
|
||||
systemctl --user restart openclaw-gateway.service || true
|
||||
fi
|
||||
|
||||
# Verify the installed version matches the requested version.
|
||||
GLOBAL_ROOT="$(npm root -g)"
|
||||
INSTALLED=""
|
||||
if [ -f "${GLOBAL_ROOT}/${PLUGIN_NAME}/package.json" ]; then
|
||||
INSTALLED="$(node -p "require('${GLOBAL_ROOT}/${PLUGIN_NAME}/package.json').version")"
|
||||
fi
|
||||
if [ "${INSTALLED}" != "${VERSION}" ]; then
|
||||
echo "::remote-error::Verification failed: expected ${VERSION}, found ${INSTALLED:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
rm -f "${STATE_DIR}/previous-version"
|
||||
|
||||
echo "==> Installed plugin state:"
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins info "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
npm ls -g "${PLUGIN_NAME}" || true
|
||||
echo "==> ${PACKAGE} is now active on $(hostname)"
|
||||
REMOTE
|
||||
|
||||
- name: Summarize deploy
|
||||
if: always()
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "::notice::openclaw-multi-session-plugins@${VERSION} deployed to ubuntu@openclaw.svc.plus"
|
||||
else
|
||||
echo "::error::Deploy to ubuntu@openclaw.svc.plus failed for openclaw-multi-session-plugins@${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
58
.github/workflows/publish.yml
vendored
58
.github/workflows/publish.yml
vendored
@ -1,58 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Check published version
|
||||
id: published
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
if npm view "${name}@${version}" version >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "${name}@${version} is already published; skipping npm publish."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.published.outputs.exists != 'true'
|
||||
run: npm publish --provenance
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
107
.github/workflows/runtime-release.yaml
vendored
Normal file
107
.github/workflows/runtime-release.yaml
vendored
Normal file
@ -0,0 +1,107 @@
|
||||
name: Build OpenClaw Plugin Runtime Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/**]
|
||||
paths:
|
||||
- "**/*.ts"
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- openclaw.plugin.json
|
||||
- .github/workflows/runtime-release.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: openclaw-plugin-runtime-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Plugin Assets
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
|
||||
- name: Build runtime asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="dist/runtime/openclaw-multi-session-plugins"
|
||||
mkdir -p "${root}/dist" dist/assets
|
||||
|
||||
# Maintain necessary file structure required by openclaw loader
|
||||
cp -a dist/index.js dist/index.d.ts dist/src "${root}/dist/"
|
||||
cp openclaw.plugin.json package.json "${root}/"
|
||||
|
||||
tar -czf "dist/assets/openclaw-multi-session-plugins-runtime-all.tar.gz" \
|
||||
-C dist/runtime openclaw-multi-session-plugins
|
||||
|
||||
(
|
||||
cd dist/assets
|
||||
sha256sum -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-all"
|
||||
)
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openclaw-plugin-assets
|
||||
path: |
|
||||
dist/assets/*.tar.gz
|
||||
dist/assets/SHA256SUMS-*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: openclaw-plugin-assets
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS || true
|
||||
rm -f dist/SHA256SUMS-*
|
||||
|
||||
# Publish (or refresh) a release with the runtime tarball + checksums.
|
||||
# --latest=false keeps GitHub's "Latest release" pointer free for the
|
||||
# human-facing v* tags; deployments pull via explicit tag URLs instead.
|
||||
publish_release() {
|
||||
local tag="$1" title="$2"
|
||||
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
else
|
||||
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--target "${GITHUB_SHA}" \
|
||||
--latest=false \
|
||||
--title "${title}" \
|
||||
--notes "Prebuilt Plugin assets. No target-host build or Nix profile installation required."
|
||||
fi
|
||||
}
|
||||
|
||||
# Immutable per-commit release for traceability.
|
||||
publish_release "runtime-${GITHUB_SHA::12}" "OpenClaw Plugin runtime ${GITHUB_SHA::12}"
|
||||
# Stable moving release so deployments resolve a deterministic URL
|
||||
# (releases/download/runtime-latest/...) instead of the mutable
|
||||
# /releases/latest/ pointer, which collides with other release tracks.
|
||||
publish_release "runtime-latest" "OpenClaw Plugin runtime (latest)"
|
||||
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Validate Release PR
|
||||
|
||||
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
|
||||
# 详见 iac_modules/docs/tldr-github-branch-model.md
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-release-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.base_ref, 'release/')
|
||||
steps:
|
||||
- name: Check PR source branch
|
||||
run: |
|
||||
SRC="${{ github.head_ref }}"
|
||||
TGT="${{ github.base_ref }}"
|
||||
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
echo "🔍 Validating PR into release branch"
|
||||
echo " source: $SRC"
|
||||
echo " target: $TGT"
|
||||
echo " labels: $LABELS"
|
||||
|
||||
if [[ "$SRC" =~ ^hotfix/ ]]; then
|
||||
echo "✅ Allowed: hotfix/* branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
|
||||
echo "✅ Allowed: cherry-pick/backport labeled PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Rejected."
|
||||
echo "release/* 仅接受:"
|
||||
echo " - 来自 hotfix/* 的 PR"
|
||||
echo " - 带 cherry-pick 或 backport 标签的 PR(已验证 feature 的 backport/cherry-pick)"
|
||||
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
|
||||
exit 1
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,3 +1,11 @@
|
||||
node_modules/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
repomix-output.xml
|
||||
openclaw-multi-session-plugins-*.tgz
|
||||
|
||||
# Runtime release build artifacts (produced by the runtime-release workflow /
|
||||
# local packaging; published to GitHub Releases, not committed). The compiled
|
||||
# library under dist/ and dist/src/ stays tracked.
|
||||
dist/assets/
|
||||
dist/runtime/
|
||||
|
||||
165
README.md
165
README.md
@ -1,40 +1,59 @@
|
||||
# xworkmate-artifacts
|
||||
# openclaw-multi-session-plugins
|
||||
|
||||
OpenClaw Gateway plugin that exports structured workspace artifact manifests for XWorkmate.
|
||||
OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling.
|
||||
|
||||
## Why
|
||||
|
||||
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
|
||||
`/gateway/openclaw` task contract. The bridge sends `chat.send`, waits for
|
||||
`agent.wait`, then asks this plugin for a structured artifact manifest. The APP
|
||||
can then sync generated files into its local thread workspace without changing
|
||||
the UI or adding provider-specific routes.
|
||||
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing
|
||||
`/acp` and `/acp/rpc` contract with OpenClaw routing metadata. The bridge sends
|
||||
`chat.send`, waits for `agent.wait`, then asks this plugin for a session/run-scoped artifact manifest.
|
||||
The app can then sync generated files into its local thread workspace without
|
||||
changing the UI or adding provider-specific routes.
|
||||
|
||||
It registers three Gateway methods:
|
||||
This plugin is not a scheduler or bridge client. OpenClaw core owns sub-agents,
|
||||
multi-agent routing, queues, cron, task registry state, and cross-session
|
||||
execution. This package only adapts existing OpenClaw task and session
|
||||
identities into isolated artifact directories, durable session key mappings,
|
||||
and signed artifact reads.
|
||||
|
||||
In practice, it provides:
|
||||
|
||||
- session preparation for a specific app thread and run
|
||||
- task-scoped artifact directories under the resolved OpenClaw workspace
|
||||
- safe export and read operations for XWorkmate Bridge
|
||||
- signed artifact references that are bound to the issuing session and run
|
||||
|
||||
It registers the minimal Gateway methods needed by XWorkmate:
|
||||
|
||||
```text
|
||||
xworkmate.session.prepare
|
||||
xworkmate.tasks.get
|
||||
xworkmate.artifacts.collect-and-snapshot
|
||||
xworkmate.artifacts.export
|
||||
xworkmate.artifacts.list
|
||||
xworkmate.artifacts.read
|
||||
```
|
||||
|
||||
The method scans the resolved OpenClaw workspace after a run finishes and returns safe, relative artifact entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract.
|
||||
`xworkmate.session.prepare` writes the durable
|
||||
`SessionEntry.pluginExtensions["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"]`
|
||||
mapping and creates a per-task artifact scope under `tasks/` in the resolved
|
||||
OpenClaw workspace. `export` and `read` then return safe, relative artifact
|
||||
entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract.
|
||||
|
||||
## Install
|
||||
|
||||
Install from the npm package through OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install xworkmate-artifacts
|
||||
openclaw plugins enable xworkmate-artifacts
|
||||
openclaw plugins install openclaw-multi-session-plugins
|
||||
openclaw plugins enable openclaw-multi-session-plugins
|
||||
```
|
||||
|
||||
Or install from a Git checkout for development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/x-evor/xworkmate-artifacts.git
|
||||
openclaw plugins install --link ./xworkmate-artifacts
|
||||
openclaw plugins enable xworkmate-artifacts
|
||||
git clone https://github.com/x-evor/openclaw-multi-session-plugins.git
|
||||
openclaw plugins install --link ./openclaw-multi-session-plugins
|
||||
openclaw plugins enable openclaw-multi-session-plugins
|
||||
```
|
||||
|
||||
Equivalent config shape for a linked checkout:
|
||||
@ -44,11 +63,11 @@ Equivalent config shape for a linked checkout:
|
||||
"plugins": {
|
||||
"load": {
|
||||
"paths": [
|
||||
"/path/to/xworkmate-artifacts"
|
||||
"/path/to/openclaw-multi-session-plugins"
|
||||
]
|
||||
},
|
||||
"entries": {
|
||||
"xworkmate-artifacts": {
|
||||
"openclaw-multi-session-plugins": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@ -58,33 +77,74 @@ Equivalent config shape for a linked checkout:
|
||||
|
||||
## Contract
|
||||
|
||||
Request params:
|
||||
Prepare request params are supplied by the OpenClaw host, bridge, or APP
|
||||
runtime. On OpenClaw runtimes that expose a trusted plugin `sessionScope`, the
|
||||
plugin uses that native scope first and maps native `sessionScope.sessionKey`
|
||||
to `openclawSessionKey` internally. External Gateway callers must use typed
|
||||
`appThreadKey`, `openclawSessionKey`, `runId`, and optional `workspaceDir`
|
||||
params. Legacy `sessionKey` is not accepted as a Gateway task or artifact lookup
|
||||
alias. The plugin does not parse paths from chat text and does not invent
|
||||
fallback session/run identities. The optional agent tool does not expose these
|
||||
fields to the model; it only uses host-injected tool context.
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"appThreadKey": "draft:thread-main",
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"runId": "turn-1",
|
||||
"workspaceDir": "/home/user/.openclaw/workspace"
|
||||
}
|
||||
```
|
||||
|
||||
Prepare response payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"scopeKind": "task",
|
||||
"artifactDirectory": "/home/user/.openclaw/workspace/tasks/thread-main-.../turn-1-...",
|
||||
"relativeArtifactDirectory": "tasks/thread-main-.../turn-1-...",
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Export request params:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"runId": "turn-1",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
"maxFiles": 64,
|
||||
"maxInlineBytes": 10485760
|
||||
}
|
||||
```
|
||||
|
||||
Response payload:
|
||||
Export response payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "thread-main",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"scopeKind": "task",
|
||||
"artifacts": [
|
||||
{
|
||||
"relativePath": "reports/final.md",
|
||||
"label": "final.md",
|
||||
"contentType": "text/markdown",
|
||||
"sizeBytes": 1234,
|
||||
"sha256": "..."
|
||||
"sha256": "...",
|
||||
"artifactRef": "...",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"scopeKind": "task"
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
@ -92,6 +152,25 @@ Response payload:
|
||||
```
|
||||
|
||||
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
|
||||
When `artifactScope` is omitted, export/list defaults to the current task scope
|
||||
derived from `openclawSessionKey/runId` for Gateway calls, or from native
|
||||
`sessionScope.sessionKey/runId` for host-injected tool calls. `sinceUnixMs` is
|
||||
only a filter inside that task scope. The prepared task scope remains
|
||||
authoritative: when it contains files, the plugin exports only that scope.
|
||||
|
||||
If the prepared task scope is empty, trusted Gateway callers may pass
|
||||
`expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then
|
||||
scans only those explicit workspace-root subdirectories and labels the exported
|
||||
files with the current task `artifactScope`. It never performs a broad workspace
|
||||
root scan, never scans `owners/*/threads/*`, and does not borrow artifacts from
|
||||
earlier task scopes.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
`artifactScope + relativePath` for the current `openclawSessionKey/runId` task
|
||||
scope. Signed task `artifactRef` values are accepted only for the same
|
||||
`openclawSessionKey/runId` that issued them. There is no unscoped arbitrary
|
||||
workspace read API.
|
||||
|
||||
## View And Download
|
||||
|
||||
@ -105,7 +184,7 @@ show a quick artifact table:
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": ["xworkmate_artifacts"]
|
||||
"allow": ["openclaw_multi_session_artifacts"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -120,20 +199,38 @@ local users can open or download them directly from that workspace path.
|
||||
|
||||
Gateway clients can use:
|
||||
|
||||
- `xworkmate.session.prepare` before `chat.send` with typed
|
||||
`schemaVersion`, `appThreadKey`, `openclawSessionKey`, `runId`, and
|
||||
`expectedArtifactDirs` to allocate a task artifact directory and persist the
|
||||
app/OpenClaw session mapping.
|
||||
- Keep the prepared `artifactScope`/`artifactDirectory` in the gateway artifact
|
||||
pipeline, not in `chat.send` params. If `chat.send` returns a different
|
||||
OpenClaw `runId`, prepare/export with that actual `runId` instead of the
|
||||
bridge request id.
|
||||
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
|
||||
- `xworkmate.artifacts.read` with `relativePath` for one inline base64 file.
|
||||
- `xworkmate.artifacts.export` after `agent.wait` for the XWorkmate APP sync path.
|
||||
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
|
||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file.
|
||||
- `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope.
|
||||
- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. Pass `expectedArtifactDirs` when the task contract declares root-level delivery directories.
|
||||
- `xworkmate.tasks.get` to read the OpenClaw native task state for a run and return the current artifact export in the same payload.
|
||||
|
||||
Large files are intentionally metadata-only in v1. XWorkmate Bridge can add a
|
||||
hosted artifact cache/download endpoint later if remote APP clients need direct
|
||||
links for large PPT/PDF/DOCX files.
|
||||
Large files are metadata-only in the export payload, but XWorkmate Bridge can
|
||||
generate its own signed download URL and call `xworkmate.artifacts.read` as the
|
||||
only remote file access path.
|
||||
|
||||
## Limits
|
||||
|
||||
- Only files inside the resolved OpenClaw workspace are exported.
|
||||
- `.git`, `.openclaw`, `.pi`, build outputs, and dependency folders are skipped.
|
||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, transient framework state, and dependency folders are excluded from task artifact exports.
|
||||
- `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively.
|
||||
- Export scans workspace-root files only from explicit `expectedArtifactDirs`, only when the prepared task scope is empty, and never from OpenClaw owner/thread workspaces.
|
||||
- Symlinks are skipped to avoid workspace escape.
|
||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||
- `export` and `list` default to the current task scope when `artifactScope` is omitted.
|
||||
- Direct `artifactScope + relativePath` reads and scoped exports must match the supplied `sessionKey/runId`.
|
||||
- `artifactRef` is bound to the issued session/run and cannot be reused from another run.
|
||||
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
|
||||
|
||||
## Development
|
||||
|
||||
@ -143,3 +240,13 @@ pnpm test
|
||||
pnpm typecheck
|
||||
pnpm pack:check
|
||||
```
|
||||
|
||||
### Coding standards
|
||||
|
||||
- **No unused exports.** Functions and types that are only used within the same file must not be exported. An `export` keyword signals a public API surface that downstream consumers may depend on.
|
||||
- **No legacy fallback chains.** When renaming config keys or environment variables, remove the old name from the codebase. Multiple fallback paths to the same dependent service (e.g., two env vars for the same secret) create confusion and mask configuration errors.
|
||||
- **No hardcoded model identifiers** (e.g., kimi-k2.5, minimax-m2.7, glm-5). Model selection must come from configuration or the bridge.
|
||||
- **No silent error swallowing.** Every `catch` block must log, warn, rethrow, or return a meaningful fallback. Empty `catch` and `.catch(() => {})` are forbidden.
|
||||
- **No redundant indirection.** If function A only calls B which only calls C with no added logic, inline or remove the middle function.
|
||||
- **No stale config references.** Scripts in `package.json`, CI workflows, and documentation must reference only tooling that still exists in the project.
|
||||
- **Multi-agent references** in bridge protocol parameters (`multiAgent: true`, `mode: "multi-agent"`) are legitimate protocol constants and are not dead code. However, framework-level ARIS or internal multi-agent orchestration code that duplicates bridge functionality must be removed.
|
||||
|
||||
8
dist/index.d.ts
vendored
8
dist/index.d.ts
vendored
@ -1,9 +1,9 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
export declare function lastAssistantText(messages: unknown): string | undefined;
|
||||
declare const plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
register: typeof register;
|
||||
};
|
||||
configSchema: import("openclaw/plugin-sdk/core").OpenClawPluginConfigSchema;
|
||||
register: NonNullable<import("openclaw/plugin-sdk/core").OpenClawPluginDefinition["register"]>;
|
||||
} & Pick<import("openclaw/plugin-sdk/core").OpenClawPluginDefinition, "kind" | "reload" | "nodeHostCommands" | "securityAuditCollectors">;
|
||||
export default plugin;
|
||||
declare function register(api: OpenClawPluginApi): void;
|
||||
|
||||
239
dist/index.js
vendored
239
dist/index.js
vendored
@ -1,16 +1,193 @@
|
||||
import { exportXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
|
||||
const plugin = {
|
||||
id: "xworkmate-artifacts",
|
||||
name: "XWorkmate Artifacts",
|
||||
description: "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.",
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
|
||||
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, recordXWorkmateTaskRunStarted, recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||
function scopedGatewayParams(params) {
|
||||
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
||||
const runScope = resolveRunScope({ sessionScope });
|
||||
if (!runScope) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
openclawSessionKey: runScope.sessionKey,
|
||||
runId: runScope.runId,
|
||||
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
|
||||
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
};
|
||||
}
|
||||
function resolveRunScope(ctx) {
|
||||
const scope = ctx.sessionScope;
|
||||
const sessionKey = scope?.sessionKey || ctx.sessionKey;
|
||||
const runId = scope?.runId || ctx.runId || "";
|
||||
if (!sessionKey || !runId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionKey,
|
||||
runId,
|
||||
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
|
||||
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
|
||||
};
|
||||
}
|
||||
function stringParam(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
export function lastAssistantText(messages) {
|
||||
if (!Array.isArray(messages))
|
||||
return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object")
|
||||
continue;
|
||||
const record = message;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant")
|
||||
continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim())
|
||||
return content.trim();
|
||||
if (!Array.isArray(content))
|
||||
continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object")
|
||||
return "";
|
||||
const item = block;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text)
|
||||
return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const plugin = definePluginEntry({
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
description: "OpenClaw logical isolation support for multi-session plugin runtimes and scoped XWorkmate artifacts.",
|
||||
register,
|
||||
};
|
||||
});
|
||||
export default plugin;
|
||||
function register(api) {
|
||||
registerXWorkmateSessionExtension(api);
|
||||
api.registerHook("session_start", async (event) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(event?.context ?? event);
|
||||
const openclawSessionKey = stringParam(params.openclawSessionKey);
|
||||
if (openclawSessionKey && params.runId) {
|
||||
const hookParams = { ...params, openclawSessionKey };
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: hookParams,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: hookParams,
|
||||
artifactScope: prepared.artifactScope,
|
||||
source: "session_start",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
api.logger?.warn?.(`xworkmate session_start preparation failed: ${String(error)}`);
|
||||
}
|
||||
}, { name: "openclaw-multi-session-plugins.session-start" });
|
||||
api.on("agent_end", async (event, ctx) => {
|
||||
try {
|
||||
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
|
||||
const runId = stringParam(event?.runId ?? ctx?.runId);
|
||||
if (!openclawSessionKey || !runId) {
|
||||
return;
|
||||
}
|
||||
await recordXWorkmateTaskRunTerminal({
|
||||
api,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
success: event?.success === true,
|
||||
output: lastAssistantText(event?.messages),
|
||||
error: event?.error,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
api.logger?.warn?.(`xworkmate agent_end state capture failed: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.session.prepare", async (opts) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(opts.params);
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params,
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
const payload = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(true, {
|
||||
...payload,
|
||||
mapping,
|
||||
appThreadKey: mapping.appThreadKey,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
}, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: String(error).includes("conflict") ? "CONFLICT" : "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => {
|
||||
try {
|
||||
const payload = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
params: opts.params,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts) => {
|
||||
try {
|
||||
const payload = await collectAndSnapshotXWorkmateArtifacts({
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -26,7 +203,7 @@ function register(api) {
|
||||
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
params: { ...opts.params, includeContent: false },
|
||||
params: { ...scopedGatewayParams(opts.params), includeContent: false },
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -42,7 +219,7 @@ function register(api) {
|
||||
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts) => {
|
||||
try {
|
||||
const payload = await readXWorkmateArtifact({
|
||||
params: opts.params,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -56,14 +233,14 @@ function register(api) {
|
||||
}
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||
names: ["xworkmate_artifacts"],
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
function createXWorkmateArtifactsTool(api, ctx) {
|
||||
return {
|
||||
name: "xworkmate_artifacts",
|
||||
label: "XWorkmate Artifacts",
|
||||
name: "openclaw_multi_session_artifacts",
|
||||
label: "openclaw-multi-session-plugins",
|
||||
description: "List generated artifacts in the current OpenClaw workspace or read one small artifact as base64 for XWorkmate.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
@ -76,7 +253,15 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
relativePath: {
|
||||
type: "string",
|
||||
description: "Artifact path relative to the workspace. Required for action=read.",
|
||||
description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
|
||||
},
|
||||
artifactScope: {
|
||||
type: "string",
|
||||
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
|
||||
},
|
||||
artifactRef: {
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Bound to the issuing task scope.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
@ -95,11 +280,23 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const runScope = resolveRunScope(ctx);
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
const { sessionKey: _ignoredSessionKey, openclawSessionKey: _ignoredOpenclawSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
|
||||
const baseParams = {
|
||||
...params,
|
||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
...operationParams,
|
||||
openclawSessionKey: sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
};
|
||||
if (action === "list") {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
@ -107,7 +304,7 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
config: ctx.config ?? api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
return { content: [{ type: "text", text: payload.manifestMarkdown }] };
|
||||
return { content: [{ type: "text", text: formatArtifactManifestMarkdown(payload) }], details: {} };
|
||||
}
|
||||
if (action === "read") {
|
||||
const payload = await readXWorkmateArtifact({
|
||||
@ -118,14 +315,14 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
const artifact = payload.artifacts[0];
|
||||
const text = artifact
|
||||
? [
|
||||
payload.manifestMarkdown,
|
||||
formatArtifactManifestMarkdown(payload),
|
||||
"",
|
||||
artifact.content
|
||||
? `Base64 content for \`${artifact.relativePath}\`:\n\n\`\`\`base64\n${artifact.content}\n\`\`\``
|
||||
: `\`${artifact.relativePath}\` is larger than maxInlineBytes; use the workspace path to download it directly.`,
|
||||
].join("\n")
|
||||
: payload.manifestMarkdown;
|
||||
return { content: [{ type: "text", text }] };
|
||||
: formatArtifactManifestMarkdown(payload);
|
||||
return { content: [{ type: "text", text }], details: {} };
|
||||
}
|
||||
throw new Error("action must be list or read");
|
||||
},
|
||||
|
||||
1
dist/src/expectedArtifactDirs.d.ts
vendored
Normal file
1
dist/src/expectedArtifactDirs.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
|
||||
33
dist/src/expectedArtifactDirs.js
vendored
Normal file
33
dist/src/expectedArtifactDirs.js
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
function safeExpectedArtifactDir(value) {
|
||||
const relativePath = optionalString(value);
|
||||
if (!relativePath) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/u.test(relativePath) || relativePath.startsWith("/") || relativePath.includes("\0")) {
|
||||
throw new Error("expectedArtifactDir must stay inside the workspace");
|
||||
}
|
||||
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
|
||||
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
|
||||
throw new Error("expectedArtifactDir must stay inside the workspace");
|
||||
}
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
}
|
||||
export function normalizeExpectedArtifactDirs(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const entry of value) {
|
||||
const normalized = safeExpectedArtifactDir(entry);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
52
dist/src/exportArtifacts.d.ts
vendored
52
dist/src/exportArtifacts.d.ts
vendored
@ -1,20 +1,62 @@
|
||||
export type XWorkmateArtifact = {
|
||||
type XWorkmateArtifact = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
artifactRef: string;
|
||||
artifactScope?: string;
|
||||
scopeKind?: XWorkmateArtifactScopeKind;
|
||||
encoding?: "base64";
|
||||
content?: string;
|
||||
};
|
||||
export type XWorkmateArtifactExport = {
|
||||
type XWorkmateArtifactScopeKind = "task";
|
||||
type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
remoteWorkspaceRefKind: "remotePath";
|
||||
artifactScope?: string;
|
||||
scopeKind: XWorkmateArtifactScopeKind;
|
||||
artifacts: XWorkmateArtifact[];
|
||||
warnings: string[];
|
||||
manifestMarkdown: string;
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
constraintSatisfied: boolean;
|
||||
missingRequiredExtensions: string[];
|
||||
missingRequiredFileCounts: Record<string, {
|
||||
expected: number;
|
||||
actual: number;
|
||||
}>;
|
||||
};
|
||||
type XWorkmateArtifactPrepare = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
remoteWorkspaceRefKind: "remotePath";
|
||||
artifactScope: string;
|
||||
scopeKind: "task";
|
||||
artifactDirectory: string;
|
||||
relativeArtifactDirectory: string;
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
};
|
||||
type XWorkmateExpectedArtifactDirStatus = {
|
||||
relativePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
type XWorkmateArtifactSnapshot = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
remoteWorkspaceRefKind: "remotePath";
|
||||
artifactScope: string;
|
||||
scopeKind: "task";
|
||||
artifactDirectory: string;
|
||||
snapshotDirectory: string;
|
||||
copiedFiles: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
type ExportInput = {
|
||||
params: Record<string, unknown>;
|
||||
@ -26,10 +68,14 @@ type ReadInput = {
|
||||
config?: unknown;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
};
|
||||
export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare>;
|
||||
export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactSnapshot>;
|
||||
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
|
||||
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
|
||||
export declare function formatArtifactManifestMarkdown(input: {
|
||||
remoteWorkingDirectory: string;
|
||||
artifactScope?: string;
|
||||
scopeKind?: XWorkmateArtifactScopeKind;
|
||||
artifacts: XWorkmateArtifact[];
|
||||
warnings: string[];
|
||||
}): string;
|
||||
|
||||
809
dist/src/exportArtifacts.js
vendored
809
dist/src/exportArtifacts.js
vendored
@ -1,35 +1,137 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
const DEFAULT_MAX_FILES = 64;
|
||||
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
||||
const TASK_SCOPE_ROOT = "tasks";
|
||||
const ARTIFACT_IGNORE_FILE = "artifact-ignore.md";
|
||||
const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
|
||||
const SKIPPED_DIRS = new Set([
|
||||
".git",
|
||||
".openclaw",
|
||||
".xworkmate",
|
||||
".pi",
|
||||
".dart_tool",
|
||||
".next",
|
||||
".turbo",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
export async function prepareXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
params,
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const artifactScope = expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
const expectedArtifactDirStatus = await expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs);
|
||||
return {
|
||||
runId,
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
artifactScope,
|
||||
scopeKind: "task",
|
||||
artifactDirectory: scopeRoot,
|
||||
relativeArtifactDirectory: artifactScope,
|
||||
warnings: [],
|
||||
expectedArtifactDirs,
|
||||
expectedArtifactDirStatus,
|
||||
};
|
||||
}
|
||||
export async function collectAndSnapshotXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
params,
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
const snapshotRoot = path.join(scopeRoot, "artifacts");
|
||||
if (!isWithinRoot(scopeRoot, snapshotRoot)) {
|
||||
throw new Error("snapshotDirectory must stay inside artifactScope");
|
||||
}
|
||||
await fs.mkdir(snapshotRoot, { recursive: true });
|
||||
const warnings = [];
|
||||
const copiedFiles = [];
|
||||
for (const source of openClawSnapshotSources(params, pluginConfig)) {
|
||||
if (copiedFiles.length >= maxFiles) {
|
||||
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
|
||||
break;
|
||||
}
|
||||
const candidates = await collectSnapshotSourceCandidates({
|
||||
source,
|
||||
sinceUnixMs,
|
||||
warnings,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (copiedFiles.length >= maxFiles) {
|
||||
warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`);
|
||||
break;
|
||||
}
|
||||
const destinationRelativePath = safeSnapshotDestinationRelativePath(source.label, candidate.relativePath);
|
||||
const destination = path.join(snapshotRoot, destinationRelativePath.split("/").join(path.sep));
|
||||
if (!isWithinRoot(snapshotRoot, destination)) {
|
||||
warnings.push(`skipped unsafe snapshot path ${destinationRelativePath}`);
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.copyFile(candidate.absolutePath, destination);
|
||||
copiedFiles.push(`artifacts/${destinationRelativePath}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
artifactScope,
|
||||
scopeKind: "task",
|
||||
artifactDirectory: scopeRoot,
|
||||
snapshotDirectory: snapshotRoot,
|
||||
copiedFiles,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
export async function exportXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId);
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const sessionKey = optionalString(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
|
||||
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const includeContent = optionalBoolean(params.includeContent, true);
|
||||
const requiredArtifactExtensions = normalizeRequiredExtensions(params.requiredArtifactExtensions);
|
||||
const expectedFileCountByExtension = normalizeExpectedFileCountByExtension(params.expectedFileCountByExtension);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -38,12 +140,68 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings = [];
|
||||
const candidates = await collectCandidates({
|
||||
workspaceRoot,
|
||||
sinceUnixMs,
|
||||
warnings,
|
||||
});
|
||||
const expectedDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const sessionScope = taskSessionScopeFor(sessionKey);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
const scopeKind = "task";
|
||||
const scopePrepared = await directoryExists(scopeRoot);
|
||||
if (!scopePrepared && sinceUnixMs > 0) {
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
}
|
||||
let effectiveSince = sinceUnixMs;
|
||||
if (scopePrepared && sinceUnixMs > 0) {
|
||||
try {
|
||||
const scopeStat = await fs.stat(scopeRoot);
|
||||
effectiveSince = Math.min(sinceUnixMs, scopeStat.birthtimeMs || scopeStat.mtimeMs);
|
||||
}
|
||||
catch (error) {
|
||||
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs: effectiveSince,
|
||||
warnSkippedSymlinks: true,
|
||||
warnings,
|
||||
ignoreRules: await loadArtifactIgnoreRules(scopeRoot, warnings),
|
||||
})
|
||||
: [];
|
||||
const candidates = scopedCandidates;
|
||||
if (candidates.length === 0 && expectedDirs.length > 0) {
|
||||
for (const dir of expectedDirs) {
|
||||
const dirPath = path.join(workspaceRoot, safeInputRelativePath(dir, "expectedArtifactDir"));
|
||||
if (await directoryExists(dirPath)) {
|
||||
const dirCandidates = await collectCandidates({
|
||||
scanRoot: dirPath,
|
||||
relativeRoot: workspaceRoot,
|
||||
sinceUnixMs: effectiveSince,
|
||||
warnSkippedSymlinks: true,
|
||||
warnings,
|
||||
ignoreRules: await loadArtifactIgnoreRules(dirPath, warnings),
|
||||
});
|
||||
for (const c of dirCandidates) {
|
||||
candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!scopePrepared && candidates.length === 0) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
const leftRequiredMatch = matchesRequiredExtension(left.relativePath, requiredArtifactExtensions) ? 1 : 0;
|
||||
const rightRequiredMatch = matchesRequiredExtension(right.relativePath, requiredArtifactExtensions) ? 1 : 0;
|
||||
if (rightRequiredMatch !== leftRequiredMatch) {
|
||||
return rightRequiredMatch - leftRequiredMatch;
|
||||
}
|
||||
if (right.mtimeMs !== left.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
}
|
||||
@ -56,13 +214,31 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
break;
|
||||
}
|
||||
const bytes = await fs.readFile(candidate.absolutePath);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const artifactScopeForCandidate = candidate.artifactScope || (scopeKind === "task" && artifactScope ? artifactScope : "");
|
||||
const scopeKindForCandidate = candidate.scopeKind || scopeKind;
|
||||
const artifact = {
|
||||
relativePath: candidate.relativePath,
|
||||
label: path.posix.basename(candidate.relativePath),
|
||||
contentType: contentTypeForPath(candidate.relativePath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
sha256,
|
||||
artifactRef: signArtifactRef({
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
sessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(artifactScopeForCandidate ? { artifactScope: artifactScopeForCandidate } : {}),
|
||||
relativePath: candidate.relativePath,
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256,
|
||||
}, pluginConfig),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
};
|
||||
if (artifactScopeForCandidate) {
|
||||
artifact.artifactScope = artifactScopeForCandidate;
|
||||
}
|
||||
if (includeContent && bytes.byteLength <= maxInlineBytes) {
|
||||
artifact.encoding = "base64";
|
||||
artifact.content = bytes.toString("base64");
|
||||
@ -72,34 +248,36 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
}
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
|
||||
const missingRequiredFileCounts = missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension);
|
||||
const result = {
|
||||
runId,
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
artifactScope,
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
expectedArtifactDirs: expectedDirs,
|
||||
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
|
||||
constraintSatisfied: missingRequiredExtensions.length === 0 && Object.keys(missingRequiredFileCounts).length === 0,
|
||||
missingRequiredExtensions,
|
||||
missingRequiredFileCounts,
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
manifestMarkdown: formatArtifactManifestMarkdown(result),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
export async function readXWorkmateArtifact(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId) || "read";
|
||||
const sessionKey = optionalString(params.sessionKey);
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
const relativePath = optionalString(params.relativePath);
|
||||
if (!relativePath) {
|
||||
throw new Error("relativePath required");
|
||||
}
|
||||
if (relativePath.split(/[\\/]/).some((part) => part === ".." || part === "")) {
|
||||
throw new Error("relativePath must stay inside the workspace");
|
||||
}
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
let relativePath = "";
|
||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
let refPayload;
|
||||
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
@ -108,9 +286,35 @@ export async function readXWorkmateArtifact(input) {
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const absolutePath = path.join(workspaceRoot, relativePath.split("/").join(path.sep));
|
||||
if (requestedArtifactRef) {
|
||||
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
|
||||
assertArtifactRefMatchesRequest(refPayload, expectedArtifactScope, expectedSessionScope);
|
||||
relativePath = refPayload.relativePath;
|
||||
if (refPayload.artifactScope) {
|
||||
artifactScope = refPayload.artifactScope;
|
||||
}
|
||||
const requestedPath = optionalString(params.relativePath);
|
||||
if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) {
|
||||
throw new Error("artifactRef does not match relativePath");
|
||||
}
|
||||
const requestedScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedScope && requestedScope !== artifactScope) {
|
||||
throw new Error("artifactRef does not match artifactScope");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
}
|
||||
else {
|
||||
if (!artifactScope) {
|
||||
throw new Error("artifactScope or artifactRef required");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
const scopeKind = refPayload?.scopeKind ?? "task";
|
||||
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(workspaceRoot, realPath)) {
|
||||
if (!isWithinRoot(scopeRoot, realPath)) {
|
||||
throw new Error("relativePath must stay inside the workspace");
|
||||
}
|
||||
const stat = await fs.stat(realPath);
|
||||
@ -118,13 +322,33 @@ export async function readXWorkmateArtifact(input) {
|
||||
throw new Error("relativePath must point to a file");
|
||||
}
|
||||
const bytes = await fs.readFile(realPath);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) {
|
||||
throw new Error("artifactRef does not match file content");
|
||||
}
|
||||
const artifact = {
|
||||
relativePath: safeRelativePath(workspaceRoot, realPath),
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
label: path.posix.basename(relativePath),
|
||||
contentType: contentTypeForPath(relativePath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
sha256,
|
||||
artifactRef: requestedArtifactRef ||
|
||||
signArtifactRef({
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope: expectedSessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256,
|
||||
}, pluginConfig),
|
||||
scopeKind,
|
||||
};
|
||||
if (artifactScope) {
|
||||
artifact.artifactScope = artifactScope;
|
||||
}
|
||||
const warnings = [];
|
||||
if (bytes.byteLength <= maxInlineBytes) {
|
||||
artifact.encoding = "base64";
|
||||
@ -138,23 +362,104 @@ export async function readXWorkmateArtifact(input) {
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
scopeKind,
|
||||
artifacts: [artifact],
|
||||
warnings,
|
||||
expectedArtifactDirs: [],
|
||||
expectedArtifactDirStatus: [],
|
||||
constraintSatisfied: true,
|
||||
missingRequiredExtensions: [],
|
||||
missingRequiredFileCounts: {},
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
manifestMarkdown: formatArtifactManifestMarkdown(result),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
function normalizeRequiredExtensions(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const entry of value) {
|
||||
const normalized = optionalString(entry)
|
||||
.toLowerCase()
|
||||
.replace(/^\.+/u, "");
|
||||
if (!normalized || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function matchesRequiredExtension(relativePath, requiredExtensions) {
|
||||
if (requiredExtensions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
return requiredExtensions.some((extension) => lowerPath.endsWith(`.${extension}`));
|
||||
}
|
||||
function missingRequiredArtifactExtensions(artifacts, requiredExtensions) {
|
||||
if (requiredExtensions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return requiredExtensions.filter((extension) => !artifacts.some((artifact) => artifact.relativePath.toLowerCase().endsWith(`.${extension}`)));
|
||||
}
|
||||
function normalizeExpectedFileCountByExtension(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const result = {};
|
||||
for (const [rawExtension, rawCount] of Object.entries(value)) {
|
||||
const extension = rawExtension
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^\.+/u, "");
|
||||
if (!extension || extension.includes("/") || extension.includes("\\") || extension.includes("\0")) {
|
||||
continue;
|
||||
}
|
||||
const count = typeof rawCount === "number" ? rawCount : Number.parseInt(optionalString(rawCount), 10);
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
continue;
|
||||
}
|
||||
result[extension] = Math.floor(count);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension) {
|
||||
const missing = {};
|
||||
for (const [extension, expected] of Object.entries(expectedFileCountByExtension)) {
|
||||
const actual = artifacts.filter((artifact) => artifact.relativePath.toLowerCase().endsWith(`.${extension}`)).length;
|
||||
if (actual < expected) {
|
||||
missing[extension] = { expected, actual };
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
async function expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs) {
|
||||
const statuses = [];
|
||||
for (const relativePath of expectedArtifactDirs) {
|
||||
const dirPath = path.join(workspaceRoot, safeInputRelativePath(relativePath, "expectedArtifactDir"));
|
||||
statuses.push({
|
||||
relativePath,
|
||||
exists: await directoryExists(dirPath),
|
||||
});
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
export function formatArtifactManifestMarkdown(input) {
|
||||
const lines = [
|
||||
"## XWorkmate artifacts",
|
||||
"",
|
||||
`Workspace: \`${input.remoteWorkingDirectory}\``,
|
||||
input.artifactScope ? `Artifact scope: \`${input.artifactScope}\`` : `Artifact scope: \`${input.scopeKind ?? "workspace"}\``,
|
||||
"",
|
||||
];
|
||||
if (input.artifacts.length === 0) {
|
||||
lines.push("No artifacts found.");
|
||||
lines.push("No artifacts found for this task run.");
|
||||
}
|
||||
else {
|
||||
lines.push("| File | Type | Size | SHA-256 | Inline |");
|
||||
@ -173,7 +478,7 @@ export function formatArtifactManifestMarkdown(input) {
|
||||
}
|
||||
async function collectCandidates(input) {
|
||||
const candidates = [];
|
||||
await walk(input.workspaceRoot);
|
||||
await walk(input.scanRoot);
|
||||
return candidates;
|
||||
async function walk(currentDir) {
|
||||
let entries;
|
||||
@ -181,7 +486,7 @@ async function collectCandidates(input) {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
}
|
||||
catch (error) {
|
||||
input.warnings.push(`cannot read ${safeDisplayPath(input.workspaceRoot, currentDir)}: ${String(error)}`);
|
||||
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
@ -191,7 +496,9 @@ async function collectCandidates(input) {
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.workspaceRoot, absolutePath)}`);
|
||||
if (input.warnSkippedSymlinks) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
@ -210,11 +517,83 @@ async function collectCandidates(input) {
|
||||
continue;
|
||||
}
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(input.workspaceRoot, realPath)) {
|
||||
if (!isWithinRoot(input.relativeRoot, realPath)) {
|
||||
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
const relativePath = safeRelativePath(input.workspaceRoot, realPath);
|
||||
const relativePath = safeRelativePath(input.relativeRoot, realPath);
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
if (isIgnoredArtifactPath(relativePath, input.ignoreRules)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
absolutePath: realPath,
|
||||
relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: changedAtMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function collectSnapshotSourceCandidates(input) {
|
||||
let sourceRoot = "";
|
||||
try {
|
||||
sourceRoot = await fs.realpath(input.source.root);
|
||||
}
|
||||
catch (error) {
|
||||
if (error?.code !== "ENOENT") {
|
||||
input.warnings.push(`cannot read ${input.source.label}: ${String(error)}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const candidates = [];
|
||||
await walk(sourceRoot);
|
||||
candidates.sort((left, right) => {
|
||||
if (right.mtimeMs !== left.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
}
|
||||
return left.relativePath.localeCompare(right.relativePath);
|
||||
});
|
||||
return candidates;
|
||||
async function walk(currentDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
}
|
||||
catch (error) {
|
||||
input.warnings.push(`cannot read ${input.source.label}/${safeDisplayPath(sourceRoot, currentDir)}: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
input.warnings.push(`skipped symlink ${input.source.label}/${safeDisplayPath(sourceRoot, absolutePath)}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs);
|
||||
if (changedAtMs < input.sinceUnixMs) {
|
||||
continue;
|
||||
}
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(sourceRoot, realPath)) {
|
||||
input.warnings.push(`skipped path outside ${input.source.label}: ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
const relativePath = safeRelativePath(sourceRoot, realPath);
|
||||
if (!relativePath) {
|
||||
continue;
|
||||
}
|
||||
@ -227,41 +606,217 @@ async function collectCandidates(input) {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function loadArtifactIgnoreRules(scopeRoot, warnings) {
|
||||
const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }];
|
||||
const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE);
|
||||
let content = "";
|
||||
try {
|
||||
content = await fs.readFile(ignorePath, "utf8");
|
||||
}
|
||||
catch (error) {
|
||||
if (error?.code !== "ENOENT") {
|
||||
warnings.push(`cannot read ${ARTIFACT_IGNORE_FILE}: ${String(error)}`);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
for (const line of artifactIgnoreRuleLines(content)) {
|
||||
const rule = parseArtifactIgnoreRule(line, warnings);
|
||||
if (rule) {
|
||||
rules.push(rule);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
function artifactIgnoreRuleLines(content) {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const fencedLines = [];
|
||||
let insideBlock = false;
|
||||
let sawBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!insideBlock && trimmed === "```artifact-ignore") {
|
||||
insideBlock = true;
|
||||
sawBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (insideBlock && trimmed === "```") {
|
||||
insideBlock = false;
|
||||
continue;
|
||||
}
|
||||
if (insideBlock) {
|
||||
fencedLines.push(line);
|
||||
}
|
||||
}
|
||||
return sawBlock ? fencedLines : lines;
|
||||
}
|
||||
function parseArtifactIgnoreRule(line, warnings) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.includes("\0") || path.isAbsolute(trimmed) || trimmed.split(/[\\/]/).some((part) => part === ".." || part === ".")) {
|
||||
warnings.push(`ignored unsafe artifact ignore rule: ${trimmed}`);
|
||||
return undefined;
|
||||
}
|
||||
const directoryRule = /[\\/]$/.test(trimmed);
|
||||
const normalized = trimmed.split(/[\\/]/).filter(Boolean).join("/");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (directoryRule) {
|
||||
if (normalized.includes("*")) {
|
||||
warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`);
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "directory", path: normalized };
|
||||
}
|
||||
if (normalized.startsWith("**/*") && normalized.length > 4) {
|
||||
return { kind: "any-suffix", suffix: normalized.slice(4) };
|
||||
}
|
||||
if (!normalized.includes("/") && normalized.startsWith("*") && normalized.length > 1) {
|
||||
return { kind: "root-suffix", suffix: normalized.slice(1) };
|
||||
}
|
||||
if (normalized.includes("*")) {
|
||||
warnings.push(`ignored unsupported artifact ignore rule: ${trimmed}`);
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "exact", path: normalized };
|
||||
}
|
||||
function isIgnoredArtifactPath(relativePath, rules) {
|
||||
for (const rule of rules) {
|
||||
switch (rule.kind) {
|
||||
case "directory":
|
||||
if (relativePath === rule.path || relativePath.startsWith(`${rule.path}/`)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "exact":
|
||||
if (relativePath === rule.path) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "root-suffix":
|
||||
if (!relativePath.includes("/") && relativePath.endsWith(rule.suffix)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "any-suffix":
|
||||
if (relativePath.endsWith(rule.suffix)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function artifactScopeFor(sessionKey, runId) {
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
function taskSessionScopeFor(sessionKey) {
|
||||
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
|
||||
}
|
||||
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope) {
|
||||
if (artifactScope === expectedArtifactScope) {
|
||||
return;
|
||||
}
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) {
|
||||
if (payload.sessionScope !== expectedSessionScope || payload.runScope !== expectedRunScope) {
|
||||
throw new Error("artifactRef does not match sessionKey/runId");
|
||||
}
|
||||
}
|
||||
function safeScopeSegment(value) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "_")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^[._-]+|[._-]+$/g, "")
|
||||
.slice(0, 96) || "scope";
|
||||
}
|
||||
function optionalArtifactScope(value) {
|
||||
const scope = optionalString(value);
|
||||
if (!scope) {
|
||||
return "";
|
||||
}
|
||||
return safeTaskArtifactScope(scope);
|
||||
}
|
||||
function safeTaskArtifactScope(value) {
|
||||
const scope = safeInputRelativePath(value, "artifactScope");
|
||||
const parts = scope.split("/");
|
||||
const rootParts = TASK_SCOPE_ROOT.split("/");
|
||||
const scopeRoot = parts.slice(0, rootParts.length).join("/");
|
||||
if (parts.length !== rootParts.length + 2 || scopeRoot !== TASK_SCOPE_ROOT) {
|
||||
throw new Error("artifactScope must be a task artifact scope");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
function safeTaskSessionScope(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
let scope;
|
||||
try {
|
||||
scope = safeInputRelativePath(raw, "artifactRef sessionScope");
|
||||
}
|
||||
catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const parts = scope.split("/");
|
||||
const rootParts = TASK_SCOPE_ROOT.split("/");
|
||||
const scopeRoot = parts.slice(0, rootParts.length).join("/");
|
||||
if (parts.length !== rootParts.length + 1 || scopeRoot !== TASK_SCOPE_ROOT) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
async function directoryExists(absolutePath) {
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath);
|
||||
return stat.isDirectory();
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function safeArtifactRefRunScope(value) {
|
||||
try {
|
||||
return safeTaskArtifactScope(value);
|
||||
}
|
||||
catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
}
|
||||
function safeInputRelativePath(value, label) {
|
||||
const relativePath = optionalString(value);
|
||||
if (!relativePath) {
|
||||
throw new Error(`${label} required`);
|
||||
}
|
||||
if (path.isAbsolute(relativePath) || relativePath.includes("\0")) {
|
||||
throw new Error(`${label} must stay inside the workspace`);
|
||||
}
|
||||
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
|
||||
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
|
||||
throw new Error(`${label} must stay inside the workspace`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
function resolveScopeRoot(workspaceRoot, artifactScope) {
|
||||
const normalizedScope = safeTaskArtifactScope(artifactScope);
|
||||
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
||||
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
||||
throw new Error("artifactScope must stay inside the workspace");
|
||||
}
|
||||
return scopeRoot;
|
||||
}
|
||||
function resolveWorkspaceDir(input) {
|
||||
const explicit = optionalString(input.params.workspaceDir) || optionalString(input.pluginConfig.workspaceDir);
|
||||
if (explicit) {
|
||||
return expandUserPath(explicit);
|
||||
}
|
||||
const config = objectRecord(input.config);
|
||||
const agents = objectRecord(config.agents);
|
||||
const agentList = Array.isArray(agents.list)
|
||||
? agents.list.map(objectRecord).filter((entry) => Object.keys(entry).length > 0)
|
||||
: [];
|
||||
const agentId = agentIdFromSessionKey(input.sessionKey);
|
||||
const selected = (agentId ? agentList.find((entry) => optionalString(entry.id) === agentId) : undefined) ??
|
||||
agentList.find((entry) => entry.default === true) ??
|
||||
agentList[0];
|
||||
const selectedWorkspace = selected ? optionalString(selected.workspace) : "";
|
||||
if (selectedWorkspace) {
|
||||
return expandUserPath(selectedWorkspace);
|
||||
}
|
||||
const defaults = objectRecord(agents.defaults);
|
||||
const defaultWorkspace = optionalString(defaults.workspace);
|
||||
if (defaultWorkspace) {
|
||||
return expandUserPath(defaultWorkspace);
|
||||
}
|
||||
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
||||
if (profile && profile.toLowerCase() !== "default") {
|
||||
return path.join(os.homedir(), ".openclaw", `workspace-${profile}`);
|
||||
}
|
||||
return path.join(os.homedir(), ".openclaw", "workspace");
|
||||
}
|
||||
function agentIdFromSessionKey(sessionKey) {
|
||||
const parts = sessionKey.split(":");
|
||||
if (parts.length >= 3 && parts[0] === "agent") {
|
||||
return parts[1]?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
return expandUserPath(path.join("~", ".openclaw", "workspace"));
|
||||
}
|
||||
function safeRelativePath(root, target) {
|
||||
const relative = path.relative(root, target);
|
||||
@ -313,10 +868,31 @@ function contentTypeForPath(relativePath) {
|
||||
return "image/gif";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".mp4":
|
||||
return "video/mp4";
|
||||
case ".mov":
|
||||
return "video/quicktime";
|
||||
case ".webm":
|
||||
return "video/webm";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
function openClawSnapshotSources(params, pluginConfig) {
|
||||
return [
|
||||
{
|
||||
label: "media",
|
||||
root: expandUserPath(optionalString(pluginConfig.openClawMediaDir) || path.join("~", ".openclaw", "media")),
|
||||
},
|
||||
{
|
||||
label: "tmp-openclaw",
|
||||
root: expandUserPath(optionalString(pluginConfig.openClawTmpDir) || path.join(os.tmpdir(), "openclaw")),
|
||||
},
|
||||
];
|
||||
}
|
||||
function safeSnapshotDestinationRelativePath(sourceLabel, sourceRelativePath) {
|
||||
return [safeScopeSegment(sourceLabel), safeInputRelativePath(sourceRelativePath, "snapshot source path")].join("/");
|
||||
}
|
||||
function objectRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value
|
||||
@ -325,6 +901,13 @@ function objectRecord(value) {
|
||||
function optionalString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
function requiredString(value, message) {
|
||||
const resolved = optionalString(value);
|
||||
if (!resolved) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
function optionalBoolean(value, fallback) {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
@ -356,6 +939,78 @@ function nonNegativeNumber(value, fallback) {
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
function signArtifactRef(payload, pluginConfig) {
|
||||
const body = base64UrlEncode(JSON.stringify(payload));
|
||||
const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
||||
const [body, signature, ...extra] = artifactRef.split(".");
|
||||
if (!body || !signature || extra.length > 0) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||
if (!constantTimeEqual(signature, expectedSignature)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
}
|
||||
catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const payload = objectRecord(parsed);
|
||||
const scopeKind = optionalString(payload.scopeKind);
|
||||
if (scopeKind !== "task") {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
|
||||
const artifactScope = optionalArtifactScope(payload.artifactScope);
|
||||
if (scopeKind === "task" && !artifactScope) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
|
||||
const sha256 = optionalString(payload.sha256).toLowerCase();
|
||||
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const sessionScope = safeTaskSessionScope(payload.sessionScope);
|
||||
const runScope = safeArtifactRefRunScope(payload.runScope);
|
||||
if (!runScope.startsWith(`${sessionScope}/`)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
|
||||
throw new Error("artifactRef does not match workspace");
|
||||
}
|
||||
return {
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope,
|
||||
runScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath,
|
||||
sizeBytes,
|
||||
sha256,
|
||||
};
|
||||
}
|
||||
function artifactRefSigningSecret(pluginConfig) {
|
||||
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
|
||||
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
|
||||
GENERATED_ARTIFACT_REF_SECRET);
|
||||
}
|
||||
function workspaceRootHash(workspaceRoot) {
|
||||
return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex");
|
||||
}
|
||||
function base64UrlEncode(value) {
|
||||
return Buffer.from(value, "utf8").toString("base64url");
|
||||
}
|
||||
function constantTimeEqual(left, right) {
|
||||
const leftBytes = Buffer.from(left);
|
||||
const rightBytes = Buffer.from(right);
|
||||
return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
function expandUserPath(value) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
|
||||
65
dist/src/taskState.d.ts
vendored
Normal file
65
dist/src/taskState.d.ts
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export declare const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
export type XWorkmateTaskMetadataV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey?: string;
|
||||
expectedArtifactDirs: string[];
|
||||
requestId?: string;
|
||||
externalTaskId?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
export type XWorkmateSessionMappingSource = "session_start" | "bridge_prepare";
|
||||
export type XWorkmateSessionMappingV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey: string;
|
||||
expectedArtifactDirs: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
};
|
||||
export type XWorkmateTaskLookupErrorCode = "mapping_not_found" | "task_not_found" | "no_native_task_record" | "conflict" | "invalid_lookup";
|
||||
export type XWorkmateTaskLookupError = {
|
||||
ok: false;
|
||||
code: XWorkmateTaskLookupErrorCode;
|
||||
message: string;
|
||||
mapping?: XWorkmateSessionMappingV1;
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
export type XWorkmateRecordedTaskRunV1 = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
success: boolean;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
|
||||
export declare function recordXWorkmateSessionMapping(input: {
|
||||
api: OpenClawPluginApi;
|
||||
params: Record<string, unknown>;
|
||||
artifactScope?: string;
|
||||
source?: XWorkmateSessionMappingSource;
|
||||
}): Promise<XWorkmateSessionMappingV1>;
|
||||
export declare function recordXWorkmateTaskRunStarted(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1>;
|
||||
export declare function recordXWorkmateTaskRunTerminal(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1>;
|
||||
export declare function getXWorkmateTaskSnapshot(input: {
|
||||
api: OpenClawPluginApi;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
506
dist/src/taskState.js
vendored
Normal file
506
dist/src/taskState.js
vendored
Normal file
@ -0,0 +1,506 @@
|
||||
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
const MAX_RECORDED_TASK_RUNS = 32;
|
||||
export function registerXWorkmateSessionExtension(api) {
|
||||
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
|
||||
if (typeof registerExtension !== "function") {
|
||||
return;
|
||||
}
|
||||
registerExtension({
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
description: "Durable XWorkmate app/OpenClaw session key mapping.",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
project: (ctx) => {
|
||||
const state = asRecord(ctx.state);
|
||||
return state ?? {};
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateSessionMapping(input) {
|
||||
const metadata = normalizeXWorkmateTaskMetadataV1(input.params);
|
||||
const openclawSessionKey = requiredString(input.params.openclawSessionKey ?? metadata.openclawSessionKey, "openclawSessionKey required");
|
||||
return upsertXWorkmateSessionMapping(input.api, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
openclawSessionKey,
|
||||
},
|
||||
openclawSessionKey,
|
||||
source: input.source ?? "bridge_prepare",
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateTaskRunStarted(input) {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: "running",
|
||||
success: false,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateTaskRunTerminal(input) {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: input.success ? "completed" : "failed",
|
||||
success: input.success,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
output: sanitizeTaskRunOutput(input.output),
|
||||
error: sanitizeTaskRunError(input.error),
|
||||
});
|
||||
}
|
||||
function normalizeXWorkmateTaskMetadataV1(input) {
|
||||
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
|
||||
const schemaVersion = Number(envelope.schemaVersion ?? 1);
|
||||
if (schemaVersion !== 1) {
|
||||
throw new Error("schemaVersion must be 1");
|
||||
}
|
||||
const appThreadKey = requiredString(envelope.appThreadKey, "appThreadKey required");
|
||||
const createdAt = optionalString(envelope.createdAt) || new Date().toISOString();
|
||||
return compactObject({
|
||||
schemaVersion: 1,
|
||||
appThreadKey,
|
||||
openclawSessionKey: optionalString(envelope.openclawSessionKey),
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(envelope.expectedArtifactDirs),
|
||||
requestId: optionalString(envelope.requestId),
|
||||
externalTaskId: optionalString(envelope.externalTaskId ?? envelope.taskId),
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
async function upsertXWorkmateSessionMapping(api, input) {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
let mapping;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const existing = readMappingFromEntry(entry);
|
||||
if (existing) {
|
||||
assertMappingCompatible(existing, input.metadata.appThreadKey, input.openclawSessionKey);
|
||||
mapping = {
|
||||
...existing,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
updatedAt: now,
|
||||
source: existing.source,
|
||||
};
|
||||
}
|
||||
else {
|
||||
mapping = compactObject({
|
||||
schemaVersion: 1,
|
||||
appThreadKey: input.metadata.appThreadKey,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
createdAt: input.metadata.createdAt || now,
|
||||
updatedAt: now,
|
||||
source: input.source,
|
||||
});
|
||||
}
|
||||
return {
|
||||
pluginExtensions: writeMappingToPluginExtensions(entry.pluginExtensions, mapping),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!mapping) {
|
||||
throw new Error("failed to write xworkmate session mapping");
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
async function readXWorkmateSessionMapping(api, lookup) {
|
||||
const getSessionEntry = resolveGetSessionEntry(api);
|
||||
if (!getSessionEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const openclawSessionKey = optionalString(lookup.openclawSessionKey);
|
||||
if (openclawSessionKey) {
|
||||
return readMappingFromEntry(getSessionEntry({ sessionKey: openclawSessionKey }));
|
||||
}
|
||||
const appThreadKey = optionalString(lookup.appThreadKey);
|
||||
if (!appThreadKey) {
|
||||
return undefined;
|
||||
}
|
||||
const listSessionEntries = resolveListSessionEntries(api);
|
||||
for (const item of listSessionEntries?.() ?? []) {
|
||||
const mapping = readMappingFromEntry(item.entry);
|
||||
if (mapping?.appThreadKey === appThreadKey) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export async function getXWorkmateTaskSnapshot(input) {
|
||||
const params = input.params ?? {};
|
||||
const appThreadKey = optionalString(params.appThreadKey);
|
||||
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
|
||||
const mapping = await readXWorkmateSessionMapping(input.api, {
|
||||
appThreadKey,
|
||||
openclawSessionKey: explicitOpenclawSessionKey,
|
||||
});
|
||||
if (!mapping && appThreadKey && !explicitOpenclawSessionKey) {
|
||||
return lookupError("mapping_not_found", `No OpenClaw session mapping found for ${appThreadKey}`);
|
||||
}
|
||||
const openclawSessionKey = mapping?.openclawSessionKey || explicitOpenclawSessionKey;
|
||||
if (!openclawSessionKey) {
|
||||
return lookupError("invalid_lookup", "openclawSessionKey or appThreadKey required");
|
||||
}
|
||||
const runId = optionalString(params.runId);
|
||||
const taskId = optionalString(params.taskId);
|
||||
const task = resolveNativeTask(input.api, {
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId,
|
||||
});
|
||||
const includeArtifacts = params.includeArtifacts !== false;
|
||||
if (!task) {
|
||||
const recordedRun = runId
|
||||
? readXWorkmateTaskRun(input.api, openclawSessionKey, runId)
|
||||
: undefined;
|
||||
const exported = includeArtifacts && runId
|
||||
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
|
||||
: undefined;
|
||||
if (recordedRun) {
|
||||
return {
|
||||
success: recordedRun.status === "running" ? true : recordedRun.success,
|
||||
status: recordedRun.status,
|
||||
taskStatus: recordedRun.status,
|
||||
terminal: recordedRun.status !== "running",
|
||||
terminalSource: recordedRun.status === "running" ? "session_prepare" : "agent_end",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: recordedRun.status,
|
||||
success: recordedRun.success,
|
||||
source: "xworkmate_run_state",
|
||||
startedAt: recordedRun.startedAt,
|
||||
updatedAt: recordedRun.updatedAt,
|
||||
completedAt: recordedRun.completedAt,
|
||||
error: recordedRun.error,
|
||||
},
|
||||
output: recordedRun.output,
|
||||
resultSummary: recordedRun.output,
|
||||
error: recordedRun.error,
|
||||
message: recordedRun.output ?? recordedRun.error,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
if (exported?.artifacts.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: "unknown",
|
||||
taskStatus: "unknown",
|
||||
evidence: "artifacts_present",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: "unknown",
|
||||
source: "artifact_fallback",
|
||||
},
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported.artifactScope,
|
||||
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
||||
scopeKind: exported.scopeKind,
|
||||
artifacts: exported.artifacts,
|
||||
constraintSatisfied: exported.constraintSatisfied,
|
||||
missingRequiredExtensions: exported.missingRequiredExtensions,
|
||||
warnings: [
|
||||
...exported.warnings,
|
||||
`Native OpenClaw task record was unavailable for ${openclawSessionKey}; artifacts are present but task status is unknown.`,
|
||||
],
|
||||
artifactCount: exported.artifacts.length,
|
||||
};
|
||||
}
|
||||
const code = runId || taskId ? "no_native_task_record" : "task_not_found";
|
||||
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
|
||||
}
|
||||
const taskStatus = optionalString(task.status) || "running";
|
||||
const exported = includeArtifacts
|
||||
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId || optionalString(task.runId) || optionalString(task.taskId), mapping)
|
||||
: undefined;
|
||||
return {
|
||||
success: true,
|
||||
status: appStatusFromTaskStatus(taskStatus),
|
||||
taskStatus,
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId: runId || optionalString(task.runId),
|
||||
taskId: taskId || optionalString(task.taskId),
|
||||
task,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
async function upsertXWorkmateTaskRun(api, input) {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
let recorded;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const runs = readTaskRunsFromEntry(entry);
|
||||
const existing = runs[input.runId];
|
||||
recorded = compactObject({
|
||||
schemaVersion: 1,
|
||||
runId: input.runId,
|
||||
status: input.status,
|
||||
success: input.success,
|
||||
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
|
||||
updatedAt: input.updatedAt,
|
||||
completedAt: input.completedAt,
|
||||
output: input.output,
|
||||
error: input.error,
|
||||
});
|
||||
runs[input.runId] = recorded;
|
||||
const boundedRuns = Object.fromEntries(Object.entries(runs)
|
||||
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
|
||||
.slice(0, MAX_RECORDED_TASK_RUNS));
|
||||
return {
|
||||
pluginExtensions: {
|
||||
...(entry.pluginExtensions ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
|
||||
schemaVersion: 1,
|
||||
runs: boundedRuns,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!recorded) {
|
||||
throw new Error("failed to write xworkmate task run state");
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
function readXWorkmateTaskRun(api, openclawSessionKey, runId) {
|
||||
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
|
||||
return readTaskRunsFromEntry(entry)[runId];
|
||||
}
|
||||
function readTaskRunsFromEntry(entry) {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
|
||||
if (store?.schemaVersion !== 1) {
|
||||
return {};
|
||||
}
|
||||
const runs = asRecord(store.runs) ?? {};
|
||||
const result = {};
|
||||
for (const [key, rawValue] of Object.entries(runs)) {
|
||||
const raw = asRecord(rawValue);
|
||||
const runId = optionalString(raw?.runId) || key;
|
||||
const status = optionalString(raw?.status);
|
||||
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
|
||||
continue;
|
||||
}
|
||||
result[runId] = compactObject({
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
status,
|
||||
success: raw?.success === true,
|
||||
startedAt: optionalString(raw?.startedAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw?.updatedAt) || new Date(0).toISOString(),
|
||||
completedAt: optionalString(raw?.completedAt),
|
||||
output: optionalString(raw?.output),
|
||||
error: optionalString(raw?.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sanitizeTaskRunOutput(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw.slice(0, 16 * 1024);
|
||||
}
|
||||
function sanitizeTaskRunError(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw
|
||||
.replace(/\b(sk|nvapi)-[A-Za-z0-9._-]+\b/gi, "$1-<redacted>")
|
||||
.replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1<redacted>")
|
||||
.slice(0, 2048);
|
||||
}
|
||||
async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) {
|
||||
return exportXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
|
||||
includeContent: params.includeContent ?? false,
|
||||
},
|
||||
config: input.api.config,
|
||||
pluginConfig: input.api.pluginConfig,
|
||||
});
|
||||
}
|
||||
function resolveNativeTask(api, input) {
|
||||
try {
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey });
|
||||
if (!bound) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = input.taskId || input.runId || "";
|
||||
const resolved = lookup ? bound.resolve?.(lookup) || bound.get?.(lookup) : bound.findLatest?.();
|
||||
return asRecord(resolved);
|
||||
}
|
||||
catch (error) {
|
||||
api.logger?.warn?.(`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function lookupError(code, message, mapping) {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
|
||||
};
|
||||
}
|
||||
function readMappingFromEntry(entry) {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const raw = asRecord(pluginState?.[XWORKMATE_SESSION_EXTENSION_NAMESPACE]);
|
||||
if (!raw || raw.schemaVersion !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const appThreadKey = optionalString(raw.appThreadKey);
|
||||
const openclawSessionKey = optionalString(raw.openclawSessionKey);
|
||||
if (!appThreadKey || !openclawSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
appThreadKey,
|
||||
openclawSessionKey,
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(raw.expectedArtifactDirs),
|
||||
createdAt: optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw.updatedAt) || optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
source: parseMappingSource(raw.source),
|
||||
};
|
||||
}
|
||||
function writeMappingToPluginExtensions(current, mapping) {
|
||||
if (!mapping) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...(current ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(current?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_SESSION_EXTENSION_NAMESPACE]: mapping,
|
||||
},
|
||||
};
|
||||
}
|
||||
function assertMappingCompatible(existing, appThreadKey, openclawSessionKey) {
|
||||
if (existing.appThreadKey !== appThreadKey || existing.openclawSessionKey !== openclawSessionKey) {
|
||||
throw new Error("conflict: xworkmate session mapping already points to a different session");
|
||||
}
|
||||
}
|
||||
function resolvePatchSessionEntry(api) {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {});
|
||||
const candidate = runtimeSession.patchSessionEntry;
|
||||
return typeof candidate === "function" ? candidate : undefined;
|
||||
}
|
||||
function resolveGetSessionEntry(api) {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {});
|
||||
const candidate = runtimeSession.getSessionEntry;
|
||||
return typeof candidate === "function" ? candidate : undefined;
|
||||
}
|
||||
function resolveListSessionEntries(api) {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {});
|
||||
const candidate = runtimeSession.listSessionEntries;
|
||||
return typeof candidate === "function"
|
||||
? candidate
|
||||
: undefined;
|
||||
}
|
||||
function appStatusFromTaskStatus(status) {
|
||||
if (status === "succeeded") {
|
||||
return "completed";
|
||||
}
|
||||
if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") {
|
||||
return "failed";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
function parseMappingSource(value) {
|
||||
const source = optionalString(value);
|
||||
if (source === "session_start" || source === "bridge_prepare") {
|
||||
return source;
|
||||
}
|
||||
return "bridge_prepare";
|
||||
}
|
||||
function requiredString(value, message) {
|
||||
const text = optionalString(value);
|
||||
if (!text) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
function optionalString(value) {
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text === "<nil>" ? "" : text;
|
||||
}
|
||||
function asRecord(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function compactObject(value) {
|
||||
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== ""));
|
||||
}
|
||||
383
index.test.ts
383
index.test.ts
@ -1,32 +1,403 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import plugin, { lastAssistantText } from "./index.js";
|
||||
import { prepareXWorkmateArtifacts } from "./src/exportArtifacts.js";
|
||||
|
||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
type GatewayMethodResponse = {
|
||||
ok: boolean;
|
||||
payload?: Record<string, unknown>;
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
|
||||
describe("plugin registration", () => {
|
||||
it("registers the xworkmate artifact export gateway method", () => {
|
||||
it("extracts only the final assistant display text", () => {
|
||||
expect(lastAssistantText([
|
||||
{ role: "user", content: "secret prompt" },
|
||||
{ role: "assistant", content: [{ type: "tool_call", text: "ignored" }, { type: "text", text: "完成并已保存。" }] },
|
||||
])).toBe("完成并已保存。");
|
||||
});
|
||||
it("declares registered agent tools in the manifest contract", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
|
||||
contracts?: { tools?: string[]; sessionScopedTools?: string[] };
|
||||
configSchema?: { properties?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.contracts?.tools).not.toContain("openclaw_multi_session_agents");
|
||||
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.contracts?.sessionScopedTools).not.toContain("openclaw_multi_session_agents");
|
||||
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
|
||||
expect(manifest.configSchema?.properties?.bridgeUrl).toBeUndefined();
|
||||
expect(manifest.configSchema?.properties?.bridgeToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers the xworkmate gateway methods and optional tools", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||
const tools: unknown[] = [];
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.push({ method, handler });
|
||||
},
|
||||
registerTool: (tool: unknown) => {
|
||||
tools.push(tool);
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(methods.map((entry) => entry.method)).toEqual([
|
||||
"xworkmate.session.prepare",
|
||||
"xworkmate.tasks.get",
|
||||
"xworkmate.artifacts.export",
|
||||
"xworkmate.artifacts.collect-and-snapshot",
|
||||
"xworkmate.artifacts.list",
|
||||
"xworkmate.artifacts.read",
|
||||
]);
|
||||
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.options).toMatchObject({
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("executes registered gateway methods against the current task scope", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-gateway-"));
|
||||
const methods = new Map<string, GatewayMethodHandler>();
|
||||
const api = {
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.set(method, handler);
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
patchSessionEntry: async (params: any) => {
|
||||
params.update({ pluginExtensions: {} });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const prepared = await callGatewayMethod(methods, "xworkmate.session.prepare", {
|
||||
appThreadKey: "thread-main",
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
expect(prepared.payload?.artifactScope).toBe("tasks/thread-main/turn-1");
|
||||
const artifactDirectory = String(prepared.payload?.artifactDirectory);
|
||||
|
||||
const emptyExport = await callGatewayMethod(methods, "xworkmate.artifacts.export", {
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
});
|
||||
expect(emptyExport.ok).toBe(true);
|
||||
expect(emptyExport.payload?.artifacts).toEqual([]);
|
||||
expect(emptyExport.payload?.warnings).toEqual([]);
|
||||
|
||||
await fs.promises.mkdir(path.join(artifactDirectory, "reports"), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(artifactDirectory, "reports", "final.md"), "final");
|
||||
|
||||
const listed = await callGatewayMethod(methods, "xworkmate.artifacts.list", {
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
expect(listed.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]);
|
||||
const listedArtifacts = listed.payload?.artifacts as Array<Record<string, unknown>>;
|
||||
expect(listedArtifacts[0]).not.toHaveProperty("content");
|
||||
|
||||
const read = await callGatewayMethod(methods, "xworkmate.artifacts.read", {
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
relativePath: "reports/final.md",
|
||||
});
|
||||
expect(read.ok).toBe(true);
|
||||
expect(read.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md", encoding: "base64" }]);
|
||||
|
||||
const unprepared = await callGatewayMethod(methods, "xworkmate.artifacts.export", {
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-unprepared",
|
||||
});
|
||||
expect(unprepared.ok).toBe(true);
|
||||
expect(unprepared.payload?.artifacts).toEqual([]);
|
||||
expect(unprepared.payload?.warnings).toEqual(["artifact scope is not prepared for this task run"]);
|
||||
});
|
||||
|
||||
it("registers xworkmate task state against the native session extension and task runtime seams", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-task-state-"));
|
||||
const methods = new Map<string, GatewayMethodHandler>();
|
||||
const hooks = new Map<string, (event: unknown, ctx?: unknown) => Promise<void>>();
|
||||
const sessionExtensions: Array<Record<string, unknown>> = [];
|
||||
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
|
||||
const detachedRuntimes: Array<Record<string, unknown>> = [];
|
||||
const api = {
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
registerSessionExtension: (extension: Record<string, unknown>) => {
|
||||
sessionExtensions.push(extension);
|
||||
},
|
||||
patchSessionEntry: async (patch: any) => {
|
||||
sessionExtensionPatches.push(patch);
|
||||
if (patch.update) patch.update({ pluginExtensions: {} });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: ({ sessionKey }: { sessionKey: string }) => ({
|
||||
resolve: (token: string) =>
|
||||
sessionKey === "draft:1780636411666238-3" && token === "turn-1"
|
||||
? {
|
||||
taskId: "native-task",
|
||||
runtime: "acp",
|
||||
requesterSessionKey: sessionKey,
|
||||
ownerKey: "draft-1780636411666238-3",
|
||||
scopeKind: "session",
|
||||
runId: token,
|
||||
task: "native",
|
||||
status: "running",
|
||||
deliveryStatus: "pending",
|
||||
notifyPolicy: "state_changes",
|
||||
createdAt: 1,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
state: {
|
||||
registerSessionExtension: (extension: Record<string, unknown>) => {
|
||||
sessionExtensions.push(extension);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
registerDetachedTaskRuntime: (runtime: Record<string, unknown>) => {
|
||||
detachedRuntimes.push(runtime);
|
||||
},
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.set(method, handler);
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
on: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(sessionExtensions).toHaveLength(1);
|
||||
expect(sessionExtensions[0]).toMatchObject({
|
||||
namespace: "xworkmate.sessionMapping",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
});
|
||||
const projected = (sessionExtensions[0]?.project as (ctx: Record<string, unknown>) => unknown)({
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
state: {},
|
||||
});
|
||||
expect(projected).toMatchObject({});
|
||||
expect(detachedRuntimes).toHaveLength(0);
|
||||
|
||||
await hooks.get("session_start")?.({
|
||||
appThreadKey: "draft:legacy-session-key-only",
|
||||
sessionKey: "draft:legacy-session-key-only",
|
||||
runId: "turn-legacy",
|
||||
});
|
||||
expect(sessionExtensionPatches).toHaveLength(0);
|
||||
|
||||
await hooks.get("session_start")?.({
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
threadId: "draft-1780636411666238-3",
|
||||
runId: "turn-1",
|
||||
expectedArtifactDirs: ["artifacts/", "reports/", "exports/"],
|
||||
});
|
||||
await fs.promises.mkdir(path.join(root, "reports"), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(root, "reports", "final.md"), "final");
|
||||
expect(sessionExtensionPatches).toHaveLength(1);
|
||||
expect(sessionExtensionPatches[0]).toMatchObject({
|
||||
sessionKey: "draft:1780636411666238-3",
|
||||
preserveActivity: true,
|
||||
});
|
||||
|
||||
const snapshot = await callGatewayMethod(methods, "xworkmate.tasks.get", {
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
runId: "turn-1",
|
||||
expectedArtifactDirs: ["reports"],
|
||||
sinceUnixMs: Date.now() - 1_000,
|
||||
});
|
||||
|
||||
expect(snapshot.ok).toBe(true);
|
||||
expect(snapshot.payload).toMatchObject({
|
||||
status: "running",
|
||||
taskStatus: "running",
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
artifactCount: 1,
|
||||
});
|
||||
expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" });
|
||||
expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]);
|
||||
|
||||
await hooks.get("agent_end")?.(
|
||||
{ runId: "turn-1", success: false, error: "401 authentication failed", messages: [{ role: "assistant", content: [{ type: "text", text: "上游认证失败。" }] }] },
|
||||
{ sessionKey: "draft:1780636411666238-3", runId: "turn-1" },
|
||||
);
|
||||
expect(sessionExtensionPatches.at(-1)).toMatchObject({
|
||||
sessionKey: "draft:1780636411666238-3",
|
||||
preserveActivity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent default session or run ids for the optional agent tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
|
||||
parameters: { properties?: Record<string, unknown> };
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
const tool = factory({});
|
||||
|
||||
expect(tool.parameters.properties?.sessionKey).toBeUndefined();
|
||||
expect(tool.parameters.properties?.runId).toBeUndefined();
|
||||
expect(tool.parameters.properties?.workspaceDir).toBeUndefined();
|
||||
await expect(tool.execute("call-1", { action: "list" })).rejects.toThrow("sessionKey required");
|
||||
await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { action: "list" })).rejects.toThrow(
|
||||
"runId required",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose the removed bridge agents tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||
const api = {
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(tools.map((item) => item.options.names).flat()).toEqual(["openclaw_multi_session_artifacts"]);
|
||||
});
|
||||
|
||||
it("uses host context scope for the optional agent tool", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-tool-"));
|
||||
const current = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const other = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.promises.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
|
||||
await fs.promises.writeFile(path.join(other.artifactDirectory, "other.txt"), "other");
|
||||
await fs.promises.writeFile(path.join(root, "global.txt"), "global");
|
||||
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
|
||||
};
|
||||
const tool = factory({
|
||||
sessionScope: {
|
||||
scopeKind: "run",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
workspaceDir: root,
|
||||
relativeTaskDirectory: "tasks/thread-main/turn-1",
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("call-1", {
|
||||
action: "list",
|
||||
openclawSessionKey: "thread-other",
|
||||
runId: "turn-2",
|
||||
workspaceDir: "/",
|
||||
});
|
||||
|
||||
expect(result.content[0]?.text).toContain("current.txt");
|
||||
expect(result.content[0]?.text).not.toContain("other.txt");
|
||||
expect(result.content[0]?.text).not.toContain("global.txt");
|
||||
});
|
||||
});
|
||||
|
||||
async function callGatewayMethod(
|
||||
methods: Map<string, GatewayMethodHandler>,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<GatewayMethodResponse> {
|
||||
const handler = methods.get(method);
|
||||
if (!handler) {
|
||||
throw new Error(`missing gateway method ${method}`);
|
||||
}
|
||||
let response: GatewayMethodResponse | undefined;
|
||||
await handler({
|
||||
params,
|
||||
respond: (ok: boolean, payload?: Record<string, unknown>, error?: GatewayMethodResponse["error"]) => {
|
||||
response = { ok, payload, error };
|
||||
},
|
||||
} as Parameters<GatewayMethodHandler>[0]);
|
||||
if (!response) {
|
||||
throw new Error(`gateway method ${method} did not respond`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
287
index.ts
287
index.ts
@ -3,31 +3,250 @@ import type {
|
||||
GatewayRequestHandlerOptions,
|
||||
OpenClawPluginApi,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
collectAndSnapshotXWorkmateArtifacts,
|
||||
exportXWorkmateArtifacts,
|
||||
prepareXWorkmateArtifacts,
|
||||
readXWorkmateArtifact,
|
||||
formatArtifactManifestMarkdown,
|
||||
} from "./src/exportArtifacts.js";
|
||||
import {
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
registerXWorkmateSessionExtension,
|
||||
} from "./src/taskState.js";
|
||||
|
||||
type XWorkmateToolContext = {
|
||||
config?: unknown;
|
||||
workspaceDir?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
sessionScope?: XWorkmatePluginSessionScope;
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
id: "xworkmate-artifacts",
|
||||
name: "XWorkmate Artifacts",
|
||||
description: "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.",
|
||||
register,
|
||||
type XWorkmatePluginSessionScope = {
|
||||
scopeKind?: "global" | "session" | "run";
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
workspaceDir?: string;
|
||||
relativeTaskDirectory?: string;
|
||||
};
|
||||
|
||||
type XWorkmateResolvedRunScope = {
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
workspaceDir?: string;
|
||||
artifactScope?: string;
|
||||
};
|
||||
|
||||
type XWorkmateGatewayRequestScope = {
|
||||
sessionScope?: XWorkmatePluginSessionScope;
|
||||
};
|
||||
|
||||
function scopedGatewayParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const sessionScope = (getPluginRuntimeGatewayRequestScope() as XWorkmateGatewayRequestScope | undefined)?.sessionScope;
|
||||
const runScope = resolveRunScope({ sessionScope });
|
||||
if (!runScope) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
openclawSessionKey: runScope.sessionKey,
|
||||
runId: runScope.runId,
|
||||
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
|
||||
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRunScope(ctx: {
|
||||
sessionScope?: XWorkmatePluginSessionScope;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
workspaceDir?: string;
|
||||
}): XWorkmateResolvedRunScope | undefined {
|
||||
const scope = ctx.sessionScope;
|
||||
const sessionKey = scope?.sessionKey || ctx.sessionKey;
|
||||
const runId = scope?.runId || ctx.runId || "";
|
||||
if (!sessionKey || !runId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionKey,
|
||||
runId,
|
||||
...(scope?.workspaceDir || ctx.workspaceDir ? { workspaceDir: scope?.workspaceDir || ctx.workspaceDir } : {}),
|
||||
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stringParam(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function lastAssistantText(messages: unknown): string | undefined {
|
||||
if (!Array.isArray(messages)) return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const record = message as Record<string, unknown>;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant") continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim()) return content.trim();
|
||||
if (!Array.isArray(content)) continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const item = block as Record<string, unknown>;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const plugin = definePluginEntry({
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
description: "OpenClaw logical isolation support for multi-session plugin runtimes and scoped XWorkmate artifacts.",
|
||||
register,
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
|
||||
function register(api: OpenClawPluginApi) {
|
||||
registerXWorkmateSessionExtension(api);
|
||||
|
||||
api.registerHook(
|
||||
"session_start",
|
||||
async (event: any) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(event?.context ?? event);
|
||||
const openclawSessionKey = stringParam(params.openclawSessionKey);
|
||||
if (openclawSessionKey && params.runId) {
|
||||
const hookParams = { ...params, openclawSessionKey };
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: hookParams,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: hookParams,
|
||||
artifactScope: prepared.artifactScope,
|
||||
source: "session_start",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
api.logger?.warn?.(`xworkmate session_start preparation failed: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
{ name: "openclaw-multi-session-plugins.session-start" },
|
||||
);
|
||||
|
||||
api.on(
|
||||
"agent_end",
|
||||
async (event: any, ctx: any) => {
|
||||
try {
|
||||
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
|
||||
const runId = stringParam(event?.runId ?? ctx?.runId);
|
||||
if (!openclawSessionKey || !runId) {
|
||||
return;
|
||||
}
|
||||
await recordXWorkmateTaskRunTerminal({
|
||||
api,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
success: event?.success === true,
|
||||
output: lastAssistantText(event?.messages),
|
||||
error: event?.error,
|
||||
});
|
||||
} catch (error) {
|
||||
api.logger?.warn?.(`xworkmate agent_end state capture failed: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
api.registerGatewayMethod("xworkmate.session.prepare", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(opts.params);
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params,
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
const payload = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(
|
||||
true,
|
||||
{
|
||||
...payload,
|
||||
mapping,
|
||||
appThreadKey: mapping.appThreadKey,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: String(error).includes("conflict") ? "CONFLICT" : "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("xworkmate.tasks.get", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
} catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
params: opts.params,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
} catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await collectAndSnapshotXWorkmateArtifacts({
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -42,7 +261,7 @@ function register(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
params: { ...opts.params, includeContent: false },
|
||||
params: { ...scopedGatewayParams(opts.params), includeContent: false },
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -57,7 +276,7 @@ function register(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod("xworkmate.artifacts.read", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await readXWorkmateArtifact({
|
||||
params: opts.params,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
@ -70,7 +289,7 @@ function register(api: OpenClawPluginApi) {
|
||||
}
|
||||
});
|
||||
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||
names: ["xworkmate_artifacts"],
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
}
|
||||
@ -80,8 +299,8 @@ function createXWorkmateArtifactsTool(
|
||||
ctx: XWorkmateToolContext,
|
||||
): AnyAgentTool {
|
||||
return {
|
||||
name: "xworkmate_artifacts",
|
||||
label: "XWorkmate Artifacts",
|
||||
name: "openclaw_multi_session_artifacts",
|
||||
label: "openclaw-multi-session-plugins",
|
||||
description:
|
||||
"List generated artifacts in the current OpenClaw workspace or read one small artifact as base64 for XWorkmate.",
|
||||
parameters: {
|
||||
@ -95,7 +314,15 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
relativePath: {
|
||||
type: "string",
|
||||
description: "Artifact path relative to the workspace. Required for action=read.",
|
||||
description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
|
||||
},
|
||||
artifactScope: {
|
||||
type: "string",
|
||||
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
|
||||
},
|
||||
artifactRef: {
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Bound to the issuing task scope.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
@ -114,11 +341,29 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const runScope = resolveRunScope(ctx);
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
const {
|
||||
sessionKey: _ignoredSessionKey,
|
||||
openclawSessionKey: _ignoredOpenclawSessionKey,
|
||||
runId: _ignoredRunId,
|
||||
workspaceDir: _ignoredWorkspaceDir,
|
||||
...operationParams
|
||||
} = params;
|
||||
const baseParams = {
|
||||
...params,
|
||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
...operationParams,
|
||||
openclawSessionKey: sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
};
|
||||
if (action === "list") {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
@ -126,7 +371,7 @@ function createXWorkmateArtifactsTool(
|
||||
config: ctx.config ?? api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
return { content: [{ type: "text", text: payload.manifestMarkdown }] };
|
||||
return { content: [{ type: "text", text: formatArtifactManifestMarkdown(payload) }], details: {} };
|
||||
}
|
||||
if (action === "read") {
|
||||
const payload = await readXWorkmateArtifact({
|
||||
@ -137,16 +382,16 @@ function createXWorkmateArtifactsTool(
|
||||
const artifact = payload.artifacts[0];
|
||||
const text = artifact
|
||||
? [
|
||||
payload.manifestMarkdown,
|
||||
formatArtifactManifestMarkdown(payload),
|
||||
"",
|
||||
artifact.content
|
||||
? `Base64 content for \`${artifact.relativePath}\`:\n\n\`\`\`base64\n${artifact.content}\n\`\`\``
|
||||
: `\`${artifact.relativePath}\` is larger than maxInlineBytes; use the workspace path to download it directly.`,
|
||||
].join("\n")
|
||||
: payload.manifestMarkdown;
|
||||
return { content: [{ type: "text", text }] };
|
||||
: formatArtifactManifestMarkdown(payload);
|
||||
return { content: [{ type: "text", text }], details: {} };
|
||||
}
|
||||
throw new Error("action must be list or read");
|
||||
},
|
||||
} as AnyAgentTool;
|
||||
} as unknown as AnyAgentTool;
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
{
|
||||
"id": "xworkmate-artifacts",
|
||||
"name": "XWorkmate Artifacts",
|
||||
"description": "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.",
|
||||
"id": "openclaw-multi-session-plugins",
|
||||
"name": "openclaw-multi-session-plugins",
|
||||
"description": "OpenClaw logical isolation support for multi-session plugin runtimes and scoped XWorkmate artifacts.",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["openclaw_multi_session_artifacts"],
|
||||
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@ -20,6 +24,10 @@
|
||||
"maxInlineBytes": {
|
||||
"type": "number",
|
||||
"description": "Default maximum file size to inline as base64."
|
||||
},
|
||||
"artifactRefSigningSecret": {
|
||||
"type": "string",
|
||||
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -35,6 +43,11 @@
|
||||
"maxInlineBytes": {
|
||||
"label": "Max Inline Bytes",
|
||||
"help": "Upper bound for base64 inline artifact content. Default: 10485760."
|
||||
},
|
||||
"artifactRefSigningSecret": {
|
||||
"label": "Artifact Ref Signing Secret",
|
||||
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
|
||||
"sensitive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5524
package-lock.json
generated
Normal file
5524
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "xworkmate-artifacts",
|
||||
"version": "0.1.3",
|
||||
"description": "XWorkmate artifact export plugin for OpenClaw Gateway",
|
||||
"name": "openclaw-multi-session-plugins",
|
||||
"version": "2026.6.1",
|
||||
"description": "OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@ -9,15 +9,18 @@
|
||||
"xworkmate",
|
||||
"artifacts",
|
||||
"gateway",
|
||||
"plugin"
|
||||
"plugin",
|
||||
"multi-session",
|
||||
"session-isolation",
|
||||
"logical-isolation"
|
||||
],
|
||||
"homepage": "https://github.com/x-evor/xworkmate-artifacts#readme",
|
||||
"homepage": "https://github.com/x-evor/openclaw-multi-session-plugins#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/x-evor/xworkmate-artifacts.git"
|
||||
"url": "git+https://github.com/x-evor/openclaw-multi-session-plugins.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/x-evor/xworkmate-artifacts/issues"
|
||||
"url": "https://github.com/x-evor/openclaw-multi-session-plugins/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@ -41,10 +44,9 @@
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"openclaw": "2026.5.3-1",
|
||||
"openclaw": "2026.5.28",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
3197
pnpm-lock.yaml
generated
3197
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
'@google/genai': set this to true or false
|
||||
esbuild: set this to true or false
|
||||
openclaw: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
tree-sitter-bash: set this to true or false
|
||||
35
src/expectedArtifactDirs.ts
Normal file
35
src/expectedArtifactDirs.ts
Normal file
@ -0,0 +1,35 @@
|
||||
function optionalString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function safeExpectedArtifactDir(value: unknown): string {
|
||||
const relativePath = optionalString(value);
|
||||
if (!relativePath) {
|
||||
return "";
|
||||
}
|
||||
if (/^[A-Za-z]:[\\/]/u.test(relativePath) || relativePath.startsWith("/") || relativePath.includes("\0")) {
|
||||
throw new Error("expectedArtifactDir must stay inside the workspace");
|
||||
}
|
||||
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
|
||||
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
|
||||
throw new Error("expectedArtifactDir must stay inside the workspace");
|
||||
}
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
export function normalizeExpectedArtifactDirs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of value) {
|
||||
const normalized = safeExpectedArtifactDir(entry);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
403
src/taskState.test.ts
Normal file
403
src/taskState.test.ts
Normal file
@ -0,0 +1,403 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
} from "./taskState.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
|
||||
function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) {
|
||||
const sessions = new Map<string, any>();
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig,
|
||||
logger: { warn: () => {} },
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
getSessionEntry: ({ sessionKey }: { sessionKey: string }) => sessions.get(sessionKey),
|
||||
listSessionEntries: () =>
|
||||
[...sessions.entries()].map(([sessionKey, entry]) => ({
|
||||
sessionKey,
|
||||
entry,
|
||||
})),
|
||||
patchSessionEntry: async ({
|
||||
sessionKey,
|
||||
fallbackEntry,
|
||||
update,
|
||||
}: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: any;
|
||||
update: (entry: any) => Partial<any> | null;
|
||||
}) => {
|
||||
const current = sessions.get(sessionKey) ?? fallbackEntry;
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const patch = update(current);
|
||||
if (patch) {
|
||||
sessions.set(sessionKey, { ...current, ...patch });
|
||||
}
|
||||
return sessions.get(sessionKey) ?? null;
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: ({ sessionKey }: { sessionKey: string }) => ({
|
||||
resolve: (token: string) => tasks[`${sessionKey}:${token}`],
|
||||
get: (token: string) => tasks[`${sessionKey}:${token}`],
|
||||
findLatest: () => tasks[`${sessionKey}:latest`],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: api as any, sessions };
|
||||
}
|
||||
|
||||
async function createWorkspaceFixture() {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), "xworkmate-task-state-"));
|
||||
}
|
||||
|
||||
describe("xworkmate task state mapping", () => {
|
||||
it("requires typed appThreadKey metadata", async () => {
|
||||
const { api } = createApiFixture();
|
||||
|
||||
await expect(
|
||||
recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
sessionKey: "draft:legacy",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("appThreadKey required");
|
||||
});
|
||||
|
||||
it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => {
|
||||
const { api, sessions } = createApiFixture();
|
||||
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
expectedArtifactDirs: ["assets/images", "reports/"],
|
||||
createdAt: "2026-06-05T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapping).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
expectedArtifactDirs: ["assets/images/", "reports/"],
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
expect(
|
||||
sessions.get("draft:1780658097668838-1").pluginExtensions[XWORKMATE_PLUGIN_ID][
|
||||
XWORKMATE_SESSION_EXTENSION_NAMESPACE
|
||||
],
|
||||
).toMatchObject(mapping);
|
||||
});
|
||||
|
||||
it("fails closed when an existing mapping points to a different app thread", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:first",
|
||||
openclawSessionKey: "draft:first",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:second",
|
||||
openclawSessionKey: "draft:first",
|
||||
runId: "run-2",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("conflict");
|
||||
});
|
||||
|
||||
it("resolves appThreadKey through pluginExtensions before querying native tasks", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:1780658097668838-1:run-1": {
|
||||
taskId: "task-1",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
status: "completed",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unknown evidence from task artifacts when native task record is unavailable", async () => {
|
||||
const workspaceDir = await createWorkspaceFixture();
|
||||
const appThreadKey = "draft:sample-task";
|
||||
const openclawSessionKey = "agent:main:draft:sample-task";
|
||||
const runId = "turn-sample";
|
||||
const artifactDir = path.join(workspaceDir, "tasks", "agent_main_draft_sample-task", runId);
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
await fs.writeFile(path.join(artifactDir, "series.config.json"), "{}\n", "utf8");
|
||||
|
||||
const { api } = createApiFixture({}, { workspaceDir });
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
status: "unknown",
|
||||
taskStatus: "unknown",
|
||||
evidence: "artifacts_present",
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
task: {
|
||||
source: "artifact_fallback",
|
||||
status: "unknown",
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
relativePath: "series.config.json",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
artifactCount: 1,
|
||||
});
|
||||
expect((result.warnings as string[]).some((entry) => entry.includes("task status is unknown"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns no_native_task_record when neither native task record nor task artifacts exist", async () => {
|
||||
const workspaceDir = await createWorkspaceFixture();
|
||||
const { api } = createApiFixture({}, { workspaceDir });
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:no-task",
|
||||
openclawSessionKey: "draft:no-task",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:no-task",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "no_native_task_record",
|
||||
mapping: {
|
||||
appThreadKey: "draft:no-task",
|
||||
openclawSessionKey: "draft:no-task",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a durable failed agent terminal state when the native task record is absent", async () => {
|
||||
const workspaceDir = await createWorkspaceFixture();
|
||||
const { api } = createApiFixture({}, { workspaceDir });
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:failed-run",
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
});
|
||||
await recordXWorkmateTaskRunTerminal({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
success: false,
|
||||
output: "任务执行失败前的说明",
|
||||
error: "401 Authentication Fails, api_key=sk-secret-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: "failed",
|
||||
taskStatus: "failed",
|
||||
terminal: true,
|
||||
terminalSource: "agent_end",
|
||||
output: "任务执行失败前的说明",
|
||||
resultSummary: "任务执行失败前的说明",
|
||||
message: "任务执行失败前的说明",
|
||||
task: {
|
||||
runId: "turn-failed",
|
||||
status: "failed",
|
||||
source: "xworkmate_run_state",
|
||||
},
|
||||
error: "401 Authentication Fails, api_key=<redacted>",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a recorded running state while the agent turn is still active", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:running-run",
|
||||
openclawSessionKey: "agent:main:draft:running-run",
|
||||
runId: "turn-running",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:running-run",
|
||||
runId: "turn-running",
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:running-run",
|
||||
runId: "turn-running",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: "running",
|
||||
terminal: false,
|
||||
terminalSource: "session_prepare",
|
||||
task: {
|
||||
runId: "turn-running",
|
||||
status: "running",
|
||||
source: "xworkmate_run_state",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept legacy sessionKey as a task lookup alias", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:legacy:run-1": {
|
||||
taskId: "task-legacy",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
sessionKey: "draft:legacy",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid_lookup",
|
||||
});
|
||||
});
|
||||
|
||||
it("can read mapping by appThreadKey from pluginExtensions", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:lookup:run-1": {
|
||||
taskId: "task-1",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:lookup",
|
||||
openclawSessionKey: "draft:lookup",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:lookup",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
appThreadKey: "draft:lookup",
|
||||
openclawSessionKey: "draft:lookup",
|
||||
});
|
||||
});
|
||||
});
|
||||
692
src/taskState.ts
Normal file
692
src/taskState.ts
Normal file
@ -0,0 +1,692 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
const MAX_RECORDED_TASK_RUNS = 32;
|
||||
|
||||
export type XWorkmateTaskMetadataV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey?: string;
|
||||
expectedArtifactDirs: string[];
|
||||
requestId?: string;
|
||||
externalTaskId?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type XWorkmateSessionMappingSource =
|
||||
| "session_start"
|
||||
| "bridge_prepare";
|
||||
|
||||
export type XWorkmateSessionMappingV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey: string;
|
||||
expectedArtifactDirs: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
};
|
||||
|
||||
export type XWorkmateTaskLookupErrorCode =
|
||||
| "mapping_not_found"
|
||||
| "task_not_found"
|
||||
| "no_native_task_record"
|
||||
| "conflict"
|
||||
| "invalid_lookup";
|
||||
|
||||
export type XWorkmateTaskLookupError = {
|
||||
ok: false;
|
||||
code: XWorkmateTaskLookupErrorCode;
|
||||
message: string;
|
||||
mapping?: XWorkmateSessionMappingV1;
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
|
||||
export type XWorkmateRecordedTaskRunV1 = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
success: boolean;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type SessionEntry = Record<string, unknown> & {
|
||||
pluginExtensions?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type PatchSessionEntry = (params: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: SessionEntry;
|
||||
preserveActivity?: boolean;
|
||||
update: (entry: SessionEntry) => Partial<SessionEntry> | null;
|
||||
}) => Promise<SessionEntry | null> | SessionEntry | null;
|
||||
|
||||
type GetSessionEntry = (params: { sessionKey: string }) => SessionEntry | undefined;
|
||||
|
||||
type BoundTaskRunsRuntime = {
|
||||
get?: (taskId: string) => unknown;
|
||||
list?: () => unknown[];
|
||||
findLatest?: () => unknown;
|
||||
resolve?: (token: string) => unknown;
|
||||
};
|
||||
|
||||
export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) {
|
||||
const registerExtension =
|
||||
api.session?.state?.registerSessionExtension ?? (api as any).registerSessionExtension;
|
||||
if (typeof registerExtension !== "function") {
|
||||
return;
|
||||
}
|
||||
registerExtension({
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
description: "Durable XWorkmate app/OpenClaw session key mapping.",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
project: (ctx: { sessionKey: string; state?: unknown }): any => {
|
||||
const state = asRecord(ctx.state);
|
||||
return state ?? {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordXWorkmateSessionMapping(input: {
|
||||
api: OpenClawPluginApi;
|
||||
params: Record<string, unknown>;
|
||||
artifactScope?: string;
|
||||
source?: XWorkmateSessionMappingSource;
|
||||
}): Promise<XWorkmateSessionMappingV1> {
|
||||
const metadata = normalizeXWorkmateTaskMetadataV1(input.params);
|
||||
const openclawSessionKey = requiredString(
|
||||
input.params.openclawSessionKey ?? metadata.openclawSessionKey,
|
||||
"openclawSessionKey required",
|
||||
);
|
||||
return upsertXWorkmateSessionMapping(input.api, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
openclawSessionKey,
|
||||
},
|
||||
openclawSessionKey,
|
||||
source: input.source ?? "bridge_prepare",
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordXWorkmateTaskRunStarted(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1> {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: "running",
|
||||
success: false,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordXWorkmateTaskRunTerminal(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1> {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: input.success ? "completed" : "failed",
|
||||
success: input.success,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
output: sanitizeTaskRunOutput(input.output),
|
||||
error: sanitizeTaskRunError(input.error),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 {
|
||||
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
|
||||
const schemaVersion = Number(envelope.schemaVersion ?? 1);
|
||||
if (schemaVersion !== 1) {
|
||||
throw new Error("schemaVersion must be 1");
|
||||
}
|
||||
const appThreadKey = requiredString(envelope.appThreadKey, "appThreadKey required");
|
||||
const createdAt = optionalString(envelope.createdAt) || new Date().toISOString();
|
||||
return compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
appThreadKey,
|
||||
openclawSessionKey: optionalString(envelope.openclawSessionKey),
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(envelope.expectedArtifactDirs),
|
||||
requestId: optionalString(envelope.requestId),
|
||||
externalTaskId: optionalString(envelope.externalTaskId ?? envelope.taskId),
|
||||
createdAt,
|
||||
}) as XWorkmateTaskMetadataV1;
|
||||
}
|
||||
|
||||
async function upsertXWorkmateSessionMapping(
|
||||
api: OpenClawPluginApi,
|
||||
input: {
|
||||
metadata: XWorkmateTaskMetadataV1;
|
||||
openclawSessionKey: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
},
|
||||
): Promise<XWorkmateSessionMappingV1> {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let mapping: XWorkmateSessionMappingV1 | undefined;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const existing = readMappingFromEntry(entry);
|
||||
if (existing) {
|
||||
assertMappingCompatible(existing, input.metadata.appThreadKey, input.openclawSessionKey);
|
||||
mapping = {
|
||||
...existing,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
updatedAt: now,
|
||||
source: existing.source,
|
||||
};
|
||||
} else {
|
||||
mapping = compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
appThreadKey: input.metadata.appThreadKey,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
createdAt: input.metadata.createdAt || now,
|
||||
updatedAt: now,
|
||||
source: input.source,
|
||||
}) as XWorkmateSessionMappingV1;
|
||||
}
|
||||
return {
|
||||
pluginExtensions: writeMappingToPluginExtensions(entry.pluginExtensions, mapping),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
throw new Error("failed to write xworkmate session mapping");
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
async function readXWorkmateSessionMapping(
|
||||
api: OpenClawPluginApi,
|
||||
lookup: {
|
||||
appThreadKey?: string;
|
||||
openclawSessionKey?: string;
|
||||
},
|
||||
): Promise<XWorkmateSessionMappingV1 | undefined> {
|
||||
const getSessionEntry = resolveGetSessionEntry(api);
|
||||
if (!getSessionEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const openclawSessionKey = optionalString(lookup.openclawSessionKey);
|
||||
if (openclawSessionKey) {
|
||||
return readMappingFromEntry(getSessionEntry({ sessionKey: openclawSessionKey }));
|
||||
}
|
||||
const appThreadKey = optionalString(lookup.appThreadKey);
|
||||
if (!appThreadKey) {
|
||||
return undefined;
|
||||
}
|
||||
const listSessionEntries = resolveListSessionEntries(api);
|
||||
for (const item of listSessionEntries?.() ?? []) {
|
||||
const mapping = readMappingFromEntry(item.entry);
|
||||
if (mapping?.appThreadKey === appThreadKey) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getXWorkmateTaskSnapshot(input: {
|
||||
api: OpenClawPluginApi;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const params = input.params ?? {};
|
||||
const appThreadKey = optionalString(params.appThreadKey);
|
||||
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
|
||||
const mapping = await readXWorkmateSessionMapping(input.api, {
|
||||
appThreadKey,
|
||||
openclawSessionKey: explicitOpenclawSessionKey,
|
||||
});
|
||||
if (!mapping && appThreadKey && !explicitOpenclawSessionKey) {
|
||||
return lookupError("mapping_not_found", `No OpenClaw session mapping found for ${appThreadKey}`);
|
||||
}
|
||||
const openclawSessionKey = mapping?.openclawSessionKey || explicitOpenclawSessionKey;
|
||||
if (!openclawSessionKey) {
|
||||
return lookupError("invalid_lookup", "openclawSessionKey or appThreadKey required");
|
||||
}
|
||||
|
||||
const runId = optionalString(params.runId);
|
||||
const taskId = optionalString(params.taskId);
|
||||
const task = resolveNativeTask(input.api, {
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId,
|
||||
});
|
||||
const includeArtifacts = params.includeArtifacts !== false;
|
||||
if (!task) {
|
||||
const recordedRun = runId
|
||||
? readXWorkmateTaskRun(input.api, openclawSessionKey, runId)
|
||||
: undefined;
|
||||
const exported = includeArtifacts && runId
|
||||
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
|
||||
: undefined;
|
||||
if (recordedRun) {
|
||||
return {
|
||||
success: recordedRun.status === "running" ? true : recordedRun.success,
|
||||
status: recordedRun.status,
|
||||
taskStatus: recordedRun.status,
|
||||
terminal: recordedRun.status !== "running",
|
||||
terminalSource: recordedRun.status === "running" ? "session_prepare" : "agent_end",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: recordedRun.status,
|
||||
success: recordedRun.success,
|
||||
source: "xworkmate_run_state",
|
||||
startedAt: recordedRun.startedAt,
|
||||
updatedAt: recordedRun.updatedAt,
|
||||
completedAt: recordedRun.completedAt,
|
||||
error: recordedRun.error,
|
||||
},
|
||||
output: recordedRun.output,
|
||||
resultSummary: recordedRun.output,
|
||||
error: recordedRun.error,
|
||||
message: recordedRun.output ?? recordedRun.error,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
if (exported?.artifacts.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: "unknown",
|
||||
taskStatus: "unknown",
|
||||
evidence: "artifacts_present",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: "unknown",
|
||||
source: "artifact_fallback",
|
||||
},
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported.artifactScope,
|
||||
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
||||
scopeKind: exported.scopeKind,
|
||||
artifacts: exported.artifacts,
|
||||
constraintSatisfied: exported.constraintSatisfied,
|
||||
missingRequiredExtensions: exported.missingRequiredExtensions,
|
||||
warnings: [
|
||||
...exported.warnings,
|
||||
`Native OpenClaw task record was unavailable for ${openclawSessionKey}; artifacts are present but task status is unknown.`,
|
||||
],
|
||||
artifactCount: exported.artifacts.length,
|
||||
};
|
||||
}
|
||||
const code: XWorkmateTaskLookupErrorCode = runId || taskId ? "no_native_task_record" : "task_not_found";
|
||||
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
|
||||
}
|
||||
|
||||
const taskStatus = optionalString((task as any).status) || "running";
|
||||
const exported = includeArtifacts
|
||||
? await exportArtifactsForTaskLookup(
|
||||
input,
|
||||
params,
|
||||
openclawSessionKey,
|
||||
runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
|
||||
mapping,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: appStatusFromTaskStatus(taskStatus),
|
||||
taskStatus,
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId: runId || optionalString((task as any).runId),
|
||||
taskId: taskId || optionalString((task as any).taskId),
|
||||
task,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertXWorkmateTaskRun(
|
||||
api: OpenClawPluginApi,
|
||||
input: Omit<XWorkmateRecordedTaskRunV1, "schemaVersion" | "startedAt"> & {
|
||||
openclawSessionKey: string;
|
||||
startedAt?: string;
|
||||
},
|
||||
): Promise<XWorkmateRecordedTaskRunV1> {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
let recorded: XWorkmateRecordedTaskRunV1 | undefined;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const runs = readTaskRunsFromEntry(entry);
|
||||
const existing = runs[input.runId];
|
||||
recorded = compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
runId: input.runId,
|
||||
status: input.status,
|
||||
success: input.success,
|
||||
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
|
||||
updatedAt: input.updatedAt,
|
||||
completedAt: input.completedAt,
|
||||
output: input.output,
|
||||
error: input.error,
|
||||
}) as XWorkmateRecordedTaskRunV1;
|
||||
runs[input.runId] = recorded;
|
||||
const boundedRuns = Object.fromEntries(
|
||||
Object.entries(runs)
|
||||
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
|
||||
.slice(0, MAX_RECORDED_TASK_RUNS),
|
||||
);
|
||||
return {
|
||||
pluginExtensions: {
|
||||
...(entry.pluginExtensions ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
|
||||
schemaVersion: 1,
|
||||
runs: boundedRuns,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!recorded) {
|
||||
throw new Error("failed to write xworkmate task run state");
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function readXWorkmateTaskRun(
|
||||
api: OpenClawPluginApi,
|
||||
openclawSessionKey: string,
|
||||
runId: string,
|
||||
): XWorkmateRecordedTaskRunV1 | undefined {
|
||||
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
|
||||
return readTaskRunsFromEntry(entry)[runId];
|
||||
}
|
||||
|
||||
function readTaskRunsFromEntry(entry: SessionEntry | undefined | null): Record<string, XWorkmateRecordedTaskRunV1> {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
|
||||
if (store?.schemaVersion !== 1) {
|
||||
return {};
|
||||
}
|
||||
const runs = asRecord(store.runs) ?? {};
|
||||
const result: Record<string, XWorkmateRecordedTaskRunV1> = {};
|
||||
for (const [key, rawValue] of Object.entries(runs)) {
|
||||
const raw = asRecord(rawValue);
|
||||
const runId = optionalString(raw?.runId) || key;
|
||||
const status = optionalString(raw?.status);
|
||||
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
|
||||
continue;
|
||||
}
|
||||
result[runId] = compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
runId,
|
||||
status,
|
||||
success: raw?.success === true,
|
||||
startedAt: optionalString(raw?.startedAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw?.updatedAt) || new Date(0).toISOString(),
|
||||
completedAt: optionalString(raw?.completedAt),
|
||||
output: optionalString(raw?.output),
|
||||
error: optionalString(raw?.error),
|
||||
}) as XWorkmateRecordedTaskRunV1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sanitizeTaskRunOutput(value: unknown): string | undefined {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw.slice(0, 16 * 1024);
|
||||
}
|
||||
|
||||
function sanitizeTaskRunError(value: unknown): string | undefined {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw
|
||||
.replace(/\b(sk|nvapi)-[A-Za-z0-9._-]+\b/gi, "$1-<redacted>")
|
||||
.replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1<redacted>")
|
||||
.slice(0, 2048);
|
||||
}
|
||||
|
||||
async function exportArtifactsForTaskLookup(
|
||||
input: { api: OpenClawPluginApi; params: Record<string, unknown> },
|
||||
params: Record<string, unknown>,
|
||||
openclawSessionKey: string,
|
||||
runId: string,
|
||||
mapping?: XWorkmateSessionMappingV1,
|
||||
) {
|
||||
return exportXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
|
||||
includeContent: params.includeContent ?? false,
|
||||
},
|
||||
config: input.api.config,
|
||||
pluginConfig: input.api.pluginConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNativeTask(
|
||||
api: OpenClawPluginApi,
|
||||
input: { openclawSessionKey: string; runId?: string; taskId?: string },
|
||||
): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey }) as
|
||||
| BoundTaskRunsRuntime
|
||||
| undefined;
|
||||
if (!bound) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = input.taskId || input.runId || "";
|
||||
const resolved = lookup ? bound.resolve?.(lookup) || bound.get?.(lookup) : bound.findLatest?.();
|
||||
return asRecord(resolved);
|
||||
} catch (error) {
|
||||
api.logger?.warn?.(
|
||||
`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function lookupError(
|
||||
code: XWorkmateTaskLookupErrorCode,
|
||||
message: string,
|
||||
mapping?: XWorkmateSessionMappingV1,
|
||||
): XWorkmateTaskLookupError {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readMappingFromEntry(entry: SessionEntry | undefined | null): XWorkmateSessionMappingV1 | undefined {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const raw = asRecord(pluginState?.[XWORKMATE_SESSION_EXTENSION_NAMESPACE]);
|
||||
if (!raw || raw.schemaVersion !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const appThreadKey = optionalString(raw.appThreadKey);
|
||||
const openclawSessionKey = optionalString(raw.openclawSessionKey);
|
||||
if (!appThreadKey || !openclawSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
appThreadKey,
|
||||
openclawSessionKey,
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(raw.expectedArtifactDirs),
|
||||
createdAt: optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw.updatedAt) || optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
source: parseMappingSource(raw.source),
|
||||
};
|
||||
}
|
||||
|
||||
function writeMappingToPluginExtensions(
|
||||
current: SessionEntry["pluginExtensions"],
|
||||
mapping: XWorkmateSessionMappingV1 | undefined,
|
||||
): SessionEntry["pluginExtensions"] {
|
||||
if (!mapping) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...(current ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(current?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_SESSION_EXTENSION_NAMESPACE]: mapping,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertMappingCompatible(
|
||||
existing: XWorkmateSessionMappingV1,
|
||||
appThreadKey: string,
|
||||
openclawSessionKey: string,
|
||||
) {
|
||||
if (existing.appThreadKey !== appThreadKey || existing.openclawSessionKey !== openclawSessionKey) {
|
||||
throw new Error("conflict: xworkmate session mapping already points to a different session");
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePatchSessionEntry(api: OpenClawPluginApi): PatchSessionEntry | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.patchSessionEntry;
|
||||
return typeof candidate === "function" ? (candidate as PatchSessionEntry) : undefined;
|
||||
}
|
||||
|
||||
function resolveGetSessionEntry(api: OpenClawPluginApi): GetSessionEntry | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.getSessionEntry;
|
||||
return typeof candidate === "function" ? (candidate as GetSessionEntry) : undefined;
|
||||
}
|
||||
|
||||
function resolveListSessionEntries(
|
||||
api: OpenClawPluginApi,
|
||||
): (() => Array<{ sessionKey: string; entry: SessionEntry }>) | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.listSessionEntries;
|
||||
return typeof candidate === "function"
|
||||
? (candidate as () => Array<{ sessionKey: string; entry: SessionEntry }>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function appStatusFromTaskStatus(status: string): string {
|
||||
if (status === "succeeded") {
|
||||
return "completed";
|
||||
}
|
||||
if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") {
|
||||
return "failed";
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
|
||||
function parseMappingSource(value: unknown): XWorkmateSessionMappingSource {
|
||||
const source = optionalString(value);
|
||||
if (source === "session_start" || source === "bridge_prepare") {
|
||||
return source;
|
||||
}
|
||||
return "bridge_prepare";
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, message: string): string {
|
||||
const text = optionalString(value);
|
||||
if (!text) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string {
|
||||
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text === "<nil>" ? "" : text;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== ""),
|
||||
) as Partial<T>;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user