Compare commits
8 Commits
main
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243459eb2d | ||
|
|
38219e98ce | ||
|
|
28c19308c1 | ||
|
|
c6414d2a63 | ||
|
|
41cae90127 | ||
|
|
80452beb49 | ||
|
|
83437f950a | ||
|
|
1cd158b248 |
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
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
|
||||
238
.github/workflows/deploy.yml
vendored
Normal file
238
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,238 @@
|
||||
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}"
|
||||
ref="${GITHUB_REF_NAME:-release/v${VERSION}}"
|
||||
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
echo "::notice::Installing ${PACKAGE} from source repo ${repo_url} ref ${ref}"
|
||||
echo "source=source" >> "$GITHUB_OUTPUT"
|
||||
echo "install_ref=${ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "install_spec=${repo_url}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SSH_PRIVATE_KEY}" ]; then
|
||||
echo "::error::Secret SINGLE_NODE_VPS_SSH_PRIVATE_KEY is not set."
|
||||
exit 1
|
||||
fi
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "${SSH_PRIVATE_KEY}" \
|
||||
| perl -pe 's/\\n/\n/g; s/\r$//' \
|
||||
> ~/.ssh/openclaw_ed25519
|
||||
chmod 600 ~/.ssh/openclaw_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/openclaw_ed25519 >/dev/null
|
||||
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 }}
|
||||
INSTALL_REF: ${{ steps.install.outputs.install_ref }}
|
||||
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}" "${INSTALL_REF}" "${FORCE}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
PLUGIN_NAME="$1"
|
||||
VERSION="$2"
|
||||
INSTALL_SPEC="$3"
|
||||
INSTALL_SOURCE="$4"
|
||||
INSTALL_REF="$5"
|
||||
FORCE="$6"
|
||||
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}"
|
||||
echo "==> Install ref: ${INSTALL_REF}"
|
||||
|
||||
get_installed_version() {
|
||||
node -e '
|
||||
const { execSync } = require("node:child_process");
|
||||
const { join } = require("node:path");
|
||||
const { existsSync, readFileSync } = require("node:fs");
|
||||
const name = process.argv[1];
|
||||
const root = execSync("npm root -g", { encoding: "utf8" }).trim();
|
||||
const manifest = join(root, name, "package.json");
|
||||
if (existsSync(manifest)) {
|
||||
process.stdout.write(JSON.parse(readFileSync(manifest, "utf8")).version || "");
|
||||
}
|
||||
' "${PLUGIN_NAME}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Record the previously installed version for rollback.
|
||||
PREVIOUS_VERSION="$(get_installed_version)"
|
||||
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() {
|
||||
global_root="$(npm root -g)"
|
||||
global_target="${global_root}/${PLUGIN_NAME}"
|
||||
if [ -e "${global_target}" ] && [ ! -d "${global_target}" ]; then
|
||||
echo "::remote-warning::Removing invalid global package path ${global_target}"
|
||||
rm -f "${global_target}"
|
||||
fi
|
||||
|
||||
if [ "${INSTALL_SOURCE}" = "source" ]; then
|
||||
SOURCE_DIR="${STATE_DIR}/source/${PLUGIN_NAME}"
|
||||
rm -rf "${SOURCE_DIR}"
|
||||
mkdir -p "${SOURCE_DIR}"
|
||||
git -C "${SOURCE_DIR}" init
|
||||
git -C "${SOURCE_DIR}" remote add origin "${INSTALL_SPEC}"
|
||||
git -C "${SOURCE_DIR}" fetch --depth 1 origin "${INSTALL_REF}"
|
||||
git -C "${SOURCE_DIR}" checkout --detach FETCH_HEAD
|
||||
TOOL_DIR="${STATE_DIR}/tools"
|
||||
npm install --prefix "${TOOL_DIR}" pnpm@10.28.2
|
||||
PNPM="${TOOL_DIR}/node_modules/.bin/pnpm"
|
||||
"${PNPM}" --dir "${SOURCE_DIR}" install --frozen-lockfile
|
||||
"${PNPM}" --dir "${SOURCE_DIR}" build
|
||||
npm install -g "${SOURCE_DIR}"
|
||||
elif [ "${INSTALL_SOURCE}" = "github" ]; then
|
||||
global_root="$(npm root -g)"
|
||||
global_target="${global_root}/${PLUGIN_NAME}"
|
||||
npm install -g "${INSTALL_SPEC}"
|
||||
elif 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="$(get_installed_version)"
|
||||
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
406
.github/workflows/pipeline.yml
vendored
@ -1,406 +0,0 @@
|
||||
name: Pipeline
|
||||
|
||||
# 单一流水线,三个串联 stage:build -> publish(npm) -> deploy。
|
||||
# build : 安装/测试/类型检查/包内容校验(PR 与 push 都跑)。
|
||||
# publish : 发布到 npm(仅 release / 版本 tag / 手动触发;needs build)。
|
||||
# deploy : SSH 安装到 ubuntu@openclaw.svc.plus(needs publish)。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Plugin version to install (e.g. 2026.6.1). Leave blank to use package.json."
|
||||
required: false
|
||||
default: ""
|
||||
force:
|
||||
description: "Reinstall even if the same version is already installed."
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
|
||||
concurrency:
|
||||
group: pipeline-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ───────────────────────── Stage 1: build ─────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Verify npm package contents
|
||||
run: pnpm pack:check
|
||||
|
||||
# ──────────────────────── Stage 2: publish ────────────────────────
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: build
|
||||
if: >-
|
||||
github.event_name == 'release' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-openclaw-multi-session-plugins
|
||||
jwtGithubAudience: vault
|
||||
secrets: |
|
||||
kv/data/github-actions/openclaw-multi-session-plugins NPM_TOKEN | NPM_TOKEN
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org/
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve package metadata
|
||||
id: meta
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify npm publish access
|
||||
shell: bash
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ steps.vault.outputs.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
user="$(npm whoami 2>/dev/null || true)"
|
||||
if [ -z "${user}" ]; then
|
||||
echo "::error::NPM_TOKEN is not valid for npm publish. Create an npm automation token for an account that can publish ${name}, then store it in Vault as NPM_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
if npm view "${name}" name >/dev/null 2>&1; then
|
||||
echo "::notice::Publishing ${name}@${version} as npm user ${user}; package already exists."
|
||||
else
|
||||
echo "::notice::Publishing ${name}@${version} as npm user ${user}; npm will create this public package on first publish."
|
||||
fi
|
||||
|
||||
- name: Check published version
|
||||
id: published
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
if npm view "${name}@${version}" version >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "${name}@${version} is already published; skipping npm publish."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.published.outputs.exists != 'true'
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ steps.vault.outputs.NPM_TOKEN }}
|
||||
|
||||
# ───────────────────────── Stage 3: deploy ────────────────────────
|
||||
deploy:
|
||||
name: Update plugin on ubuntu@openclaw.svc.plus
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: openclaw-deploy
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
SSH_HOST: ubuntu@openclaw.svc.plus
|
||||
PLUGIN_NAME: openclaw-multi-session-plugins
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ env.VAULT_ADDR }}
|
||||
method: jwt
|
||||
role: github-actions-openclaw-multi-session-plugins
|
||||
jwtGithubAudience: vault
|
||||
secrets: |
|
||||
kv/data/github-actions/openclaw-multi-session-plugins OPENCLAW_SSH_KEY | OPENCLAW_SSH_KEY ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins OPENCLAW_SSH_KEY_B64 | OPENCLAW_SSH_KEY_B64 ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins SINGLE_NODE_VPS_SSH_PRIVATE_KEY | SINGLE_NODE_VPS_SSH_PRIVATE_KEY ;
|
||||
kv/data/github-actions/openclaw-multi-session-plugins SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 | SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64
|
||||
|
||||
- name: Resolve target version
|
||||
id: version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PUBLISH_VERSION: ${{ needs.publish.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_VERSION}" ]; then
|
||||
value="${INPUT_VERSION}"
|
||||
elif [ -n "${PUBLISH_VERSION}" ]; then
|
||||
value="${PUBLISH_VERSION}"
|
||||
else
|
||||
value="$(node -p "require('./package.json').version")"
|
||||
fi
|
||||
value="${value##*/}"
|
||||
value="${value#v}"
|
||||
if ! [[ "${value}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Resolved value '${value}' is not a valid X.Y.Z version"
|
||||
exit 1
|
||||
fi
|
||||
echo "value=${value}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved plugin version: ${value}"
|
||||
|
||||
- name: Resolve install source
|
||||
id: install
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
FORCE: ${{ inputs.force || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
if [ "${FORCE}" != "true" ] && npm view "${PACKAGE}" version >/dev/null 2>&1; then
|
||||
PUBLISHED="$(npm view "${PACKAGE}" version)"
|
||||
echo "::notice::${PLUGIN_NAME}@${PUBLISHED} is available on npm"
|
||||
echo "source=npm" >> "$GITHUB_OUTPUT"
|
||||
echo "install_spec=${PACKAGE}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
install_spec="/tmp/${PLUGIN_NAME}-${VERSION}-${GITHUB_SHA}.tgz"
|
||||
echo "::warning::Building and installing ${install_spec} from the checked-out source"
|
||||
echo "source=archive" >> "$GITHUB_OUTPUT"
|
||||
echo "install_spec=${install_spec}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Build archive install source
|
||||
id: archive
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm pack
|
||||
tarball="${PLUGIN_NAME}-${VERSION}.tgz"
|
||||
test -f "${tarball}"
|
||||
echo "tarball=${tarball}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
OPENCLAW_SSH_KEY: ${{ steps.vault.outputs.OPENCLAW_SSH_KEY }}
|
||||
OPENCLAW_SSH_KEY_B64: ${{ steps.vault.outputs.OPENCLAW_SSH_KEY_B64 }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY }}
|
||||
SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64: ${{ steps.vault.outputs.SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSH_KEY=""
|
||||
if [ -n "${OPENCLAW_SSH_KEY_B64:-}" ]; then
|
||||
SSH_KEY="$(printf '%s' "${OPENCLAW_SSH_KEY_B64}" | base64 -d)"
|
||||
elif [ -n "${OPENCLAW_SSH_KEY:-}" ]; then
|
||||
SSH_KEY="${OPENCLAW_SSH_KEY}"
|
||||
fi
|
||||
if [ -z "${SSH_KEY}" ] && [ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64:-}" ]; then
|
||||
SSH_KEY="$(printf '%s' "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY_B64}" | base64 -d)"
|
||||
elif [ -z "${SSH_KEY}" ] && [ -n "${SINGLE_NODE_VPS_SSH_PRIVATE_KEY:-}" ]; then
|
||||
SSH_KEY="${SINGLE_NODE_VPS_SSH_PRIVATE_KEY}"
|
||||
fi
|
||||
|
||||
if [ -z "${SSH_KEY}" ]; then
|
||||
echo "::error::Neither OPENCLAW_SSH_KEY nor SINGLE_NODE_VPS_SSH_PRIVATE_KEY is set."
|
||||
exit 1
|
||||
fi
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "${SSH_KEY}" > ~/.ssh/openclaw_ed25519
|
||||
chmod 600 ~/.ssh/openclaw_ed25519
|
||||
ssh-keyscan -H openclaw.svc.plus >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Verify SSH connectivity
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
"${SSH_HOST}" 'echo "connected to $(hostname) as $(whoami)"'
|
||||
|
||||
- name: Upload archive install source
|
||||
if: steps.install.outputs.source == 'archive'
|
||||
run: |
|
||||
scp -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes \
|
||||
"${{ steps.archive.outputs.tarball }}" "${SSH_HOST}:${{ steps.install.outputs.install_spec }}"
|
||||
|
||||
- name: Install or update plugin on remote host
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
INSTALL_SPEC: ${{ steps.install.outputs.install_spec }}
|
||||
INSTALL_SOURCE: ${{ steps.install.outputs.source }}
|
||||
FORCE: ${{ inputs.force || 'false' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ServerAliveInterval=30 \
|
||||
"${SSH_HOST}" bash -s -- "${PLUGIN_NAME}" "${VERSION}" "${INSTALL_SPEC}" "${INSTALL_SOURCE}" "${FORCE}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
PLUGIN_NAME="$1"
|
||||
VERSION="$2"
|
||||
INSTALL_SPEC="$3"
|
||||
INSTALL_SOURCE="$4"
|
||||
FORCE="$5"
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
STATE_DIR="/tmp/openclaw-deploy"
|
||||
mkdir -p "${STATE_DIR}"
|
||||
|
||||
echo "==> Installing ${PACKAGE} from ${INSTALL_SOURCE} on $(hostname) (force=${FORCE})"
|
||||
echo "==> Install spec: ${INSTALL_SPEC}"
|
||||
|
||||
# Record the previously installed version for rollback.
|
||||
PREVIOUS_VERSION=""
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
PREVIOUS_VERSION="$(npm ls -g "${PLUGIN_NAME}" --depth=0 2>/dev/null \
|
||||
| awk -F'[@:]' '/'"${PLUGIN_NAME}"'@/ {print $2; exit}' || true)"
|
||||
fi
|
||||
echo "==> Previously installed version: ${PREVIOUS_VERSION:-<none>}"
|
||||
|
||||
# Skip when the requested version is already present unless forced.
|
||||
if [ "${FORCE}" != "true" ] && [ "${PREVIOUS_VERSION}" = "${VERSION}" ]; then
|
||||
echo "==> ${PACKAGE} already installed and force=false; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "${PREVIOUS_VERSION}" > "${STATE_DIR}/previous-version"
|
||||
|
||||
rollback() {
|
||||
local rc=$?
|
||||
echo "::remote-error::Install failed (exit ${rc}); attempting rollback"
|
||||
local prev
|
||||
prev="$(cat "${STATE_DIR}/previous-version" 2>/dev/null || true)"
|
||||
if [ -n "${prev}" ] && [ "${prev}" != "${VERSION}" ]; then
|
||||
echo "::remote-warning::Reinstalling ${PLUGIN_NAME}@${prev}"
|
||||
npm install -g "${PLUGIN_NAME}@${prev}" || true
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins enable "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
else
|
||||
echo "::remote-warning::No previous version recorded; leaving host as-is"
|
||||
fi
|
||||
exit "${rc}"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
install_plugin() {
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins install --force "${INSTALL_SPEC}" \
|
||||
|| openclaw plugins install "${INSTALL_SPEC}" \
|
||||
|| openclaw plugins update "${INSTALL_SPEC}" \
|
||||
|| npm install -g "${INSTALL_SPEC}"
|
||||
else
|
||||
npm install -g "${INSTALL_SPEC}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_plugin
|
||||
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins enable "${PLUGIN_NAME}" || true
|
||||
systemctl --user restart openclaw-gateway.service || true
|
||||
fi
|
||||
|
||||
# Verify the installed version matches the requested version.
|
||||
GLOBAL_ROOT="$(npm root -g)"
|
||||
INSTALLED=""
|
||||
if [ -f "${GLOBAL_ROOT}/${PLUGIN_NAME}/package.json" ]; then
|
||||
INSTALLED="$(node -p "require('${GLOBAL_ROOT}/${PLUGIN_NAME}/package.json').version")"
|
||||
fi
|
||||
if [ "${INSTALLED}" != "${VERSION}" ]; then
|
||||
echo "::remote-error::Verification failed: expected ${VERSION}, found ${INSTALLED:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
rm -f "${STATE_DIR}/previous-version"
|
||||
|
||||
echo "==> Installed plugin state:"
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins info "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
npm ls -g "${PLUGIN_NAME}" || true
|
||||
echo "==> ${PACKAGE} is now active on $(hostname)"
|
||||
REMOTE
|
||||
|
||||
- name: Summarize deploy
|
||||
if: always()
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "::notice::openclaw-multi-session-plugins@${VERSION} deployed to ubuntu@openclaw.svc.plus"
|
||||
else
|
||||
echo "::error::Deploy to ubuntu@openclaw.svc.plus failed for openclaw-multi-session-plugins@${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
77
.github/workflows/publish.yml
vendored
Normal file
77
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,77 @@
|
||||
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
107
.github/workflows/runtime-release.yaml
vendored
@ -1,107 +0,0 @@
|
||||
name: Build OpenClaw Plugin Runtime Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/**]
|
||||
paths:
|
||||
- "**/*.ts"
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- openclaw.plugin.json
|
||||
- .github/workflows/runtime-release.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: openclaw-plugin-runtime-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Plugin Assets
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
|
||||
- name: Build runtime asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="dist/runtime/openclaw-multi-session-plugins"
|
||||
mkdir -p "${root}/dist" dist/assets
|
||||
|
||||
# Maintain necessary file structure required by openclaw loader
|
||||
cp -a dist/index.js dist/index.d.ts dist/src "${root}/dist/"
|
||||
cp openclaw.plugin.json package.json "${root}/"
|
||||
|
||||
tar -czf "dist/assets/openclaw-multi-session-plugins-runtime-all.tar.gz" \
|
||||
-C dist/runtime openclaw-multi-session-plugins
|
||||
|
||||
(
|
||||
cd dist/assets
|
||||
sha256sum -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-all"
|
||||
)
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openclaw-plugin-assets
|
||||
path: |
|
||||
dist/assets/*.tar.gz
|
||||
dist/assets/SHA256SUMS-*
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: openclaw-plugin-assets
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS || true
|
||||
rm -f dist/SHA256SUMS-*
|
||||
|
||||
# Publish (or refresh) a release with the runtime tarball + checksums.
|
||||
# --latest=false keeps GitHub's "Latest release" pointer free for the
|
||||
# human-facing v* tags; deployments pull via explicit tag URLs instead.
|
||||
publish_release() {
|
||||
local tag="$1" title="$2"
|
||||
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
else
|
||||
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--target "${GITHUB_SHA}" \
|
||||
--latest=false \
|
||||
--title "${title}" \
|
||||
--notes "Prebuilt Plugin assets. No target-host build or Nix profile installation required."
|
||||
fi
|
||||
}
|
||||
|
||||
# Immutable per-commit release for traceability.
|
||||
publish_release "runtime-${GITHUB_SHA::12}" "OpenClaw Plugin runtime ${GITHUB_SHA::12}"
|
||||
# Stable moving release so deployments resolve a deterministic URL
|
||||
# (releases/download/runtime-latest/...) instead of the mutable
|
||||
# /releases/latest/ pointer, which collides with other release tracks.
|
||||
publish_release "runtime-latest" "OpenClaw Plugin runtime (latest)"
|
||||
44
.github/workflows/validate-release-pr.yml
vendored
44
.github/workflows/validate-release-pr.yml
vendored
@ -1,44 +0,0 @@
|
||||
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
6
.gitignore
vendored
@ -3,9 +3,3 @@ 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/
|
||||
|
||||
53
README.md
53
README.md
@ -1,27 +1,20 @@
|
||||
# openclaw-multi-session-plugins
|
||||
|
||||
OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling.
|
||||
OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifact manifests.
|
||||
|
||||
## 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 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
|
||||
execution. This package only adapts those existing OpenClaw task/session
|
||||
identities into isolated artifact directories, session key mapping, and signed
|
||||
artifact reads.
|
||||
|
||||
It registers the minimal Gateway methods needed by XWorkmate:
|
||||
|
||||
@ -79,18 +72,16 @@ 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 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.
|
||||
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.
|
||||
|
||||
```json
|
||||
{
|
||||
"appThreadKey": "draft:thread-main",
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"sessionKey": "thread-main",
|
||||
"runId": "turn-1",
|
||||
"workspaceDir": "/home/user/.openclaw/workspace"
|
||||
}
|
||||
@ -101,7 +92,7 @@ Prepare response payload:
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"sessionKey": "thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
@ -116,7 +107,7 @@ Export request params:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"sessionKey": "thread-main",
|
||||
"runId": "turn-1",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
@ -130,7 +121,7 @@ Export response payload:
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"sessionKey": "thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
@ -153,10 +144,9 @@ 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 `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.
|
||||
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.
|
||||
|
||||
If the prepared task scope is empty, trusted Gateway callers may pass
|
||||
`expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then
|
||||
@ -167,10 +157,9 @@ earlier task scopes.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
`artifactScope + relativePath` for the current `openclawSessionKey/runId` task
|
||||
scope. Signed task `artifactRef` values are accepted only for the same
|
||||
`openclawSessionKey/runId` that issued them. There is no unscoped arbitrary
|
||||
workspace read API.
|
||||
`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.
|
||||
|
||||
## View And Download
|
||||
|
||||
|
||||
8
dist/index.d.ts
vendored
8
dist/index.d.ts
vendored
@ -1,9 +1,9 @@
|
||||
export declare function lastAssistantText(messages: unknown): string | undefined;
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
declare const plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
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">;
|
||||
register: typeof register;
|
||||
};
|
||||
export default plugin;
|
||||
declare function register(api: OpenClawPluginApi): void;
|
||||
|
||||
142
dist/index.js
vendored
142
dist/index.js
vendored
@ -1,7 +1,6 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
|
||||
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, recordXWorkmateTaskRunStarted, recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||
import { createOrUpdateXWorkmateTaskRecord, createXWorkmateTaskStore, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateDetachedTaskRuntime, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||
function scopedGatewayParams(params) {
|
||||
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
||||
const runScope = resolveRunScope({ sessionScope });
|
||||
@ -10,7 +9,7 @@ function scopedGatewayParams(params) {
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
openclawSessionKey: runScope.sessionKey,
|
||||
sessionKey: runScope.sessionKey,
|
||||
runId: runScope.runId,
|
||||
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
|
||||
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
@ -30,133 +29,48 @@ function resolveRunScope(ctx) {
|
||||
...(scope?.relativeTaskDirectory ? { artifactScope: scope.relativeTaskDirectory } : {}),
|
||||
};
|
||||
}
|
||||
function stringParam(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
export function lastAssistantText(messages) {
|
||||
if (!Array.isArray(messages))
|
||||
return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object")
|
||||
continue;
|
||||
const record = message;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant")
|
||||
continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim())
|
||||
return content.trim();
|
||||
if (!Array.isArray(content))
|
||||
continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object")
|
||||
return "";
|
||||
const item = block;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text)
|
||||
return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const plugin = definePluginEntry({
|
||||
const plugin = {
|
||||
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);
|
||||
api.registerHook("session_start", async (event) => {
|
||||
registerXWorkmateDetachedTaskRuntime(api, taskStore);
|
||||
api.registerHook("session.start", async (event) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(event?.context ?? event);
|
||||
const openclawSessionKey = stringParam(params.openclawSessionKey);
|
||||
if (openclawSessionKey && params.runId) {
|
||||
const hookParams = { ...params, openclawSessionKey };
|
||||
if (params.sessionKey && params.runId) {
|
||||
createOrUpdateXWorkmateTaskRecord(taskStore, {
|
||||
params,
|
||||
status: "running",
|
||||
progressSummary: "OpenClaw task is running",
|
||||
});
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: hookParams,
|
||||
params,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: hookParams,
|
||||
taskStore,
|
||||
params,
|
||||
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.on("agent_end", async (event, ctx) => {
|
||||
try {
|
||||
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
|
||||
const runId = stringParam(event?.runId ?? ctx?.runId);
|
||||
if (!openclawSessionKey || !runId) {
|
||||
return;
|
||||
}
|
||||
await recordXWorkmateTaskRunTerminal({
|
||||
api,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
success: event?.success === true,
|
||||
output: lastAssistantText(event?.messages),
|
||||
error: event?.error,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
api.logger?.warn?.(`xworkmate agent_end state capture failed: ${String(error)}`);
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.session.prepare", async (opts) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(opts.params);
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params,
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
const payload = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(true, {
|
||||
...payload,
|
||||
mapping,
|
||||
appThreadKey: mapping.appThreadKey,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
}, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: String(error).includes("conflict") ? "CONFLICT" : "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => {
|
||||
try {
|
||||
const payload = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
taskStore,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
@ -168,6 +82,22 @@ function register(api) {
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
|
||||
try {
|
||||
const payload = await prepareXWorkmateArtifacts({
|
||||
params: scopedGatewayParams(opts.params),
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
}
|
||||
catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.export", async (opts) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
@ -290,10 +220,10 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
const { sessionKey: _ignoredSessionKey, openclawSessionKey: _ignoredOpenclawSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
|
||||
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
|
||||
const baseParams = {
|
||||
...operationParams,
|
||||
openclawSessionKey: sessionKey,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
|
||||
1
dist/src/expectedArtifactDirs.d.ts
vendored
1
dist/src/expectedArtifactDirs.d.ts
vendored
@ -1 +0,0 @@
|
||||
export declare function normalizeExpectedArtifactDirs(value: unknown): string[];
|
||||
33
dist/src/expectedArtifactDirs.js
vendored
33
dist/src/expectedArtifactDirs.js
vendored
@ -1,33 +0,0 @@
|
||||
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;
|
||||
}
|
||||
24
dist/src/exportArtifacts.d.ts
vendored
24
dist/src/exportArtifacts.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
type XWorkmateArtifact = {
|
||||
export type XWorkmateArtifact = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
contentType: string;
|
||||
@ -10,8 +10,8 @@ type XWorkmateArtifact = {
|
||||
encoding?: "base64";
|
||||
content?: string;
|
||||
};
|
||||
type XWorkmateArtifactScopeKind = "task";
|
||||
type XWorkmateArtifactExport = {
|
||||
export type XWorkmateArtifactScopeKind = "task";
|
||||
export type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
@ -20,16 +20,8 @@ type XWorkmateArtifactExport = {
|
||||
scopeKind: XWorkmateArtifactScopeKind;
|
||||
artifacts: XWorkmateArtifact[];
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
constraintSatisfied: boolean;
|
||||
missingRequiredExtensions: string[];
|
||||
missingRequiredFileCounts: Record<string, {
|
||||
expected: number;
|
||||
actual: number;
|
||||
}>;
|
||||
};
|
||||
type XWorkmateArtifactPrepare = {
|
||||
export type XWorkmateArtifactPrepare = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
@ -39,14 +31,8 @@ type XWorkmateArtifactPrepare = {
|
||||
artifactDirectory: string;
|
||||
relativeArtifactDirectory: string;
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
};
|
||||
type XWorkmateExpectedArtifactDirStatus = {
|
||||
relativePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
type XWorkmateArtifactSnapshot = {
|
||||
export type XWorkmateArtifactSnapshot = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
|
||||
147
dist/src/exportArtifacts.js
vendored
147
dist/src/exportArtifacts.js
vendored
@ -2,7 +2,6 @@ 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";
|
||||
@ -22,8 +21,7 @@ export async function prepareXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
@ -39,7 +37,6 @@ 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,
|
||||
@ -50,15 +47,13 @@ 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.openclawSessionKey, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
@ -125,13 +120,11 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey 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,
|
||||
@ -140,7 +133,6 @@ 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) {
|
||||
@ -160,9 +152,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const scopeStat = await fs.stat(scopeRoot);
|
||||
effectiveSince = Math.min(sinceUnixMs, scopeStat.birthtimeMs || scopeStat.mtimeMs);
|
||||
}
|
||||
catch (error) {
|
||||
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
@ -175,6 +165,9 @@ 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"));
|
||||
@ -197,11 +190,6 @@ 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;
|
||||
}
|
||||
@ -248,8 +236,6 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
}
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
|
||||
const missingRequiredFileCounts = missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension);
|
||||
const result = {
|
||||
runId,
|
||||
sessionKey,
|
||||
@ -259,11 +245,6 @@ 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;
|
||||
}
|
||||
@ -271,7 +252,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.openclawSessionKey, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
@ -366,90 +347,9 @@ 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",
|
||||
@ -816,7 +716,36 @@ function resolveWorkspaceDir(input) {
|
||||
if (explicit) {
|
||||
return expandUserPath(explicit);
|
||||
}
|
||||
return expandUserPath(path.join("~", ".openclaw", "workspace"));
|
||||
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 "";
|
||||
}
|
||||
function safeRelativePath(root, target) {
|
||||
const relative = path.relative(root, target);
|
||||
|
||||
94
dist/src/taskState.d.ts
vendored
94
dist/src/taskState.d.ts
vendored
@ -1,65 +1,57 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export declare const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
export type XWorkmateTaskMetadataV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey?: string;
|
||||
expectedArtifactDirs: string[];
|
||||
requestId?: string;
|
||||
externalTaskId?: string;
|
||||
createdAt: string;
|
||||
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 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;
|
||||
type XWorkmateSessionMapping = {
|
||||
appSessionKey: string;
|
||||
openClawSessionKey: string;
|
||||
appThreadId?: string;
|
||||
sessionId?: string;
|
||||
runId: string;
|
||||
artifactScope?: string;
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
export type XWorkmateRecordedTaskRunV1 = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
success: boolean;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
export type XWorkmateTaskStore = {
|
||||
records: Map<string, XWorkmateTaskRecord>;
|
||||
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
|
||||
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
|
||||
};
|
||||
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;
|
||||
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>;
|
||||
}): Promise<void>;
|
||||
export declare function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore): void;
|
||||
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 {};
|
||||
|
||||
685
dist/src/taskState.js
vendored
685
dist/src/taskState.js
vendored
@ -1,9 +1,13 @@
|
||||
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate";
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
const MAX_RECORDED_TASK_RUNS = 32;
|
||||
export function createXWorkmateTaskStore() {
|
||||
return {
|
||||
records: new Map(),
|
||||
sessionMappingsByAppKey: new Map(),
|
||||
sessionMappingsByOpenClawKey: new Map(),
|
||||
};
|
||||
}
|
||||
export function registerXWorkmateSessionExtension(api) {
|
||||
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
|
||||
if (typeof registerExtension !== "function") {
|
||||
@ -11,459 +15,251 @@ export function registerXWorkmateSessionExtension(api) {
|
||||
}
|
||||
registerExtension({
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
description: "Durable XWorkmate app/OpenClaw session key mapping.",
|
||||
description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
project: (ctx) => {
|
||||
const state = asRecord(ctx.state);
|
||||
return state ?? {};
|
||||
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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateSessionMapping(input) {
|
||||
const metadata = normalizeXWorkmateTaskMetadataV1(input.params);
|
||||
const openclawSessionKey = requiredString(input.params.openclawSessionKey ?? metadata.openclawSessionKey, "openclawSessionKey required");
|
||||
return upsertXWorkmateSessionMapping(input.api, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
openclawSessionKey,
|
||||
},
|
||||
openclawSessionKey,
|
||||
source: input.source ?? "bridge_prepare",
|
||||
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,
|
||||
});
|
||||
}
|
||||
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");
|
||||
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;
|
||||
}
|
||||
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,
|
||||
await patchSessionExtension({
|
||||
key: openClawSessionKey,
|
||||
sessionKey: openClawSessionKey,
|
||||
pluginId: XWORKMATE_PLUGIN_ID,
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
value: mapping,
|
||||
});
|
||||
}
|
||||
async function upsertXWorkmateSessionMapping(api, input) {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
export function registerXWorkmateDetachedTaskRuntime(api, taskStore) {
|
||||
const registerRuntime = api.registerDetachedTaskRuntime;
|
||||
if (typeof registerRuntime !== "function") {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
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),
|
||||
};
|
||||
record.status = "cancelled";
|
||||
record.endedAt = Date.now();
|
||||
record.lastEventAt = record.endedAt;
|
||||
return { found: true, cancelled: true, reason: optionalString(params.reason), task: record };
|
||||
},
|
||||
});
|
||||
if (!mapping) {
|
||||
throw new Error("failed to write xworkmate session mapping");
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
async function readXWorkmateSessionMapping(api, lookup) {
|
||||
const getSessionEntry = resolveGetSessionEntry(api);
|
||||
if (!getSessionEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const openclawSessionKey = optionalString(lookup.openclawSessionKey);
|
||||
if (openclawSessionKey) {
|
||||
return readMappingFromEntry(getSessionEntry({ sessionKey: openclawSessionKey }));
|
||||
}
|
||||
const appThreadKey = optionalString(lookup.appThreadKey);
|
||||
if (!appThreadKey) {
|
||||
return undefined;
|
||||
}
|
||||
const listSessionEntries = resolveListSessionEntries(api);
|
||||
for (const item of listSessionEntries?.() ?? []) {
|
||||
const mapping = readMappingFromEntry(item.entry);
|
||||
if (mapping?.appThreadKey === appThreadKey) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export async function getXWorkmateTaskSnapshot(input) {
|
||||
const params = input.params ?? {};
|
||||
const appThreadKey = optionalString(params.appThreadKey);
|
||||
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
|
||||
const mapping = await readXWorkmateSessionMapping(input.api, {
|
||||
appThreadKey,
|
||||
openclawSessionKey: explicitOpenclawSessionKey,
|
||||
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,
|
||||
});
|
||||
if (!mapping && appThreadKey && !explicitOpenclawSessionKey) {
|
||||
return lookupError("mapping_not_found", `No OpenClaw session mapping found for ${appThreadKey}`);
|
||||
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";
|
||||
}
|
||||
const openclawSessionKey = mapping?.openclawSessionKey || explicitOpenclawSessionKey;
|
||||
if (!openclawSessionKey) {
|
||||
return lookupError("invalid_lookup", "openclawSessionKey or appThreadKey required");
|
||||
}
|
||||
const runId = optionalString(params.runId);
|
||||
const taskId = optionalString(params.taskId);
|
||||
const task = resolveNativeTask(input.api, {
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId,
|
||||
});
|
||||
const includeArtifacts = params.includeArtifacts !== false;
|
||||
if (!task) {
|
||||
const recordedRun = runId
|
||||
? readXWorkmateTaskRun(input.api, openclawSessionKey, runId)
|
||||
: undefined;
|
||||
const exported = includeArtifacts && runId
|
||||
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
|
||||
: undefined;
|
||||
if (recordedRun) {
|
||||
return {
|
||||
success: recordedRun.status === "running" ? true : recordedRun.success,
|
||||
status: recordedRun.status,
|
||||
taskStatus: recordedRun.status,
|
||||
terminal: recordedRun.status !== "running",
|
||||
terminalSource: recordedRun.status === "running" ? "session_prepare" : "agent_end",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: recordedRun.status,
|
||||
success: recordedRun.success,
|
||||
source: "xworkmate_run_state",
|
||||
startedAt: recordedRun.startedAt,
|
||||
updatedAt: recordedRun.updatedAt,
|
||||
completedAt: recordedRun.completedAt,
|
||||
error: recordedRun.error,
|
||||
},
|
||||
output: recordedRun.output,
|
||||
resultSummary: recordedRun.output,
|
||||
error: recordedRun.error,
|
||||
message: recordedRun.output ?? recordedRun.error,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
if (exported?.artifacts.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: "unknown",
|
||||
taskStatus: "unknown",
|
||||
evidence: "artifacts_present",
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId: taskId || runId,
|
||||
task: {
|
||||
taskId: taskId || runId,
|
||||
runId,
|
||||
status: "unknown",
|
||||
source: "artifact_fallback",
|
||||
},
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported.artifactScope,
|
||||
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
||||
scopeKind: exported.scopeKind,
|
||||
artifacts: exported.artifacts,
|
||||
constraintSatisfied: exported.constraintSatisfied,
|
||||
missingRequiredExtensions: exported.missingRequiredExtensions,
|
||||
warnings: [
|
||||
...exported.warnings,
|
||||
`Native OpenClaw task record was unavailable for ${openclawSessionKey}; artifacts are present but task status is unknown.`,
|
||||
],
|
||||
artifactCount: exported.artifacts.length,
|
||||
};
|
||||
}
|
||||
const code = runId || taskId ? "no_native_task_record" : "task_not_found";
|
||||
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
|
||||
}
|
||||
const taskStatus = optionalString(task.status) || "running";
|
||||
const exported = includeArtifacts
|
||||
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId || optionalString(task.runId) || optionalString(task.taskId), mapping)
|
||||
: undefined;
|
||||
return {
|
||||
success: true,
|
||||
status: appStatusFromTaskStatus(taskStatus),
|
||||
taskStatus,
|
||||
mode: "gateway-chat",
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId: runId || optionalString(task.runId),
|
||||
taskId: taskId || optionalString(task.taskId),
|
||||
sessionKey,
|
||||
openClawSessionKey,
|
||||
appSessionKey,
|
||||
runId,
|
||||
task,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
constraintSatisfied: exported?.constraintSatisfied,
|
||||
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
artifactScope: exported.artifactScope,
|
||||
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
||||
scopeKind: exported.scopeKind,
|
||||
artifacts: exported.artifacts,
|
||||
warnings: exported.warnings,
|
||||
artifactCount: exported.artifacts.length,
|
||||
};
|
||||
}
|
||||
async function upsertXWorkmateTaskRun(api, input) {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
let recorded;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const runs = readTaskRunsFromEntry(entry);
|
||||
const existing = runs[input.runId];
|
||||
recorded = compactObject({
|
||||
schemaVersion: 1,
|
||||
runId: input.runId,
|
||||
status: input.status,
|
||||
success: input.success,
|
||||
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
|
||||
updatedAt: input.updatedAt,
|
||||
completedAt: input.completedAt,
|
||||
output: input.output,
|
||||
error: input.error,
|
||||
});
|
||||
runs[input.runId] = recorded;
|
||||
const boundedRuns = Object.fromEntries(Object.entries(runs)
|
||||
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
|
||||
.slice(0, MAX_RECORDED_TASK_RUNS));
|
||||
return {
|
||||
pluginExtensions: {
|
||||
...(entry.pluginExtensions ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
|
||||
schemaVersion: 1,
|
||||
runs: boundedRuns,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!recorded) {
|
||||
throw new Error("failed to write xworkmate task run state");
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
function readXWorkmateTaskRun(api, openclawSessionKey, runId) {
|
||||
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
|
||||
return readTaskRunsFromEntry(entry)[runId];
|
||||
}
|
||||
function readTaskRunsFromEntry(entry) {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
|
||||
if (store?.schemaVersion !== 1) {
|
||||
return {};
|
||||
}
|
||||
const runs = asRecord(store.runs) ?? {};
|
||||
const result = {};
|
||||
for (const [key, rawValue] of Object.entries(runs)) {
|
||||
const raw = asRecord(rawValue);
|
||||
const runId = optionalString(raw?.runId) || key;
|
||||
const status = optionalString(raw?.status);
|
||||
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
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),
|
||||
});
|
||||
if (options.progressSummary) {
|
||||
existing.progressSummary = options.progressSummary;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return result;
|
||||
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 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 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;
|
||||
});
|
||||
for (const record of records) {
|
||||
Object.assign(record, compactObject(patch));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
function resolveNativeTask(api, input) {
|
||||
function resolveNativeTask(api, sessionKey, runId) {
|
||||
try {
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey });
|
||||
if (!bound) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = input.taskId || input.runId || "";
|
||||
const resolved = lookup ? bound.resolve?.(lookup) || bound.get?.(lookup) : bound.findLatest?.();
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey });
|
||||
const resolved = bound?.resolve?.(runId) || bound?.get?.(runId);
|
||||
return asRecord(resolved);
|
||||
}
|
||||
catch (error) {
|
||||
api.logger?.warn?.(`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`);
|
||||
api.logger?.warn?.(`xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function lookupError(code, message, mapping) {
|
||||
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");
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
|
||||
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",
|
||||
};
|
||||
}
|
||||
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;
|
||||
function normalizeTaskStatus(status, hasArtifacts) {
|
||||
const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running");
|
||||
if (normalized === "running" && hasArtifacts) {
|
||||
return "succeeded";
|
||||
}
|
||||
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;
|
||||
return normalized;
|
||||
}
|
||||
function appStatusFromTaskStatus(status) {
|
||||
if (status === "succeeded") {
|
||||
@ -474,12 +270,38 @@ function appStatusFromTaskStatus(status) {
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
function parseMappingSource(value) {
|
||||
const source = optionalString(value);
|
||||
if (source === "session_start" || source === "bridge_prepare") {
|
||||
return source;
|
||||
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;
|
||||
}
|
||||
return "bridge_prepare";
|
||||
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;
|
||||
}
|
||||
function requiredString(value, message) {
|
||||
const text = optionalString(value);
|
||||
@ -495,6 +317,26 @@ 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;
|
||||
@ -504,3 +346,6 @@ 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, "_");
|
||||
}
|
||||
|
||||
@ -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, { lastAssistantText } from "./index.js";
|
||||
import plugin from "./index.js";
|
||||
import { prepareXWorkmateArtifacts } from "./src/exportArtifacts.js";
|
||||
|
||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
@ -14,12 +14,6 @@ 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[] };
|
||||
@ -48,7 +42,6 @@ describe("plugin registration", () => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
@ -80,7 +73,6 @@ describe("plugin registration", () => {
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
@ -100,12 +92,12 @@ describe("plugin registration", () => {
|
||||
openclawSessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
});
|
||||
expect(prepared.ok).toBe(true);
|
||||
console.log(prepared); expect(prepared.ok).toBe(true);
|
||||
expect(prepared.payload?.artifactScope).toBe("tasks/thread-main/turn-1");
|
||||
const artifactDirectory = String(prepared.payload?.artifactDirectory);
|
||||
|
||||
const emptyExport = await callGatewayMethod(methods, "xworkmate.artifacts.export", {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
});
|
||||
@ -117,7 +109,7 @@ describe("plugin registration", () => {
|
||||
await fs.promises.writeFile(path.join(artifactDirectory, "reports", "final.md"), "final");
|
||||
|
||||
const listed = await callGatewayMethod(methods, "xworkmate.artifacts.list", {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
});
|
||||
@ -127,7 +119,7 @@ describe("plugin registration", () => {
|
||||
expect(listedArtifacts[0]).not.toHaveProperty("content");
|
||||
|
||||
const read = await callGatewayMethod(methods, "xworkmate.artifacts.read", {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.payload?.artifactScope,
|
||||
relativePath: "reports/final.md",
|
||||
@ -136,7 +128,7 @@ describe("plugin registration", () => {
|
||||
expect(read.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md", encoding: "base64" }]);
|
||||
|
||||
const unprepared = await callGatewayMethod(methods, "xworkmate.artifacts.export", {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-unprepared",
|
||||
});
|
||||
expect(unprepared.ok).toBe(true);
|
||||
@ -147,7 +139,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, ctx?: unknown) => Promise<void>>();
|
||||
const hooks = new Map<string, (event: unknown) => Promise<void>>();
|
||||
const sessionExtensions: Array<Record<string, unknown>> = [];
|
||||
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
|
||||
const detachedRuntimes: Array<Record<string, unknown>> = [];
|
||||
@ -205,10 +197,7 @@ describe("plugin registration", () => {
|
||||
methods.set(method, handler);
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
on: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
|
||||
@ -222,21 +211,15 @@ describe("plugin registration", () => {
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
});
|
||||
const projected = (sessionExtensions[0]?.project as (ctx: Record<string, unknown>) => unknown)({
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
sessionKey: "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",
|
||||
@ -268,15 +251,6 @@ 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 () => {
|
||||
@ -286,7 +260,6 @@ 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 });
|
||||
},
|
||||
@ -316,7 +289,6 @@ describe("plugin registration", () => {
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
@ -330,11 +302,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const other = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.promises.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
|
||||
@ -347,7 +319,6 @@ describe("plugin registration", () => {
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
@ -369,7 +340,7 @@ describe("plugin registration", () => {
|
||||
});
|
||||
const result = await tool.execute("call-1", {
|
||||
action: "list",
|
||||
openclawSessionKey: "thread-other",
|
||||
sessionKey: "thread-other",
|
||||
runId: "turn-2",
|
||||
workspaceDir: "/",
|
||||
});
|
||||
|
||||
73
index.ts
73
index.ts
@ -3,7 +3,6 @@ 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,
|
||||
@ -15,8 +14,7 @@ import {
|
||||
import {
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
registerXWorkmateDetachedTaskRuntime,
|
||||
registerXWorkmateSessionExtension,
|
||||
} from "./src/taskState.js";
|
||||
|
||||
@ -55,7 +53,7 @@ function scopedGatewayParams(params: Record<string, unknown>): Record<string, un
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
openclawSessionKey: runScope.sessionKey,
|
||||
sessionKey: runScope.sessionKey,
|
||||
runId: runScope.runId,
|
||||
...(runScope.workspaceDir ? { workspaceDir: runScope.workspaceDir } : {}),
|
||||
...(runScope.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
@ -86,49 +84,26 @@ function stringParam(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function lastAssistantText(messages: unknown): string | undefined {
|
||||
if (!Array.isArray(messages)) return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const record = message as Record<string, unknown>;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant") continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim()) return content.trim();
|
||||
if (!Array.isArray(content)) continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const item = block as Record<string, unknown>;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const plugin = definePluginEntry({
|
||||
const plugin = {
|
||||
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);
|
||||
const openclawSessionKey = stringParam(params.openclawSessionKey) || stringParam(params.sessionKey);
|
||||
if (openclawSessionKey && params.runId) {
|
||||
const hookParams = { ...params, openclawSessionKey };
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
@ -138,6 +113,7 @@ function register(api: OpenClawPluginApi) {
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
taskStore,
|
||||
params: hookParams,
|
||||
artifactScope: prepared.artifactScope,
|
||||
source: "session_start",
|
||||
@ -150,34 +126,12 @@ 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",
|
||||
});
|
||||
@ -190,11 +144,6 @@ function register(api: OpenClawPluginApi) {
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(
|
||||
true,
|
||||
{
|
||||
@ -218,6 +167,7 @@ function register(api: OpenClawPluginApi) {
|
||||
try {
|
||||
const payload = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
taskStore,
|
||||
params: scopedGatewayParams(opts.params),
|
||||
});
|
||||
opts.respond(true, payload, undefined);
|
||||
@ -353,14 +303,13 @@ function createXWorkmateArtifactsTool(
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
const {
|
||||
sessionKey: _ignoredSessionKey,
|
||||
openclawSessionKey: _ignoredOpenclawSessionKey,
|
||||
runId: _ignoredRunId,
|
||||
workspaceDir: _ignoredWorkspaceDir,
|
||||
...operationParams
|
||||
} = params;
|
||||
const baseParams = {
|
||||
...operationParams,
|
||||
openclawSessionKey: sessionKey,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}),
|
||||
|
||||
5524
package-lock.json
generated
5524
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openclaw-multi-session-plugins",
|
||||
"version": "2026.6.1",
|
||||
"description": "OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling",
|
||||
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const second = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
@ -31,22 +31,11 @@ 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: { openclawSessionKey: "agent::main:main", runId: "run alpha" },
|
||||
params: { sessionKey: "agent::main:main", runId: "run alpha" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
@ -56,7 +45,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
||||
@ -66,7 +55,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
sinceUnixMs: stat.mtimeMs - 1,
|
||||
},
|
||||
@ -95,7 +84,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-expected",
|
||||
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
||||
},
|
||||
@ -103,7 +92,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
});
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-expected",
|
||||
artifactScope: prepared.artifactScope,
|
||||
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
||||
@ -125,7 +114,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-unsafe",
|
||||
expectedArtifactDirs: ["../outside"],
|
||||
},
|
||||
@ -134,159 +123,14 @@ 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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(mediaRoot, "browser"), { recursive: true });
|
||||
@ -300,7 +144,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const snapshot = await collectAndSnapshotXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
sinceUnixMs: snapshotSinceUnixMs,
|
||||
@ -321,7 +165,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
includeContent: false,
|
||||
@ -338,7 +182,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, ".git"), { recursive: true });
|
||||
@ -350,7 +194,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -363,7 +207,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "tmp"), { recursive: true });
|
||||
@ -390,7 +234,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -402,11 +246,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const second = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(first.artifactDirectory, "reports"), { recursive: true });
|
||||
@ -416,7 +260,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: first.artifactScope,
|
||||
},
|
||||
@ -435,7 +279,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "dist"), { recursive: true });
|
||||
@ -445,7 +289,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
maxInlineBytes: 0,
|
||||
},
|
||||
@ -461,11 +305,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const other = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "global.txt"), "global");
|
||||
@ -474,7 +318,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -488,14 +332,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "global.txt"), "global");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -512,7 +356,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft-article",
|
||||
sessionKey: "draft-article",
|
||||
runId: "openclaw-run-1",
|
||||
sinceUnixMs,
|
||||
maxInlineBytes: 0,
|
||||
@ -528,7 +372,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: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
|
||||
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(root, "assets", "images"), { recursive: true });
|
||||
@ -539,7 +383,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft-article",
|
||||
sessionKey: "draft-article",
|
||||
runId: "openclaw-run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
expectedArtifactDirs: ["assets/images", "reports"],
|
||||
@ -559,7 +403,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: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
|
||||
params: { sessionKey: "draft-article", runId: "openclaw-run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
||||
@ -569,7 +413,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft-article",
|
||||
sessionKey: "draft-article",
|
||||
runId: "openclaw-run-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
expectedArtifactDirs: ["reports"],
|
||||
@ -584,7 +428,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "old-root.md"), "old");
|
||||
@ -592,7 +436,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
sinceUnixMs: stat.mtimeMs + 10_000,
|
||||
},
|
||||
@ -606,18 +450,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
await expect(
|
||||
exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactScope: first.artifactScope,
|
||||
},
|
||||
@ -629,11 +473,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const otherTask = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
|
||||
@ -644,7 +488,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
sinceUnixMs: stat.mtimeMs + 10_000,
|
||||
@ -661,11 +505,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: { openclawSessionKey: "thread-main", runId: "turn-previous" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-previous" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-follow-up" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-follow-up" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
|
||||
@ -673,7 +517,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-follow-up",
|
||||
sinceUnixMs: Date.now() + 10_000,
|
||||
},
|
||||
@ -690,7 +534,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft:1779524982823421-3",
|
||||
sessionKey: "draft:1779524982823421-3",
|
||||
runId: "turn-1779685283403237342",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -717,7 +561,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft:1779524982823421-3",
|
||||
sessionKey: "draft:1779524982823421-3",
|
||||
runId: "turn-1779685283403237342",
|
||||
sinceUnixMs: Date.now() + 10_000,
|
||||
},
|
||||
@ -750,7 +594,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft:1779524982823421-3",
|
||||
sessionKey: "draft:1779524982823421-3",
|
||||
runId: "turn-1779685283403237342",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -761,7 +605,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "draft:1779524982823421-3",
|
||||
sessionKey: "draft:1779524982823421-3",
|
||||
runId: "turn-1779685283403237342",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -786,15 +630,15 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await Promise.all([
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-a", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-b", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-a", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
]);
|
||||
@ -804,15 +648,15 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const results = await Promise.all([
|
||||
exportXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-a", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
exportXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-b", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
exportXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
|
||||
params: { sessionKey: "thread-a", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
]);
|
||||
@ -828,14 +672,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "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: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
maxInlineBytes: 2,
|
||||
},
|
||||
@ -851,14 +695,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "small.txt"), "small");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
maxInlineBytes: 0,
|
||||
},
|
||||
@ -874,7 +718,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "a.txt"), "a");
|
||||
@ -882,7 +726,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
maxFiles: 1,
|
||||
},
|
||||
@ -893,6 +737,32 @@ 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 });
|
||||
@ -901,7 +771,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
relativePath: "reports/final.txt",
|
||||
},
|
||||
@ -913,7 +783,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
||||
@ -921,7 +791,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
const result = await readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "reports/final.txt",
|
||||
@ -944,7 +814,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(first.artifactDirectory, "first.txt"), "first");
|
||||
@ -952,7 +822,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactScope: first.artifactScope,
|
||||
relativePath: "first.txt",
|
||||
@ -965,13 +835,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "first.txt"), "first");
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
},
|
||||
@ -981,7 +851,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-other",
|
||||
sessionKey: "thread-other",
|
||||
runId: "turn-1",
|
||||
artifactRef: exported.artifacts[0]?.artifactRef,
|
||||
},
|
||||
@ -993,14 +863,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
|
||||
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
},
|
||||
@ -1010,7 +880,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactRef: exported.artifacts[0]?.artifactRef,
|
||||
},
|
||||
@ -1022,13 +892,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: { openclawSessionKey: "thread-main", runId: "run-1" },
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
@ -1039,7 +909,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactRef: tampered,
|
||||
},
|
||||
@ -1068,7 +938,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactRef: legacyRef,
|
||||
},
|
||||
@ -1080,14 +950,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "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: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "large.bin",
|
||||
@ -1111,14 +981,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: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "../outside.txt",
|
||||
@ -1134,7 +1004,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactScope: "../outside",
|
||||
relativePath: "secret.txt",
|
||||
@ -1150,7 +1020,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const outsideFile = path.join(outsideRoot, "secret.txt");
|
||||
await fs.writeFile(outsideFile, "secret");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt"));
|
||||
@ -1158,7 +1028,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
openclawSessionKey: "thread-main",
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "linked-secret.txt",
|
||||
|
||||
@ -2,7 +2,6 @@ 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;
|
||||
@ -21,7 +20,7 @@ const SKIPPED_DIRS = new Set([
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
type XWorkmateArtifact = {
|
||||
export type XWorkmateArtifact = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
contentType: string;
|
||||
@ -34,9 +33,9 @@ type XWorkmateArtifact = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type XWorkmateArtifactScopeKind = "task";
|
||||
export type XWorkmateArtifactScopeKind = "task";
|
||||
|
||||
type XWorkmateArtifactExport = {
|
||||
export type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
@ -47,12 +46,9 @@ type XWorkmateArtifactExport = {
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
constraintSatisfied: boolean;
|
||||
missingRequiredExtensions: string[];
|
||||
missingRequiredFileCounts: Record<string, { expected: number; actual: number }>;
|
||||
};
|
||||
|
||||
type XWorkmateArtifactPrepare = {
|
||||
export type XWorkmateArtifactPrepare = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
@ -66,12 +62,12 @@ type XWorkmateArtifactPrepare = {
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
};
|
||||
|
||||
type XWorkmateExpectedArtifactDirStatus = {
|
||||
export type XWorkmateExpectedArtifactDirStatus = {
|
||||
relativePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
type XWorkmateArtifactSnapshot = {
|
||||
export type XWorkmateArtifactSnapshot = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
remoteWorkingDirectory: string;
|
||||
@ -121,7 +117,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, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
@ -158,7 +154,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, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
@ -228,7 +224,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, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
|
||||
const maxInlineBytes = nonNegativeInteger(
|
||||
@ -238,8 +234,6 @@ 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,
|
||||
@ -267,11 +261,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
try {
|
||||
const scopeStat = await fs.stat(scopeRoot);
|
||||
effectiveSince = Math.min(sinceUnixMs, scopeStat.birthtimeMs || scopeStat.mtimeMs);
|
||||
} catch (error) {
|
||||
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
@ -307,11 +298,6 @@ 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;
|
||||
}
|
||||
@ -362,8 +348,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
}
|
||||
artifacts.push(artifact);
|
||||
}
|
||||
const missingRequiredExtensions = missingRequiredArtifactExtensions(artifacts, requiredArtifactExtensions);
|
||||
const missingRequiredFileCounts = missingRequiredArtifactFileCounts(artifacts, expectedFileCountByExtension);
|
||||
|
||||
const result = {
|
||||
runId,
|
||||
@ -376,9 +360,6 @@ 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;
|
||||
}
|
||||
@ -387,7 +368,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, "openclawSessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
@ -490,91 +471,28 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
warnings,
|
||||
expectedArtifactDirs: [],
|
||||
expectedArtifactDirStatus: [],
|
||||
constraintSatisfied: true,
|
||||
missingRequiredExtensions: [],
|
||||
missingRequiredFileCounts: {},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeRequiredExtensions(value: unknown): string[] {
|
||||
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 = optionalString(entry)
|
||||
.toLowerCase()
|
||||
.replace(/^\.+/u, "");
|
||||
if (!normalized || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
|
||||
const normalized = safeInputRelativePath(entry, "expectedArtifactDir");
|
||||
const withSlash = normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
if (seen.has(withSlash)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
seen.add(withSlash);
|
||||
result.push(withSlash);
|
||||
}
|
||||
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[],
|
||||
@ -1014,7 +932,38 @@ function resolveWorkspaceDir(input: {
|
||||
if (explicit) {
|
||||
return expandUserPath(explicit);
|
||||
}
|
||||
return expandUserPath(path.join("~", ".openclaw", "workspace"));
|
||||
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 "";
|
||||
}
|
||||
|
||||
function safeRelativePath(root: string, target: string): string {
|
||||
@ -1082,6 +1031,7 @@ function contentTypeForPath(relativePath: string): string {
|
||||
}
|
||||
|
||||
function openClawSnapshotSources(params: Record<string, unknown>, pluginConfig: Record<string, unknown>): SnapshotSource[] {
|
||||
|
||||
return [
|
||||
{
|
||||
label: "media",
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
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,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
readXWorkmateSessionMapping,
|
||||
} from "./taskState.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
|
||||
function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) {
|
||||
function createApiFixture(tasks: Record<string, unknown> = {}) {
|
||||
const sessions = new Map<string, any>();
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig,
|
||||
pluginConfig: {},
|
||||
logger: { warn: () => {} },
|
||||
runtime: {
|
||||
agent: {
|
||||
@ -29,17 +25,12 @@ function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Rec
|
||||
})),
|
||||
patchSessionEntry: async ({
|
||||
sessionKey,
|
||||
fallbackEntry,
|
||||
update,
|
||||
}: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: any;
|
||||
update: (entry: any) => Partial<any> | null;
|
||||
}) => {
|
||||
const current = sessions.get(sessionKey) ?? fallbackEntry;
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const current = sessions.get(sessionKey) ?? { sessionId: sessionKey, updatedAt: 0 };
|
||||
const patch = update(current);
|
||||
if (patch) {
|
||||
sessions.set(sessionKey, { ...current, ...patch });
|
||||
@ -62,24 +53,15 @@ function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Rec
|
||||
return { api: api as any, sessions };
|
||||
}
|
||||
|
||||
async function createWorkspaceFixture() {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), "xworkmate-task-state-"));
|
||||
}
|
||||
|
||||
describe("xworkmate task state mapping", () => {
|
||||
it("requires typed appThreadKey metadata", async () => {
|
||||
const { api } = createApiFixture();
|
||||
|
||||
await expect(
|
||||
recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
sessionKey: "draft:legacy",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
},
|
||||
it("requires typed appThreadKey metadata", () => {
|
||||
expect(() =>
|
||||
normalizeXWorkmateTaskMetadataV1({
|
||||
schemaVersion: 1,
|
||||
sessionKey: "draft:legacy",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
}),
|
||||
).rejects.toThrow("appThreadKey required");
|
||||
).toThrow("appThreadKey required");
|
||||
});
|
||||
|
||||
it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => {
|
||||
@ -172,60 +154,8 @@ describe("xworkmate task state mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
it("returns no_native_task_record instead of inferring success from artifacts", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
@ -254,95 +184,6 @@ 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": {
|
||||
@ -368,13 +209,7 @@ describe("xworkmate task state mapping", () => {
|
||||
});
|
||||
|
||||
it("can read mapping by appThreadKey from pluginExtensions", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:lookup:run-1": {
|
||||
taskId: "task-1",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
@ -385,17 +220,7 @@ describe("xworkmate task state mapping", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:lookup",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
await expect(readXWorkmateSessionMapping(api, { appThreadKey: "draft:lookup" })).resolves.toMatchObject({
|
||||
appThreadKey: "draft:lookup",
|
||||
openclawSessionKey: "draft:lookup",
|
||||
});
|
||||
|
||||
340
src/taskState.ts
340
src/taskState.ts
@ -1,11 +1,8 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_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;
|
||||
@ -29,6 +26,7 @@ export type XWorkmateSessionMappingV1 = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
legacyDerived?: boolean;
|
||||
};
|
||||
|
||||
export type XWorkmateTaskLookupErrorCode =
|
||||
@ -46,17 +44,7 @@ export type XWorkmateTaskLookupError = {
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
|
||||
export type XWorkmateRecordedTaskRunV1 = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
success: boolean;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
export type XWorkmateTaskStore = Record<string, never>;
|
||||
|
||||
type SessionEntry = Record<string, unknown> & {
|
||||
pluginExtensions?: Record<string, Record<string, unknown>>;
|
||||
@ -64,7 +52,6 @@ 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;
|
||||
@ -78,6 +65,10 @@ 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;
|
||||
@ -95,8 +86,13 @@ 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;
|
||||
@ -116,44 +112,7 @@ export async function recordXWorkmateSessionMapping(input: {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
export 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) {
|
||||
@ -172,12 +131,36 @@ function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWork
|
||||
}) as XWorkmateTaskMetadataV1;
|
||||
}
|
||||
|
||||
async function upsertXWorkmateSessionMapping(
|
||||
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(
|
||||
api: OpenClawPluginApi,
|
||||
input: {
|
||||
metadata: XWorkmateTaskMetadataV1;
|
||||
openclawSessionKey: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
legacyDerived?: boolean;
|
||||
},
|
||||
): Promise<XWorkmateSessionMappingV1> {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
@ -189,10 +172,6 @@ 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);
|
||||
@ -213,6 +192,7 @@ async function upsertXWorkmateSessionMapping(
|
||||
createdAt: input.metadata.createdAt || now,
|
||||
updatedAt: now,
|
||||
source: input.source,
|
||||
legacyDerived: input.legacyDerived === true ? true : undefined,
|
||||
}) as XWorkmateSessionMappingV1;
|
||||
}
|
||||
return {
|
||||
@ -227,7 +207,7 @@ async function upsertXWorkmateSessionMapping(
|
||||
return mapping;
|
||||
}
|
||||
|
||||
async function readXWorkmateSessionMapping(
|
||||
export async function readXWorkmateSessionMapping(
|
||||
api: OpenClawPluginApi,
|
||||
lookup: {
|
||||
appThreadKey?: string;
|
||||
@ -258,6 +238,7 @@ async function readXWorkmateSessionMapping(
|
||||
|
||||
export async function getXWorkmateTaskSnapshot(input: {
|
||||
api: OpenClawPluginApi;
|
||||
taskStore?: XWorkmateTaskStore;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const params = input.params ?? {};
|
||||
@ -282,100 +263,25 @@ 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 exportArtifactsForTaskLookup(
|
||||
input,
|
||||
params,
|
||||
openclawSessionKey,
|
||||
runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
|
||||
mapping,
|
||||
)
|
||||
? 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,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@ -395,150 +301,11 @@ 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 },
|
||||
@ -593,6 +360,7 @@ 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user