Compare commits

...

30 Commits

Author SHA1 Message Date
e705c69ba8
ci: unify ci/publish/deploy into one 3-stage pipeline (#4)
Merge ci.yml + publish.yml + deploy.yml into pipeline.yml with sequential
stages build -> publish(npm) -> deploy:
- build: install/test/typecheck/pack:check (runs on PR and push)
- publish: npm publish (needs build; release/tag/dispatch only)
- deploy: SSH install on ubuntu@openclaw.svc.plus (needs publish)

Version now flows from publish job output to deploy, removing the
workflow_run cross-workflow trigger. Vault roles/secrets unchanged.

Co-authored-by: Haitao Pan <haitao.pan@xworkmate.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:12:41 +08:00
8f0a15d906
ci: add release/* branch source validation workflow (#2)
release/* 仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
详见 iac_modules/docs/tldr-github-branch-model.md

Co-authored-by: Haitao Pan <haitao.pan@xworkmate.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:12:27 +08:00
Haitao Pan
849972ab5c chore: gitignore runtime release build artifacts (dist/assets, dist/runtime)
These are produced by the runtime-release workflow / local packaging and
published to GitHub Releases, not committed. The compiled library under
dist/ and dist/src/ stays tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:06:41 +08:00
Haitao Pan
d396760a4e fix(tasks): return durable assistant output 2026-06-27 12:14:01 +08:00
Haitao Pan
6ec2c10924 fix(tasks): distinguish prepared from terminal run state 2026-06-27 12:07:27 +08:00
Haitao Pan
d5f0e9f437 fix(tasks): persist gateway agent terminal state 2026-06-27 12:02:51 +08:00
Haitao Pan
baddb2f13d fix(ci): package runtime asset without recursive dist copy 2026-06-27 11:37:41 +08:00
Haitao Pan
1fe544c984 ci(runtime-release): publish stable runtime-latest tag alongside per-commit
Deployments resolve a deterministic releases/download/runtime-latest/ URL
instead of the mutable /releases/latest/ pointer (which collides with the
human-facing v* release track). Keeps --latest=false so GitHub's "Latest
release" stays on the v* tags. Per-commit runtime-<sha> release retained for
traceability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:30:56 +08:00
48c05db842 ci: build plugin runtime with npm (was pnpm w/ stale lockfile)
The workflow used pnpm install --frozen-lockfile against a stale pnpm-lock.yaml
while the repo migrated to npm (current package-lock.json v3), so it failed and
never published a release -> the deploy's download of
openclaw-multi-session-plugins-runtime-all.tar.gz from releases/latest 404'd.
Switch to npm ci + npm run build and trigger on package-lock.json.
2026-06-21 08:28:00 +00:00
Haitao Pan
c34217e70d ci: add GitHub Actions workflow for runtime release packaging 2026-06-19 19:03:48 +08:00
Haitao Pan
80bbce0955 fix: define openclaw plugin entry 2026-06-18 10:01:19 +08:00
Haitao Pan
df3aab97be fix(artifacts): update export artifacts module 2026-06-17 21:01:47 +08:00
Haitao Pan
0682fbf7cf refactor: share expected artifact dir normalization 2026-06-12 14:49:54 +08:00
Haitao Pan
bec9567f13 fix(deploy): install forced archive builds for OpenClaw plugin 2026-06-12 14:37:17 +08:00
Haitao Pan
dbb7cbfc93 chore(release): bump plugin package version to 2026.6.2 2026-06-12 14:29:12 +08:00
Haitao Pan
e75176db39 feat(artifacts): validate required export constraints 2026-06-12 14:08:08 +08:00
Haitao Pan
fc6a02ac1a fix(taskState): report artifact fallback as unknown evidence 2026-06-12 14:08:02 +08:00
Haitao Pan
35379f2fb0 fix: resolve xworkmate task snapshots from artifacts 2026-06-08 07:13:40 +08:00
Haitao Pan
f0ffa62f52 fix(ci): verify deployed version from package.json 2026-06-06 14:18:03 +08:00
Haitao Pan
8aaae6a8db fix(ci): separate vault deploy secrets 2026-06-06 14:15:19 +08:00
Haitao Pan
48dab93794 fix(ci): use base64 ssh deploy key 2026-06-06 14:14:41 +08:00
Haitao Pan
d4bb8b9ee1 ci: load github actions secrets from vault 2026-06-06 13:33:44 +08:00
Haitao Pan
96468db304 fix(ci): support alternate ssh secret 2026-06-06 12:46:04 +08:00
Haitao Pan
4cf4ae49af Create session mapping entries during prepare 2026-06-06 10:26:55 +08:00
Haitao Pan
529965aa3b Keep OpenClaw artifact export task scoped 2026-06-06 08:16:28 +08:00
Haitao Pan
d887df0f64 Refactor: Fix path breakage by copying global media to task scope, flatten workspace resolution cascade 2026-06-06 08:06:50 +08:00
Haitao Pan
d5ffc79098 Merge OpenClaw thin adapter refactor
# Conflicts:
#	dist/index.js
#	dist/src/exportArtifacts.js
#	dist/src/taskState.d.ts
#	dist/src/taskState.js
2026-06-06 07:58:35 +08:00
Haitao Pan
ba7b447af6 Simplify OpenClaw multi-session plugin adapter 2026-06-06 07:56:32 +08:00
Haitao Pan
cd3b645abe refactor: simplify task state to read openclawSessionKey directly 2026-06-06 07:25:03 +08:00
8c4dcb45bc
Merge pull request #1 from x-evor/release/v2026.6.1
Release/v2026.6.1
2026-06-06 06:29:46 +08:00
25 changed files with 7811 additions and 976 deletions

View File

@ -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

View File

@ -1,202 +0,0 @@
name: Deploy
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+"
release:
types:
- published
workflow_dispatch:
inputs:
version:
description: "Plugin version to install (e.g. 2026.6.1). Leave blank to use the release tag."
required: false
default: ""
force:
description: "Reinstall even if the same version is already installed."
required: false
default: "false"
type: choice
options:
- "false"
- "true"
concurrency:
group: openclaw-deploy
cancel-in-progress: false
permissions:
contents: read
jobs:
install-on-host:
name: Update plugin on ubuntu@openclaw.svc.plus
runs-on: ubuntu-latest
env:
SSH_HOST: ubuntu@openclaw.svc.plus
PLUGIN_NAME: openclaw-multi-session-plugins
steps:
- name: Resolve target version
id: version
run: |
set -euo pipefail
if [ -n "${{ inputs.version }}" ]; then
value="${{ inputs.version }}"
else
ref="${GITHUB_REF_NAME:-}"
value="${ref}"
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
if [ -z "${value}" ]; then
echo "::error::Could not resolve plugin version from inputs or GITHUB_REF_NAME"
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 }}
run: |
set -euo pipefail
PACKAGE="${PLUGIN_NAME}@${VERSION}"
if 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
ref="${GITHUB_REF_NAME:-release/v${VERSION}}"
install_spec="git+${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git#${ref}"
echo "::warning::${PACKAGE} is not published to npm yet; installing from ${install_spec}"
echo "source=github" >> "$GITHUB_OUTPUT"
echo "install_spec=${install_spec}" >> "$GITHUB_OUTPUT"
fi
- name: Configure SSH key
run: |
set -euo pipefail
if [ -z "${{ secrets.OPENCLAW_SSH_KEY }}" ]; then
echo "::error::Secret OPENCLAW_SSH_KEY is not set."
exit 1
fi
install -m 700 -d ~/.ssh
printf '%s' "${{ secrets.OPENCLAW_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: 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 "${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
fi
# Verify the installed version matches the requested version.
INSTALLED="$(npm ls -g "${PLUGIN_NAME}" --depth=0 2>/dev/null \
| awk -F'[@:]' '/'"${PLUGIN_NAME}"'@/ {print $2; exit}' || true)"
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

406
.github/workflows/pipeline.yml vendored Normal file
View File

@ -0,0 +1,406 @@
name: Pipeline
# 单一流水线,三个串联 stagebuild -> publish(npm) -> deploy。
# build : 安装/测试/类型检查/包内容校验PR 与 push 都跑)。
# publish : 发布到 npm仅 release / 版本 tag / 手动触发needs build
# deploy : SSH 安装到 ubuntu@openclaw.svc.plusneeds 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

View File

@ -1,77 +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: Verify npm publish access
shell: bash
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 save it as the repository secret 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
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- 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: ${{ secrets.NPM_TOKEN }}

107
.github/workflows/runtime-release.yaml vendored Normal file
View 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)"

View 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

6
.gitignore vendored
View File

@ -3,3 +3,9 @@ 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/

View File

@ -1,20 +1,27 @@
# openclaw-multi-session-plugins
OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifact manifests.
OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling.
## Why
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
The app can then sync generated files into its local thread workspace without
changing the UI or adding provider-specific routes.
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 those existing OpenClaw task/session
identities into isolated artifact directories, session key mapping, and signed
artifact reads.
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:
@ -72,16 +79,18 @@ Equivalent config shape for a linked checkout:
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 scope first. Otherwise it falls back to bridge/app runtime
params. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
mapping into OpenClaw's built-in multi-session model; it 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.
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"
}
@ -92,7 +101,7 @@ Prepare 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-...",
@ -107,7 +116,7 @@ Export request params:
```json
{
"sessionKey": "thread-main",
"openclawSessionKey": "agent:main:draft:thread-main",
"runId": "turn-1",
"artifactScope": "tasks/thread-main-.../turn-1-...",
"sinceUnixMs": 1770000000000,
@ -121,7 +130,7 @@ 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-...",
@ -144,9 +153,10 @@ Export 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 `sessionKey/runId`. `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.
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
@ -157,9 +167,10 @@ 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 `sessionKey/runId` task scope.
Signed task `artifactRef` values are accepted only for the same `sessionKey/runId`
that issued them. There is no unscoped arbitrary workspace read API.
`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

8
dist/index.d.ts vendored
View File

@ -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;

128
dist/index.js vendored
View File

@ -1,6 +1,7 @@
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 { createOrUpdateXWorkmateTaskRecord, createXWorkmateTaskStore, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateDetachedTaskRuntime, registerXWorkmateSessionExtension, } from "./src/taskState.js";
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, recordXWorkmateTaskRunStarted, recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js";
function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
const runScope = resolveRunScope({ sessionScope });
@ -9,7 +10,7 @@ function scopedGatewayParams(params) {
}
return {
...params,
sessionKey: runScope.sessionKey,
openclawSessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
@ -29,65 +30,134 @@ function resolveRunScope(ctx) {
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
};
}
const plugin = {
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) {
const taskStore = createXWorkmateTaskStore();
registerXWorkmateSessionExtension(api);
registerXWorkmateDetachedTaskRuntime(api, taskStore);
api.registerHook("session.start", async (event) => {
api.registerHook("session_start", async (event) => {
try {
const params = scopedGatewayParams(event?.context ?? event);
if (params.sessionKey && params.runId) {
createOrUpdateXWorkmateTaskRecord(taskStore, {
params,
status: "running",
progressSummary: "OpenClaw task is running",
});
const openclawSessionKey = stringParam(params.openclawSessionKey);
if (openclawSessionKey && params.runId) {
const hookParams = { ...params, openclawSessionKey };
const prepared = await prepareXWorkmateArtifacts({
params,
params: hookParams,
config: api.config,
pluginConfig: api.pluginConfig,
});
await recordXWorkmateSessionMapping({
api,
taskStore,
params,
params: hookParams,
artifactScope: prepared.artifactScope,
source: "session_start",
});
}
}
catch (error) {
api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`);
api.logger?.warn?.(`xworkmate session_start preparation failed: ${String(error)}`);
}
}, { name: "openclaw-multi-session-plugins.session-start" });
api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => {
api.on("agent_end", async (event, ctx) => {
try {
const payload = await getXWorkmateTaskSnapshot({
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
const runId = stringParam(event?.runId ?? ctx?.runId);
if (!openclawSessionKey || !runId) {
return;
}
await recordXWorkmateTaskRunTerminal({
api,
taskStore,
params: scopedGatewayParams(opts.params),
openclawSessionKey,
runId,
success: event?.success === true,
output: lastAssistantText(event?.messages),
error: event?.error,
});
opts.respond(true, payload, undefined);
}
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: "INVALID_REQUEST",
code: String(error).includes("conflict") ? "CONFLICT" : "INVALID_REQUEST",
message: error instanceof Error ? error.message : String(error),
});
}
});
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => {
try {
const payload = await prepareXWorkmateArtifacts({
const payload = await getXWorkmateTaskSnapshot({
api,
params: scopedGatewayParams(opts.params),
config: api.config,
pluginConfig: api.pluginConfig,
});
opts.respond(true, payload, undefined);
}
@ -220,10 +290,10 @@ function createXWorkmateArtifactsTool(api, ctx) {
throw new Error("runId required");
}
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const { sessionKey: _ignoredSessionKey, openclawSessionKey: _ignoredOpenclawSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const baseParams = {
...operationParams,
sessionKey,
openclawSessionKey: sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),

1
dist/src/expectedArtifactDirs.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];

33
dist/src/expectedArtifactDirs.js vendored Normal file
View 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;
}

View File

@ -1,4 +1,4 @@
export type XWorkmateArtifact = {
type XWorkmateArtifact = {
relativePath: string;
label: string;
contentType: string;
@ -10,8 +10,8 @@ export type XWorkmateArtifact = {
encoding?: "base64";
content?: string;
};
export type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = {
type XWorkmateArtifactScopeKind = "task";
type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -20,8 +20,16 @@ export type XWorkmateArtifactExport = {
scopeKind: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
constraintSatisfied: boolean;
missingRequiredExtensions: string[];
missingRequiredFileCounts: Record<string, {
expected: number;
actual: number;
}>;
};
export type XWorkmateArtifactPrepare = {
type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -31,8 +39,14 @@ export type XWorkmateArtifactPrepare = {
artifactDirectory: string;
relativeArtifactDirectory: string;
warnings: string[];
expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
};
export type XWorkmateArtifactSnapshot = {
type XWorkmateExpectedArtifactDirStatus = {
relativePath: string;
exists: boolean;
};
type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;

View File

@ -2,6 +2,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt
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";
@ -21,7 +22,8 @@ export async function prepareXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey 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) {
@ -37,6 +39,7 @@ export async function prepareXWorkmateArtifacts(input) {
const artifactScope = expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
await fs.mkdir(scopeRoot, { recursive: true });
const expectedArtifactDirStatus = await expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs);
return {
runId,
sessionKey,
@ -47,13 +50,15 @@ export async function prepareXWorkmateArtifacts(input) {
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.sessionKey, "sessionKey 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);
@ -120,11 +125,13 @@ export async function exportXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey 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,
@ -133,6 +140,7 @@ export async function exportXWorkmateArtifacts(input) {
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings = [];
const expectedDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
@ -152,7 +160,9 @@ export async function exportXWorkmateArtifacts(input) {
const scopeStat = await fs.stat(scopeRoot);
effectiveSince = Math.min(sinceUnixMs, scopeStat.birthtimeMs || scopeStat.mtimeMs);
}
catch { }
catch (error) {
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
}
}
const scopedCandidates = (await directoryExists(scopeRoot))
? await collectCandidates({
@ -165,9 +175,6 @@ export async function exportXWorkmateArtifacts(input) {
})
: [];
const candidates = scopedCandidates;
const expectedDirs = Array.isArray(params.expectedArtifactDirs)
? params.expectedArtifactDirs.map((d) => String(d).trim()).filter(Boolean)
: [];
if (candidates.length === 0 && expectedDirs.length > 0) {
for (const dir of expectedDirs) {
const dirPath = path.join(workspaceRoot, safeInputRelativePath(dir, "expectedArtifactDir"));
@ -190,6 +197,11 @@ export async function exportXWorkmateArtifacts(input) {
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;
}
@ -236,6 +248,8 @@ export async function exportXWorkmateArtifacts(input) {
}
artifacts.push(artifact);
}
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
const missingRequiredFileCounts = missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension);
const result = {
runId,
sessionKey,
@ -245,6 +259,11 @@ export async function exportXWorkmateArtifacts(input) {
scopeKind,
artifacts,
warnings,
expectedArtifactDirs: expectedDirs,
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
constraintSatisfied: missingRequiredExtensions.length === 0 && Object.keys(missingRequiredFileCounts).length === 0,
missingRequiredExtensions,
missingRequiredFileCounts,
};
return result;
}
@ -252,7 +271,7 @@ export async function readXWorkmateArtifact(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const expectedSessionScope = taskSessionScopeFor(sessionKey);
const requestedArtifactRef = optionalString(params.artifactRef);
@ -347,9 +366,90 @@ export async function readXWorkmateArtifact(input) {
scopeKind,
artifacts: [artifact],
warnings,
expectedArtifactDirs: [],
expectedArtifactDirStatus: [],
constraintSatisfied: true,
missingRequiredExtensions: [],
missingRequiredFileCounts: {},
};
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",
@ -716,36 +816,7 @@ function resolveWorkspaceDir(input) {
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);

View File

@ -1,57 +1,65 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
type XWorkmateTaskRecord = {
taskId: string;
runtime: "acp";
taskKind: "xworkmate-openclaw";
requesterSessionKey: string;
ownerKey: string;
scopeKind: "session";
runId: string;
label: string;
task: string;
status: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "lost";
deliveryStatus: "pending" | "delivered" | "session_queued" | "failed" | "parent_missing" | "not_applicable";
notifyPolicy: "done_only" | "state_changes" | "silent";
createdAt: number;
startedAt?: number;
endedAt?: number;
lastEventAt?: number;
error?: string;
progressSummary?: string;
terminalSummary?: string;
terminalOutcome?: "succeeded" | "blocked";
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;
};
type XWorkmateSessionMapping = {
appSessionKey: string;
openClawSessionKey: string;
appThreadId?: string;
sessionId?: string;
runId: string;
artifactScope?: 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 XWorkmateTaskStore = {
records: Map<string, XWorkmateTaskRecord>;
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
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 createXWorkmateTaskStore(): XWorkmateTaskStore;
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
export declare function recordXWorkmateSessionMapping(input: {
api: OpenClawPluginApi;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
artifactScope?: string;
}): Promise<void>;
export declare function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore): void;
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;
taskStore: XWorkmateTaskStore;
params: Record<string, unknown>;
}): Promise<Record<string, unknown>>;
export declare function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: {
params: Record<string, unknown>;
status: XWorkmateTaskRecord["status"];
progressSummary?: string;
}): XWorkmateTaskRecord;
export {};

691
dist/src/taskState.js vendored
View File

@ -1,13 +1,9 @@
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export function createXWorkmateTaskStore() {
return {
records: new Map(),
sessionMappingsByAppKey: new Map(),
sessionMappingsByOpenClawKey: new Map(),
};
}
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") {
@ -15,251 +11,459 @@ export function registerXWorkmateSessionExtension(api) {
}
registerExtension({
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.",
description: "Durable XWorkmate app/OpenClaw session key mapping.",
sessionEntrySlotKey: "xworkmate",
project: (ctx) => {
const state = asRecord(ctx.state) ?? {};
const appSessionKey = optionalString(state.appSessionKey) ||
optionalString(state.appThreadId) ||
optionalString(state.threadId) ||
appSessionKeyFromOpenClawSessionKey(ctx.sessionKey);
const openClawSessionKey = optionalString(state.openClawSessionKey) || ctx.sessionKey;
return {
...state,
appSessionKey,
openClawSessionKey,
sessionId: optionalString(state.sessionId) || optionalString(ctx.sessionId),
};
const state = asRecord(ctx.state);
return state ?? {};
},
});
}
export async function recordXWorkmateSessionMapping(input) {
const appSessionKey = requiredString(input.params.sessionKey || input.params.appSessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const openClawSessionKey = optionalString(input.params.openClawSessionKey) ||
optionalString(input.params.openClawSessionId) ||
agentMainSessionKeyFor(appSessionKey);
const expectedArtifactDirs = stringList(input.params.expectedArtifactDirs);
const mapping = compactObject({
appSessionKey,
openClawSessionKey,
appThreadId: optionalString(input.params.threadId) || appSessionKey,
sessionId: optionalString(input.params.sessionId),
runId,
artifactScope: input.artifactScope || optionalString(input.params.artifactScope),
expectedArtifactDirs: expectedArtifactDirs.length > 0 ? expectedArtifactDirs : undefined,
});
input.taskStore.sessionMappingsByAppKey.set(appSessionKey, mapping);
input.taskStore.sessionMappingsByOpenClawKey.set(openClawSessionKey, mapping);
const patchSessionExtension = resolvePatchSessionExtension(input.api);
if (!patchSessionExtension) {
// Legacy fallback owner: this plugin. Scope: tests and OpenClaw hosts that do not expose
// session extension patching yet. Exit: remove this map once 2026.6.1+ hosts expose the patch
// method on the public plugin API in all supported deployments.
return;
}
await patchSessionExtension({
key: openClawSessionKey,
sessionKey: openClawSessionKey,
pluginId: XWORKMATE_PLUGIN_ID,
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
value: mapping,
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 function registerXWorkmateDetachedTaskRuntime(api, taskStore) {
const registerRuntime = api.registerDetachedTaskRuntime;
if (typeof registerRuntime !== "function") {
return;
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");
}
registerRuntime({
createQueuedTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }),
createRunningTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }),
startTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }),
recordTaskRunProgressByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
lastEventAt: Date.now(),
progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary),
}),
finalizeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)),
completeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: "succeeded",
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
terminalOutcome: "succeeded",
}),
failTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
status: taskStatusFrom(params.status, "failed"),
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
}),
setDetachedTaskDeliveryStatusByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, {
deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"),
error: optionalString(params.error),
}),
cancelDetachedTaskRunById: async (params) => {
const taskId = optionalString(params.taskId);
const record = taskId ? findXWorkmateTaskByTaskId(taskStore, taskId) : undefined;
if (!record) {
return { found: false, cancelled: false };
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,
};
}
record.status = "cancelled";
record.endedAt = Date.now();
record.lastEventAt = record.endedAt;
return { found: true, cancelled: true, reason: optionalString(params.reason), task: record };
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 sessionKey = requiredString(input.params.sessionKey, "sessionKey required");
const runId = requiredString(input.params.runId, "runId required");
const mapping = resolveSessionMapping(input.taskStore, input.params, sessionKey);
const openClawSessionKey = mapping?.openClawSessionKey || optionalString(input.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey);
const appSessionKey = mapping?.appSessionKey || sessionKey;
const nativeTask = resolveNativeTask(input.api, openClawSessionKey, runId) || resolveNativeTask(input.api, sessionKey, runId);
const storedTask = findXWorkmateTask(input.taskStore, sessionKey, runId);
const exported = await exportXWorkmateArtifacts({
params: input.params,
config: input.api.config,
pluginConfig: input.api.pluginConfig,
const params = input.params ?? {};
const appThreadKey = optionalString(params.appThreadKey);
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
const mapping = await readXWorkmateSessionMapping(input.api, {
appThreadKey,
openclawSessionKey: explicitOpenclawSessionKey,
});
const task = nativeTask || storedTask;
const taskStatus = normalizeTaskStatus(optionalString(task.status), exported.artifacts.length > 0);
if (storedTask && taskStatus === "succeeded" && storedTask.status !== "succeeded") {
storedTask.status = "succeeded";
storedTask.endedAt = Date.now();
storedTask.lastEventAt = storedTask.endedAt;
storedTask.terminalOutcome = "succeeded";
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",
sessionKey,
openClawSessionKey,
appSessionKey,
runId,
mapping,
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
openclawSessionKey,
runId: runId || optionalString(task.runId),
taskId: taskId || optionalString(task.taskId),
task,
artifactScope: exported.artifactScope,
remoteWorkingDirectory: exported.remoteWorkingDirectory,
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
scopeKind: exported.scopeKind,
artifacts: exported.artifacts,
warnings: exported.warnings,
artifactCount: exported.artifacts.length,
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,
};
}
export function createOrUpdateXWorkmateTaskRecord(input, options) {
const sessionKey = requiredString(options.params.sessionKey || options.params.requesterSessionKey, "sessionKey required");
const runId = requiredString(options.params.runId, "runId required");
const key = taskRecordKey(sessionKey, runId);
const now = Date.now();
const existing = input.records.get(key);
if (existing) {
existing.status = options.status;
existing.lastEventAt = now;
if (options.status === "running" && !existing.startedAt) {
existing.startedAt = now;
}
if (options.progressSummary) {
existing.progressSummary = options.progressSummary;
}
return existing;
async function upsertXWorkmateTaskRun(api, input) {
const patchSessionEntry = resolvePatchSessionEntry(api);
if (!patchSessionEntry) {
throw new Error("OpenClaw runtime session patch API is unavailable");
}
const record = {
taskId: `xworkmate:${safeTaskIdSegment(sessionKey)}:${safeTaskIdSegment(runId)}`,
runtime: "acp",
taskKind: "xworkmate-openclaw",
requesterSessionKey: optionalString(options.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey),
ownerKey: sessionKey,
scopeKind: "session",
runId,
label: optionalString(options.params.label) || "XWorkmate OpenClaw task",
task: optionalString(options.params.taskPrompt) || optionalString(options.params.task) || "XWorkmate OpenClaw task",
status: options.status,
deliveryStatus: "pending",
notifyPolicy: "state_changes",
createdAt: now,
startedAt: options.status === "running" ? now : undefined,
lastEventAt: now,
progressSummary: options.progressSummary,
};
input.records.set(key, record);
return record;
}
function updateXWorkmateTaskRecordsByRunId(input, params, patch) {
const runId = optionalString(params.runId);
const sessionKey = optionalString(params.sessionKey || params.requesterSessionKey);
const records = [...input.records.values()].filter((record) => {
if (runId && record.runId !== runId) {
return false;
}
if (sessionKey && record.ownerKey !== sessionKey && record.requesterSessionKey !== sessionKey) {
return false;
}
return true;
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,
},
},
},
};
},
});
for (const record of records) {
Object.assign(record, compactObject(patch));
if (!recorded) {
throw new Error("failed to write xworkmate task run state");
}
return records;
return recorded;
}
function resolveNativeTask(api, sessionKey, runId) {
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 });
const resolved = bound?.resolve?.(runId) || bound?.get?.(runId);
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 task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`);
api.logger?.warn?.(`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`);
return undefined;
}
}
function resolveSessionMapping(input, params, sessionKey) {
const explicitOpenClawKey = optionalString(params.openClawSessionKey);
if (explicitOpenClawKey) {
const byOpenClaw = input.sessionMappingsByOpenClawKey.get(explicitOpenClawKey);
if (byOpenClaw) {
return byOpenClaw;
}
}
return input.sessionMappingsByAppKey.get(sessionKey) || input.sessionMappingsByOpenClawKey.get(sessionKey);
}
function findXWorkmateTask(input, sessionKey, runId) {
return input.records.get(taskRecordKey(sessionKey, runId));
}
function findXWorkmateTaskByTaskId(input, taskId) {
return [...input.records.values()].find((record) => record.taskId === taskId);
}
function taskRecordKey(sessionKey, runId) {
return `${sessionKey}\u0000${runId}`;
}
function appSessionKeyFromOpenClawSessionKey(sessionKey) {
return sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
}
function agentMainSessionKeyFor(sessionKey) {
return sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
}
function terminalPatch(params) {
const status = taskStatusFrom(params.status, "succeeded");
function lookupError(code, message, mapping) {
return {
status,
endedAt: numberOrNow(params.endedAt),
lastEventAt: numberOrNow(params.lastEventAt),
error: optionalString(params.error),
progressSummary: optionalString(params.progressSummary),
terminalSummary: optionalString(params.terminalSummary),
terminalOutcome: status === "succeeded" ? "succeeded" : "blocked",
ok: false,
code,
message,
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
};
}
function normalizeTaskStatus(status, hasArtifacts) {
const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running");
if (normalized === "running" && hasArtifacts) {
return "succeeded";
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;
}
return normalized;
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") {
@ -270,38 +474,12 @@ function appStatusFromTaskStatus(status) {
}
return "running";
}
function taskStatusFrom(value, fallback) {
const status = optionalString(value);
if (status === "queued" ||
status === "running" ||
status === "succeeded" ||
status === "failed" ||
status === "timed_out" ||
status === "cancelled" ||
status === "lost") {
return status;
function parseMappingSource(value) {
const source = optionalString(value);
if (source === "session_start" || source === "bridge_prepare") {
return source;
}
return fallback;
}
function deliveryStatusFrom(value, fallback) {
const status = optionalString(value);
if (status === "pending" ||
status === "delivered" ||
status === "session_queued" ||
status === "failed" ||
status === "parent_missing" ||
status === "not_applicable") {
return status;
}
return fallback;
}
function resolvePatchSessionExtension(api) {
const stateApi = (api.session?.state ?? {});
const apiRecord = api;
const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension;
return typeof candidate === "function"
? candidate
: undefined;
return "bridge_prepare";
}
function requiredString(value, message) {
const text = optionalString(value);
@ -317,26 +495,6 @@ function optionalString(value) {
const text = String(value).trim();
return text === "<nil>" ? "" : text;
}
function stringList(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const result = [];
for (const entry of value) {
const text = optionalString(entry);
if (!text || seen.has(text)) {
continue;
}
seen.add(text);
result.push(text);
}
return result;
}
function numberOrNow(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
}
function asRecord(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@ -346,6 +504,3 @@ function asRecord(value) {
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== ""));
}
function safeTaskIdSegment(value) {
return value.replace(/[^A-Za-z0-9._:-]+/g, "_");
}

View File

@ -3,7 +3,7 @@ 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];
@ -14,6 +14,12 @@ type GatewayMethodResponse = {
};
describe("plugin registration", () => {
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[] };
@ -42,6 +48,7 @@ describe("plugin registration", () => {
tools.push({ tool, options });
},
registerHook: () => undefined,
on: () => undefined,
} as unknown as OpenClawPluginApi;
plugin.register(api);
@ -73,6 +80,7 @@ describe("plugin registration", () => {
},
registerTool: () => undefined,
registerHook: () => undefined,
on: () => undefined,
runtime: {
agent: {
session: {
@ -92,12 +100,12 @@ describe("plugin registration", () => {
openclawSessionKey: "thread-main",
runId: "turn-1",
});
console.log(prepared); expect(prepared.ok).toBe(true);
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", {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.payload?.artifactScope,
});
@ -109,7 +117,7 @@ describe("plugin registration", () => {
await fs.promises.writeFile(path.join(artifactDirectory, "reports", "final.md"), "final");
const listed = await callGatewayMethod(methods, "xworkmate.artifacts.list", {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.payload?.artifactScope,
});
@ -119,7 +127,7 @@ describe("plugin registration", () => {
expect(listedArtifacts[0]).not.toHaveProperty("content");
const read = await callGatewayMethod(methods, "xworkmate.artifacts.read", {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.payload?.artifactScope,
relativePath: "reports/final.md",
@ -128,7 +136,7 @@ describe("plugin registration", () => {
expect(read.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md", encoding: "base64" }]);
const unprepared = await callGatewayMethod(methods, "xworkmate.artifacts.export", {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-unprepared",
});
expect(unprepared.ok).toBe(true);
@ -139,7 +147,7 @@ describe("plugin registration", () => {
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) => Promise<void>>();
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>> = [];
@ -197,7 +205,10 @@ describe("plugin registration", () => {
methods.set(method, handler);
},
registerTool: () => undefined,
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
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);
},
@ -211,15 +222,21 @@ describe("plugin registration", () => {
sessionEntrySlotKey: "xworkmate",
});
const projected = (sessionExtensions[0]?.project as (ctx: Record<string, unknown>) => unknown)({
sessionKey: "draft:1780636411666238-3",
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",
sessionKey: "draft-1780636411666238-3",
openclawSessionKey: "draft:1780636411666238-3",
threadId: "draft-1780636411666238-3",
runId: "turn-1",
@ -251,6 +268,15 @@ describe("plugin registration", () => {
});
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 () => {
@ -260,6 +286,7 @@ describe("plugin registration", () => {
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 });
},
@ -289,6 +316,7 @@ describe("plugin registration", () => {
pluginConfig: {},
registerGatewayMethod: () => undefined,
registerHook: () => undefined,
on: () => undefined,
registerTool: (tool: unknown, options: { names?: string[] }) => {
tools.push({ tool, options });
},
@ -302,11 +330,11 @@ describe("plugin registration", () => {
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: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const other = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.promises.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
@ -319,6 +347,7 @@ describe("plugin registration", () => {
pluginConfig: {},
registerGatewayMethod: () => undefined,
registerHook: () => undefined,
on: () => undefined,
registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options });
},
@ -340,7 +369,7 @@ describe("plugin registration", () => {
});
const result = await tool.execute("call-1", {
action: "list",
sessionKey: "thread-other",
openclawSessionKey: "thread-other",
runId: "turn-2",
workspaceDir: "/",
});

View File

@ -3,6 +3,7 @@ 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,
@ -14,7 +15,8 @@ import {
import {
getXWorkmateTaskSnapshot,
recordXWorkmateSessionMapping,
registerXWorkmateDetachedTaskRuntime,
recordXWorkmateTaskRunStarted,
recordXWorkmateTaskRunTerminal,
registerXWorkmateSessionExtension,
} from "./src/taskState.js";
@ -53,7 +55,7 @@ function scopedGatewayParams(params: Record<string, unknown>): Record<string, un
}
return {
...params,
sessionKey: runScope.sessionKey,
openclawSessionKey: runScope.sessionKey,
runId: runScope.runId,
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
@ -84,26 +86,49 @@ function stringParam(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
const plugin = {
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) {
const taskStore = {};
registerXWorkmateSessionExtension(api);
registerXWorkmateDetachedTaskRuntime(api, taskStore);
api.registerHook(
"session_start",
async (event: any) => {
try {
const params = scopedGatewayParams(event?.context ?? event);
const openclawSessionKey = stringParam(params.openclawSessionKey) || stringParam(params.sessionKey);
const openclawSessionKey = stringParam(params.openclawSessionKey);
if (openclawSessionKey && params.runId) {
const hookParams = { ...params, openclawSessionKey };
const prepared = await prepareXWorkmateArtifacts({
@ -113,7 +138,6 @@ function register(api: OpenClawPluginApi) {
});
await recordXWorkmateSessionMapping({
api,
taskStore,
params: hookParams,
artifactScope: prepared.artifactScope,
source: "session_start",
@ -126,12 +150,34 @@ function register(api: OpenClawPluginApi) {
{ 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,
taskStore,
params,
source: "bridge_prepare",
});
@ -144,6 +190,11 @@ function register(api: OpenClawPluginApi) {
config: api.config,
pluginConfig: api.pluginConfig,
});
await recordXWorkmateTaskRunStarted({
api,
openclawSessionKey: mapping.openclawSessionKey,
runId: stringParam(params.runId),
});
opts.respond(
true,
{
@ -167,7 +218,6 @@ function register(api: OpenClawPluginApi) {
try {
const payload = await getXWorkmateTaskSnapshot({
api,
taskStore,
params: scopedGatewayParams(opts.params),
});
opts.respond(true, payload, undefined);
@ -303,13 +353,14 @@ function createXWorkmateArtifactsTool(
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
const {
sessionKey: _ignoredSessionKey,
openclawSessionKey: _ignoredOpenclawSessionKey,
runId: _ignoredRunId,
workspaceDir: _ignoredWorkspaceDir,
...operationParams
} = params;
const baseParams = {
...operationParams,
sessionKey,
openclawSessionKey: sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),

5524
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "openclaw-multi-session-plugins",
"version": "2026.6.1",
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
"description": "OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling",
"type": "module",
"license": "MIT",
"keywords": [

View 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;
}

View File

@ -15,11 +15,11 @@ describe("exportXWorkmateArtifacts", () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const second = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
@ -31,11 +31,22 @@ describe("exportXWorkmateArtifacts", () => {
expect(first.scopeKind).toBe("task");
});
it("rejects legacy sessionKey artifact params", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await expect(
prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("openclawSessionKey required");
});
it("normalizes task scope segments like the OpenClaw session scope runtime", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "agent::main:main", runId: "run alpha" },
params: { openclawSessionKey: "agent::main:main", runId: "run alpha" },
pluginConfig: { workspaceDir: root },
});
@ -45,7 +56,7 @@ describe("exportXWorkmateArtifacts", () => {
it("exports changed files with metadata and base64 content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
@ -55,7 +66,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
sinceUnixMs: stat.mtimeMs - 1,
},
@ -84,7 +95,7 @@ describe("exportXWorkmateArtifacts", () => {
const prepared = await prepareXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-expected",
expectedArtifactDirs: ["artifacts/", "assets/images"],
},
@ -92,7 +103,7 @@ describe("exportXWorkmateArtifacts", () => {
});
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-expected",
artifactScope: prepared.artifactScope,
expectedArtifactDirs: ["artifacts/", "assets/images"],
@ -114,7 +125,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
prepareXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-unsafe",
expectedArtifactDirs: ["../outside"],
},
@ -123,14 +134,159 @@ describe("exportXWorkmateArtifacts", () => {
).rejects.toThrow("expectedArtifactDir must stay inside the workspace");
});
it("satisfies required artifact extensions when a matching artifact is exported", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-required" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "final.PDF"), "pdf");
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-required",
requiredArtifactExtensions: [".pdf"],
},
pluginConfig: { workspaceDir: root },
});
expect(result.constraintSatisfied).toBe(true);
expect(result.missingRequiredExtensions).toEqual([]);
});
it("reports missing required artifact extensions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-missing-required" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-missing-required",
requiredArtifactExtensions: ["pdf"],
},
pluginConfig: { workspaceDir: root },
});
expect(result.constraintSatisfied).toBe(false);
expect(result.missingRequiredExtensions).toEqual(["pdf"]);
});
it("reports missing required artifact file counts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-missing-count" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "assets", "images"), { recursive: true });
await fs.writeFile(path.join(prepared.artifactDirectory, "assets", "images", "001.png"), "png");
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-missing-count",
requiredArtifactExtensions: ["png"],
expectedFileCountByExtension: { png: 7 },
},
pluginConfig: { workspaceDir: root },
});
expect(result.constraintSatisfied).toBe(false);
expect(result.missingRequiredExtensions).toEqual([]);
expect(result.missingRequiredFileCounts).toEqual({ png: { expected: 7, actual: 1 } });
});
it("treats an empty required artifact extension list as satisfied", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-empty-required" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-empty-required",
requiredArtifactExtensions: [],
},
pluginConfig: { workspaceDir: root },
});
expect(result.constraintSatisfied).toBe(true);
expect(result.missingRequiredExtensions).toEqual([]);
});
it("keeps required-extension artifacts before truncating export results", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-required-limit" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "final.mp4"), "mp4");
await new Promise((resolve) => setTimeout(resolve, 30));
for (let index = 0; index < 64; index += 1) {
await fs.writeFile(path.join(prepared.artifactDirectory, `part-${String(index).padStart(2, "0")}.txt`), "txt");
}
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-required-limit",
requiredArtifactExtensions: ["mp4"],
maxFiles: 64,
maxInlineBytes: 0,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts).toHaveLength(64);
expect(result.artifacts.some((entry) => entry.relativePath === "final.mp4")).toBe(true);
expect(result.constraintSatisfied).toBe(true);
expect(result.missingRequiredExtensions).toEqual([]);
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 64");
});
it("keeps mtime and relative path ordering when no required extensions are provided", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { openclawSessionKey: "thread-main", runId: "run-no-required-order" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "older.txt"), "older");
await new Promise((resolve) => setTimeout(resolve, 30));
await fs.writeFile(path.join(prepared.artifactDirectory, "newer-b.txt"), "newer");
await fs.writeFile(path.join(prepared.artifactDirectory, "newer-a.txt"), "newer");
const result = await exportXWorkmateArtifacts({
params: {
openclawSessionKey: "thread-main",
runId: "run-no-required-order",
maxInlineBytes: 0,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
"newer-a.txt",
"newer-b.txt",
"older.txt",
]);
expect(result.constraintSatisfied).toBe(true);
expect(result.missingRequiredExtensions).toEqual([]);
});
it("snapshots OpenClaw media and tmp outputs into the current task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const mediaRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-media-"));
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-global-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(mediaRoot, "browser"), { recursive: true });
@ -144,7 +300,7 @@ describe("exportXWorkmateArtifacts", () => {
const snapshot = await collectAndSnapshotXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: snapshotSinceUnixMs,
@ -165,7 +321,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
artifactScope: prepared.artifactScope,
includeContent: false,
@ -182,7 +338,7 @@ describe("exportXWorkmateArtifacts", () => {
it("skips excluded directories and symlinks", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, ".git"), { recursive: true });
@ -194,7 +350,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
},
pluginConfig: { workspaceDir: root },
@ -207,7 +363,7 @@ describe("exportXWorkmateArtifacts", () => {
it("applies artifact-ignore.md inside the current task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "tmp"), { recursive: true });
@ -234,7 +390,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
},
pluginConfig: { workspaceDir: root },
@ -246,11 +402,11 @@ describe("exportXWorkmateArtifacts", () => {
it("exports only files inside a task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const second = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(first.artifactDirectory, "reports"), { recursive: true });
@ -260,7 +416,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: first.artifactScope,
},
@ -279,7 +435,7 @@ describe("exportXWorkmateArtifacts", () => {
it("exports nested dist and build deliverables inside the task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "dist"), { recursive: true });
@ -289,7 +445,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
maxInlineBytes: 0,
},
@ -305,11 +461,11 @@ describe("exportXWorkmateArtifacts", () => {
it("uses the current task scope when artifactScope is omitted", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const current = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const other = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "global.txt"), "global");
@ -318,7 +474,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
},
pluginConfig: { workspaceDir: root },
@ -332,14 +488,14 @@ describe("exportXWorkmateArtifacts", () => {
it("does not scan the workspace root without a current-run timestamp", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "global.txt"), "global");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
},
pluginConfig: { workspaceDir: root },
@ -356,7 +512,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft-article",
openclawSessionKey: "draft-article",
runId: "openclaw-run-1",
sinceUnixMs,
maxInlineBytes: 0,
@ -372,7 +528,7 @@ describe("exportXWorkmateArtifacts", () => {
it("exports explicitly expected artifact dirs when the task scope is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
params: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(root, "assets", "images"), { recursive: true });
@ -383,7 +539,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft-article",
openclawSessionKey: "draft-article",
runId: "openclaw-run-1",
artifactScope: prepared.artifactScope,
expectedArtifactDirs: ["assets/images", "reports"],
@ -403,7 +559,7 @@ describe("exportXWorkmateArtifacts", () => {
it("keeps scoped artifacts authoritative over expected artifact dirs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
params: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
@ -413,7 +569,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft-article",
openclawSessionKey: "draft-article",
runId: "openclaw-run-1",
artifactScope: prepared.artifactScope,
expectedArtifactDirs: ["reports"],
@ -428,7 +584,7 @@ describe("exportXWorkmateArtifacts", () => {
it("does not adopt old workspace root files into a later task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "old-root.md"), "old");
@ -436,7 +592,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs: stat.mtimeMs + 10_000,
},
@ -450,18 +606,18 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects scoped exports that do not match the requested session/run", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await expect(
exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-2",
artifactScope: first.artifactScope,
},
@ -473,11 +629,11 @@ describe("exportXWorkmateArtifacts", () => {
it("does not adopt old workspace files when the scoped directory is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const otherTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
@ -488,7 +644,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: stat.mtimeMs + 10_000,
@ -505,11 +661,11 @@ describe("exportXWorkmateArtifacts", () => {
it("does not borrow previous session task files when current task scope is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const previousTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-previous" },
params: { openclawSessionKey: "thread-main", runId: "turn-previous" },
pluginConfig: { workspaceDir: root },
});
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-follow-up" },
params: { openclawSessionKey: "thread-main", runId: "turn-follow-up" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
@ -517,7 +673,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-follow-up",
sinceUnixMs: Date.now() + 10_000,
},
@ -534,7 +690,7 @@ describe("exportXWorkmateArtifacts", () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: {
sessionKey: "draft:1779524982823421-3",
openclawSessionKey: "draft:1779524982823421-3",
runId: "turn-1779685283403237342",
},
pluginConfig: { workspaceDir: root },
@ -561,7 +717,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft:1779524982823421-3",
openclawSessionKey: "draft:1779524982823421-3",
runId: "turn-1779685283403237342",
sinceUnixMs: Date.now() + 10_000,
},
@ -594,7 +750,7 @@ describe("exportXWorkmateArtifacts", () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: {
sessionKey: "draft:1779524982823421-3",
openclawSessionKey: "draft:1779524982823421-3",
runId: "turn-1779685283403237342",
},
pluginConfig: { workspaceDir: root },
@ -605,7 +761,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "draft:1779524982823421-3",
openclawSessionKey: "draft:1779524982823421-3",
runId: "turn-1779685283403237342",
},
pluginConfig: { workspaceDir: root },
@ -630,15 +786,15 @@ describe("exportXWorkmateArtifacts", () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await Promise.all([
prepareXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-1" },
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
}),
prepareXWorkmateArtifacts({
params: { sessionKey: "thread-b", runId: "turn-1" },
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
}),
prepareXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-2" },
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
}),
]);
@ -648,15 +804,15 @@ describe("exportXWorkmateArtifacts", () => {
const results = await Promise.all([
exportXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-1" },
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
}),
exportXWorkmateArtifacts({
params: { sessionKey: "thread-b", runId: "turn-1" },
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
}),
exportXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-2" },
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
}),
]);
@ -672,14 +828,14 @@ describe("exportXWorkmateArtifacts", () => {
it("leaves oversized artifacts out of inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "large.pdf"), Buffer.from("large-content"));
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
maxInlineBytes: 2,
},
@ -695,14 +851,14 @@ describe("exportXWorkmateArtifacts", () => {
it("can list artifacts without inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "small.txt"), "small");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
maxInlineBytes: 0,
},
@ -718,7 +874,7 @@ describe("exportXWorkmateArtifacts", () => {
it("limits exported files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "a.txt"), "a");
@ -726,7 +882,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
maxFiles: 1,
},
@ -737,32 +893,6 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 1");
});
it("selects an agent workspace from agent session keys", async () => {
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-main-"));
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-agent-"));
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "agent:research:thread-1", runId: "run-1" },
pluginConfig: { workspaceDir: agentRoot },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "agent.txt"), "agent");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "agent:research:thread-1",
runId: "run-1",
},
config: {
agents: {
defaults: { workspace: mainRoot },
list: [{ id: "research", workspace: agentRoot }],
},
},
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
});
it("rejects unscoped artifact reads by relative path", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.mkdir(path.join(root, "reports"), { recursive: true });
@ -771,7 +901,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
relativePath: "reports/final.txt",
},
@ -783,7 +913,7 @@ describe("exportXWorkmateArtifacts", () => {
it("reads one artifact inside a task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
@ -791,7 +921,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "reports/final.txt",
@ -814,7 +944,7 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects direct reads from another run artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(first.artifactDirectory, "first.txt"), "first");
@ -822,7 +952,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-2",
artifactScope: first.artifactScope,
relativePath: "first.txt",
@ -835,13 +965,13 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects signed task artifact refs from another session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "first.txt"), "first");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
},
@ -851,7 +981,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-other",
openclawSessionKey: "thread-other",
runId: "turn-1",
artifactRef: exported.artifacts[0]?.artifactRef,
},
@ -863,14 +993,14 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects signed task artifact refs from another run", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
},
@ -880,7 +1010,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-2",
artifactRef: exported.artifacts[0]?.artifactRef,
},
@ -892,13 +1022,13 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects tampered artifact refs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
params: { openclawSessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
},
pluginConfig: { workspaceDir: root },
@ -909,7 +1039,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
artifactRef: tampered,
},
@ -938,7 +1068,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
artifactRef: legacyRef,
},
@ -950,14 +1080,14 @@ describe("exportXWorkmateArtifacts", () => {
it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "large.bin"), Buffer.from("large-content"));
const result = await readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "large.bin",
@ -981,14 +1111,14 @@ describe("exportXWorkmateArtifacts", () => {
it("rejects relative path traversal when reading artifacts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "../outside.txt",
@ -1004,7 +1134,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "run-1",
artifactScope: "../outside",
relativePath: "secret.txt",
@ -1020,7 +1150,7 @@ describe("exportXWorkmateArtifacts", () => {
const outsideFile = path.join(outsideRoot, "secret.txt");
await fs.writeFile(outsideFile, "secret");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt"));
@ -1028,7 +1158,7 @@ describe("exportXWorkmateArtifacts", () => {
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
openclawSessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "linked-secret.txt",

View File

@ -2,6 +2,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt
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;
@ -20,7 +21,7 @@ const SKIPPED_DIRS = new Set([
"node_modules",
]);
export type XWorkmateArtifact = {
type XWorkmateArtifact = {
relativePath: string;
label: string;
contentType: string;
@ -33,9 +34,9 @@ export type XWorkmateArtifact = {
content?: string;
};
export type XWorkmateArtifactScopeKind = "task";
type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = {
type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -46,9 +47,12 @@ export type XWorkmateArtifactExport = {
warnings: string[];
expectedArtifactDirs: string[];
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
constraintSatisfied: boolean;
missingRequiredExtensions: string[];
missingRequiredFileCounts: Record<string, { expected: number; actual: number }>;
};
export type XWorkmateArtifactPrepare = {
type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -62,12 +66,12 @@ export type XWorkmateArtifactPrepare = {
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
};
export type XWorkmateExpectedArtifactDirStatus = {
type XWorkmateExpectedArtifactDirStatus = {
relativePath: string;
exists: boolean;
};
export type XWorkmateArtifactSnapshot = {
type XWorkmateArtifactSnapshot = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
@ -117,7 +121,7 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
const expectedArtifactDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
@ -154,7 +158,7 @@ export async function collectAndSnapshotXWorkmateArtifacts(input: ExportInput):
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey 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);
@ -224,7 +228,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
const maxInlineBytes = nonNegativeInteger(
@ -234,6 +238,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
);
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,
@ -261,8 +267,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
try {
const scopeStat = await fs.stat(scopeRoot);
effectiveSince = Math.min(sinceUnixMs, scopeStat.birthtimeMs || scopeStat.mtimeMs);
} catch {}
} catch (error) {
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
}
}
const scopedCandidates = (await directoryExists(scopeRoot))
? await collectCandidates({
scanRoot: scopeRoot,
@ -298,6 +307,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
}
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;
}
@ -348,6 +362,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
}
artifacts.push(artifact);
}
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
const missingRequiredFileCounts = missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension);
const result = {
runId,
@ -360,6 +376,9 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
warnings,
expectedArtifactDirs: expectedDirs,
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
constraintSatisfied: missingRequiredExtensions.length === 0 && Object.keys(missingRequiredFileCounts).length === 0,
missingRequiredExtensions,
missingRequiredFileCounts,
};
return result;
}
@ -368,7 +387,7 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const expectedSessionScope = taskSessionScopeFor(sessionKey);
const requestedArtifactRef = optionalString(params.artifactRef);
@ -471,28 +490,91 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
warnings,
expectedArtifactDirs: [],
expectedArtifactDirStatus: [],
constraintSatisfied: true,
missingRequiredExtensions: [],
missingRequiredFileCounts: {},
};
return result;
}
export function normalizeExpectedArtifactDirs(value: unknown): string[] {
function normalizeRequiredExtensions(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of value) {
const normalized = safeInputRelativePath(entry, "expectedArtifactDir");
const withSlash = normalized.endsWith("/") ? normalized : `${normalized}/`;
if (seen.has(withSlash)) {
const normalized = optionalString(entry)
.toLowerCase()
.replace(/^\.+/u, "");
if (!normalized || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
continue;
}
seen.add(withSlash);
result.push(withSlash);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function matchesRequiredExtension(relativePath: string, requiredExtensions: string[]): boolean {
if (requiredExtensions.length === 0) {
return false;
}
const lowerPath = relativePath.toLowerCase();
return requiredExtensions.some((extension) => lowerPath.endsWith(`.${extension}`));
}
function missingRequiredArtifactExtensions(
artifacts: XWorkmateArtifact[],
requiredExtensions: string[],
): string[] {
if (requiredExtensions.length === 0) {
return [];
}
return requiredExtensions.filter(
(extension) => !artifacts.some((artifact) => artifact.relativePath.toLowerCase().endsWith(`.${extension}`)),
);
}
function normalizeExpectedFileCountByExtension(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const result: Record<string, number> = {};
for (const [rawExtension, rawCount] of Object.entries(value as Record<string, unknown>)) {
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: XWorkmateArtifact[],
expectedFileCountByExtension: Record<string, number>,
): Record<string, { expected: number; actual: number }> {
const missing: Record<string, { expected: number; actual: number }> = {};
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: string,
expectedArtifactDirs: string[],
@ -932,38 +1014,7 @@ function resolveWorkspaceDir(input: {
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: string): string {
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: string, target: string): string {
@ -1031,7 +1082,6 @@ function contentTypeForPath(relativePath: string): string {
}
function openClawSnapshotSources(params: Record<string, unknown>, pluginConfig: Record<string, unknown>): SnapshotSource[] {
return [
{
label: "media",

View File

@ -1,18 +1,22 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
XWORKMATE_PLUGIN_ID,
XWORKMATE_SESSION_EXTENSION_NAMESPACE,
getXWorkmateTaskSnapshot,
normalizeXWorkmateTaskMetadataV1,
recordXWorkmateSessionMapping,
readXWorkmateSessionMapping,
recordXWorkmateTaskRunStarted,
recordXWorkmateTaskRunTerminal,
} from "./taskState.js";
function createApiFixture(tasks: Record<string, unknown> = {}) {
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: {},
pluginConfig,
logger: { warn: () => {} },
runtime: {
agent: {
@ -25,12 +29,17 @@ function createApiFixture(tasks: Record<string, unknown> = {}) {
})),
patchSessionEntry: async ({
sessionKey,
fallbackEntry,
update,
}: {
sessionKey: string;
fallbackEntry?: any;
update: (entry: any) => Partial<any> | null;
}) => {
const current = sessions.get(sessionKey) ?? { sessionId: sessionKey, updatedAt: 0 };
const current = sessions.get(sessionKey) ?? fallbackEntry;
if (!current) {
return null;
}
const patch = update(current);
if (patch) {
sessions.set(sessionKey, { ...current, ...patch });
@ -53,15 +62,24 @@ function createApiFixture(tasks: Record<string, unknown> = {}) {
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", () => {
expect(() =>
normalizeXWorkmateTaskMetadataV1({
schemaVersion: 1,
sessionKey: "draft:legacy",
expectedArtifactDirs: ["artifacts/"],
it("requires typed appThreadKey metadata", async () => {
const { api } = createApiFixture();
await expect(
recordXWorkmateSessionMapping({
api,
params: {
schemaVersion: 1,
sessionKey: "draft:legacy",
expectedArtifactDirs: ["artifacts/"],
},
}),
).toThrow("appThreadKey required");
).rejects.toThrow("appThreadKey required");
});
it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => {
@ -154,8 +172,60 @@ describe("xworkmate task state mapping", () => {
});
});
it("returns no_native_task_record instead of inferring success from artifacts", async () => {
const { api } = createApiFixture();
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: {
@ -184,6 +254,95 @@ describe("xworkmate task state mapping", () => {
});
});
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": {
@ -209,7 +368,13 @@ describe("xworkmate task state mapping", () => {
});
it("can read mapping by appThreadKey from pluginExtensions", async () => {
const { api } = createApiFixture();
const { api } = createApiFixture({
"draft:lookup:run-1": {
taskId: "task-1",
runId: "run-1",
status: "succeeded",
},
});
await recordXWorkmateSessionMapping({
api,
params: {
@ -220,7 +385,17 @@ describe("xworkmate task state mapping", () => {
},
});
await expect(readXWorkmateSessionMapping(api, { appThreadKey: "draft:lookup" })).resolves.toMatchObject({
await expect(
getXWorkmateTaskSnapshot({
api,
params: {
appThreadKey: "draft:lookup",
runId: "run-1",
includeArtifacts: false,
},
}),
).resolves.toMatchObject({
success: true,
appThreadKey: "draft:lookup",
openclawSessionKey: "draft:lookup",
});

View File

@ -1,8 +1,11 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
export const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
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;
@ -26,7 +29,6 @@ export type XWorkmateSessionMappingV1 = {
createdAt: string;
updatedAt: string;
source: XWorkmateSessionMappingSource;
legacyDerived?: boolean;
};
export type XWorkmateTaskLookupErrorCode =
@ -44,7 +46,17 @@ export type XWorkmateTaskLookupError = {
expectedArtifactDirs?: string[];
};
export type XWorkmateTaskStore = Record<string, never>;
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>>;
@ -52,6 +64,7 @@ type SessionEntry = Record<string, unknown> & {
type PatchSessionEntry = (params: {
sessionKey: string;
fallbackEntry?: SessionEntry;
preserveActivity?: boolean;
update: (entry: SessionEntry) => Partial<SessionEntry> | null;
}) => Promise<SessionEntry | null> | SessionEntry | null;
@ -65,10 +78,6 @@ type BoundTaskRunsRuntime = {
resolve?: (token: string) => unknown;
};
export function createXWorkmateTaskStore(): XWorkmateTaskStore {
return {};
}
export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) {
const registerExtension =
api.session?.state?.registerSessionExtension ?? (api as any).registerSessionExtension;
@ -86,13 +95,8 @@ export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) {
});
}
export function registerXWorkmateDetachedTaskRuntime(_api: OpenClawPluginApi, _taskStore: XWorkmateTaskStore) {
// OpenClaw native task-registry is the only task status source for this plugin.
}
export async function recordXWorkmateSessionMapping(input: {
api: OpenClawPluginApi;
taskStore?: XWorkmateTaskStore;
params: Record<string, unknown>;
artifactScope?: string;
source?: XWorkmateSessionMappingSource;
@ -112,7 +116,44 @@ export async function recordXWorkmateSessionMapping(input: {
});
}
export function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 {
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) {
@ -131,36 +172,12 @@ export function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>)
}) as XWorkmateTaskMetadataV1;
}
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 text = optionalString(entry).replaceAll("\\", "/").replace(/^\.\/+/u, "");
if (!text || seen.has(text)) {
continue;
}
if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text) || text.split("/").includes("..")) {
throw new Error("expectedArtifactDirs must be relative paths without traversal");
}
const normalized = text.endsWith("/") ? text : `${text}/`;
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
}
return result;
}
export async function upsertXWorkmateSessionMapping(
async function upsertXWorkmateSessionMapping(
api: OpenClawPluginApi,
input: {
metadata: XWorkmateTaskMetadataV1;
openclawSessionKey: string;
source: XWorkmateSessionMappingSource;
legacyDerived?: boolean;
},
): Promise<XWorkmateSessionMappingV1> {
const patchSessionEntry = resolvePatchSessionEntry(api);
@ -172,6 +189,10 @@ export async function upsertXWorkmateSessionMapping(
let mapping: XWorkmateSessionMappingV1 | undefined;
await patchSessionEntry({
sessionKey: input.openclawSessionKey,
fallbackEntry: {
sessionId: input.openclawSessionKey,
updatedAt: Date.now(),
},
preserveActivity: true,
update: (entry) => {
const existing = readMappingFromEntry(entry);
@ -192,7 +213,6 @@ export async function upsertXWorkmateSessionMapping(
createdAt: input.metadata.createdAt || now,
updatedAt: now,
source: input.source,
legacyDerived: input.legacyDerived === true ? true : undefined,
}) as XWorkmateSessionMappingV1;
}
return {
@ -207,7 +227,7 @@ export async function upsertXWorkmateSessionMapping(
return mapping;
}
export async function readXWorkmateSessionMapping(
async function readXWorkmateSessionMapping(
api: OpenClawPluginApi,
lookup: {
appThreadKey?: string;
@ -238,7 +258,6 @@ export async function readXWorkmateSessionMapping(
export async function getXWorkmateTaskSnapshot(input: {
api: OpenClawPluginApi;
taskStore?: XWorkmateTaskStore;
params: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
const params = input.params ?? {};
@ -263,25 +282,100 @@ export async function getXWorkmateTaskSnapshot(input: {
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 includeArtifacts = params.includeArtifacts !== false;
const exported = includeArtifacts
? await exportXWorkmateArtifacts({
params: {
...params,
openclawSessionKey,
runId: runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
includeContent: params.includeContent ?? false,
},
config: input.api.config,
pluginConfig: input.api.pluginConfig,
})
? await exportArtifactsForTaskLookup(
input,
params,
openclawSessionKey,
runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
mapping,
)
: undefined;
return {
@ -301,11 +395,150 @@ export async function getXWorkmateTaskSnapshot(input: {
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 },
@ -360,7 +593,6 @@ function readMappingFromEntry(entry: SessionEntry | undefined | null): XWorkmate
createdAt: optionalString(raw.createdAt) || new Date(0).toISOString(),
updatedAt: optionalString(raw.updatedAt) || optionalString(raw.createdAt) || new Date(0).toISOString(),
source: parseMappingSource(raw.source),
...(raw.legacyDerived === true ? { legacyDerived: true } : {}),
};
}