Compare commits
9 Commits
runtime-la
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e705c69ba8 | |||
| 8f0a15d906 | |||
|
|
849972ab5c | ||
|
|
d396760a4e | ||
|
|
6ec2c10924 | ||
|
|
d5f0e9f437 | ||
|
|
baddb2f13d | ||
|
|
1fe544c984 | ||
| 48c05db842 |
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
@ -1,38 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Verify npm package contents
|
||||
run: pnpm pack:check
|
||||
@ -1,25 +1,24 @@
|
||||
name: Deploy
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
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_run:
|
||||
workflows:
|
||||
- Publish
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Plugin version to install (e.g. 2026.6.1). Leave blank to use the release tag."
|
||||
description: "Plugin version to install (e.g. 2026.6.1). Leave blank to use package.json."
|
||||
required: false
|
||||
default: ""
|
||||
force:
|
||||
@ -31,27 +30,150 @@ on:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
|
||||
concurrency:
|
||||
group: openclaw-deploy
|
||||
cancel-in-progress: false
|
||||
group: pipeline-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
install-on-host:
|
||||
name: Update plugin on ubuntu@openclaw.svc.plus
|
||||
# ───────────────────────── Stage 1: build ─────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'release')
|
||||
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
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
|
||||
|
||||
- name: Load Vault secrets
|
||||
id: vault
|
||||
@ -69,19 +191,17 @@ jobs:
|
||||
|
||||
- name: Resolve target version
|
||||
id: version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PUBLISH_VERSION: ${{ needs.publish.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${{ inputs.version }}" ]; then
|
||||
value="${{ inputs.version }}"
|
||||
elif [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
if [ ! -f package.json ]; then
|
||||
echo "::error::package.json not found after checking out workflow_run source"
|
||||
exit 1
|
||||
fi
|
||||
value="$(node -p "require('./package.json').version")"
|
||||
if [ -n "${INPUT_VERSION}" ]; then
|
||||
value="${INPUT_VERSION}"
|
||||
elif [ -n "${PUBLISH_VERSION}" ]; then
|
||||
value="${PUBLISH_VERSION}"
|
||||
else
|
||||
ref="${GITHUB_REF_NAME:-}"
|
||||
value="${ref}"
|
||||
value="$(node -p "require('./package.json').version")"
|
||||
fi
|
||||
value="${value##*/}"
|
||||
value="${value#v}"
|
||||
@ -89,10 +209,6 @@ jobs:
|
||||
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}"
|
||||
|
||||
@ -136,8 +252,6 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
pnpm pack
|
||||
tarball="${PLUGIN_NAME}-${VERSION}.tgz"
|
||||
test -f "${tarball}"
|
||||
91
.github/workflows/publish.yml
vendored
91
.github/workflows/publish.yml
vendored
@ -1,91 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
env:
|
||||
VAULT_ADDR: https://vault.svc.plus
|
||||
|
||||
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: 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: 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: ${{ steps.vault.outputs.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: ${{ steps.vault.outputs.NPM_TOKEN }}
|
||||
53
.github/workflows/runtime-release.yaml
vendored
53
.github/workflows/runtime-release.yaml
vendored
@ -6,7 +6,7 @@ on:
|
||||
paths:
|
||||
- "**/*.ts"
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- package-lock.json
|
||||
- openclaw.plugin.json
|
||||
- .github/workflows/runtime-release.yaml
|
||||
workflow_dispatch:
|
||||
@ -25,31 +25,26 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: pnpm 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/assets
|
||||
mkdir -p "${root}/dist" dist/assets
|
||||
|
||||
# Maintain necessary file structure required by openclaw loader
|
||||
cp -a dist "${root}/"
|
||||
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" \
|
||||
@ -83,16 +78,30 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="runtime-${GITHUB_SHA::12}"
|
||||
cat dist/SHA256SUMS-* | sort -u > dist/SHA256SUMS || true
|
||||
rm -f dist/SHA256SUMS-*
|
||||
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}" \
|
||||
--title "OpenClaw Plugin runtime ${GITHUB_SHA::12}" \
|
||||
--notes "Prebuilt Plugin assets. No target-host build or Nix profile installation required."
|
||||
fi
|
||||
|
||||
# Publish (or refresh) a release with the runtime tarball + checksums.
|
||||
# --latest=false keeps GitHub's "Latest release" pointer free for the
|
||||
# human-facing v* tags; deployments pull via explicit tag URLs instead.
|
||||
publish_release() {
|
||||
local tag="$1" title="$2"
|
||||
if gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
|
||||
gh release upload "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
else
|
||||
gh release create "${tag}" dist/*.tar.gz dist/SHA256SUMS \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--target "${GITHUB_SHA}" \
|
||||
--latest=false \
|
||||
--title "${title}" \
|
||||
--notes "Prebuilt Plugin assets. No target-host build or Nix profile installation required."
|
||||
fi
|
||||
}
|
||||
|
||||
# Immutable per-commit release for traceability.
|
||||
publish_release "runtime-${GITHUB_SHA::12}" "OpenClaw Plugin runtime ${GITHUB_SHA::12}"
|
||||
# Stable moving release so deployments resolve a deterministic URL
|
||||
# (releases/download/runtime-latest/...) instead of the mutable
|
||||
# /releases/latest/ pointer, which collides with other release tracks.
|
||||
publish_release "runtime-latest" "OpenClaw Plugin runtime (latest)"
|
||||
|
||||
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
44
.github/workflows/validate-release-pr.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Validate Release PR
|
||||
|
||||
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
|
||||
# 详见 iac_modules/docs/tldr-github-branch-model.md
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-release-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.base_ref, 'release/')
|
||||
steps:
|
||||
- name: Check PR source branch
|
||||
run: |
|
||||
SRC="${{ github.head_ref }}"
|
||||
TGT="${{ github.base_ref }}"
|
||||
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
echo "🔍 Validating PR into release branch"
|
||||
echo " source: $SRC"
|
||||
echo " target: $TGT"
|
||||
echo " labels: $LABELS"
|
||||
|
||||
if [[ "$SRC" =~ ^hotfix/ ]]; then
|
||||
echo "✅ Allowed: hotfix/* branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
|
||||
echo "✅ Allowed: cherry-pick/backport labeled PR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "❌ Rejected."
|
||||
echo "release/* 仅接受:"
|
||||
echo " - 来自 hotfix/* 的 PR"
|
||||
echo " - 带 cherry-pick 或 backport 标签的 PR(已验证 feature 的 backport/cherry-pick)"
|
||||
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
|
||||
exit 1
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -3,3 +3,9 @@ coverage/
|
||||
*.tsbuildinfo
|
||||
repomix-output.xml
|
||||
openclaw-multi-session-plugins-*.tgz
|
||||
|
||||
# Runtime release build artifacts (produced by the runtime-release workflow /
|
||||
# local packaging; published to GitHub Releases, not committed). The compiled
|
||||
# library under dist/ and dist/src/ stays tracked.
|
||||
dist/assets/
|
||||
dist/runtime/
|
||||
|
||||
1
dist/index.d.ts
vendored
1
dist/index.d.ts
vendored
@ -1,3 +1,4 @@
|
||||
export declare function lastAssistantText(messages: unknown): string | undefined;
|
||||
declare const plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
58
dist/index.js
vendored
58
dist/index.js
vendored
@ -1,7 +1,7 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
|
||||
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, recordXWorkmateTaskRunStarted, recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||
function scopedGatewayParams(params) {
|
||||
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
||||
const runScope = resolveRunScope({ sessionScope });
|
||||
@ -33,6 +33,37 @@ function resolveRunScope(ctx) {
|
||||
function stringParam(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
export function lastAssistantText(messages) {
|
||||
if (!Array.isArray(messages))
|
||||
return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object")
|
||||
continue;
|
||||
const record = message;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant")
|
||||
continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim())
|
||||
return content.trim();
|
||||
if (!Array.isArray(content))
|
||||
continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object")
|
||||
return "";
|
||||
const item = block;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text)
|
||||
return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const plugin = definePluginEntry({
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
@ -65,6 +96,26 @@ function register(api) {
|
||||
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);
|
||||
@ -82,6 +133,11 @@ function register(api) {
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(true, {
|
||||
...payload,
|
||||
mapping,
|
||||
|
||||
25
dist/src/taskState.d.ts
vendored
25
dist/src/taskState.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
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;
|
||||
@ -27,6 +28,17 @@ export type XWorkmateTaskLookupError = {
|
||||
mapping?: XWorkmateSessionMappingV1;
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
export type XWorkmateRecordedTaskRunV1 = {
|
||||
schemaVersion: 1;
|
||||
runId: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
success: boolean;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
|
||||
export declare function recordXWorkmateSessionMapping(input: {
|
||||
api: OpenClawPluginApi;
|
||||
@ -34,6 +46,19 @@ export declare function recordXWorkmateSessionMapping(input: {
|
||||
artifactScope?: string;
|
||||
source?: XWorkmateSessionMappingSource;
|
||||
}): Promise<XWorkmateSessionMappingV1>;
|
||||
export declare function recordXWorkmateTaskRunStarted(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1>;
|
||||
export declare function recordXWorkmateTaskRunTerminal(input: {
|
||||
api: OpenClawPluginApi;
|
||||
openclawSessionKey: string;
|
||||
runId: string;
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}): Promise<XWorkmateRecordedTaskRunV1>;
|
||||
export declare function getXWorkmateTaskSnapshot(input: {
|
||||
api: OpenClawPluginApi;
|
||||
params: Record<string, unknown>;
|
||||
|
||||
169
dist/src/taskState.js
vendored
169
dist/src/taskState.js
vendored
@ -2,6 +2,8 @@ import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
const MAX_RECORDED_TASK_RUNS = 32;
|
||||
export function registerXWorkmateSessionExtension(api) {
|
||||
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
|
||||
if (typeof registerExtension !== "function") {
|
||||
@ -29,6 +31,30 @@ export async function recordXWorkmateSessionMapping(input) {
|
||||
source: input.source ?? "bridge_prepare",
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateTaskRunStarted(input) {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: "running",
|
||||
success: false,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
export async function recordXWorkmateTaskRunTerminal(input) {
|
||||
const now = new Date().toISOString();
|
||||
return upsertXWorkmateTaskRun(input.api, {
|
||||
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
|
||||
runId: requiredString(input.runId, "runId required"),
|
||||
status: input.success ? "completed" : "failed",
|
||||
success: input.success,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
output: sanitizeTaskRunOutput(input.output),
|
||||
error: sanitizeTaskRunError(input.error),
|
||||
});
|
||||
}
|
||||
function normalizeXWorkmateTaskMetadataV1(input) {
|
||||
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
|
||||
const schemaVersion = Number(envelope.schemaVersion ?? 1);
|
||||
@ -139,9 +165,52 @@ export async function getXWorkmateTaskSnapshot(input) {
|
||||
});
|
||||
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,
|
||||
@ -205,6 +274,106 @@ export async function getXWorkmateTaskSnapshot(input) {
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
async function upsertXWorkmateTaskRun(api, input) {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
let recorded;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: input.openclawSessionKey,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const runs = readTaskRunsFromEntry(entry);
|
||||
const existing = runs[input.runId];
|
||||
recorded = compactObject({
|
||||
schemaVersion: 1,
|
||||
runId: input.runId,
|
||||
status: input.status,
|
||||
success: input.success,
|
||||
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
|
||||
updatedAt: input.updatedAt,
|
||||
completedAt: input.completedAt,
|
||||
output: input.output,
|
||||
error: input.error,
|
||||
});
|
||||
runs[input.runId] = recorded;
|
||||
const boundedRuns = Object.fromEntries(Object.entries(runs)
|
||||
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
|
||||
.slice(0, MAX_RECORDED_TASK_RUNS));
|
||||
return {
|
||||
pluginExtensions: {
|
||||
...(entry.pluginExtensions ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
|
||||
schemaVersion: 1,
|
||||
runs: boundedRuns,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!recorded) {
|
||||
throw new Error("failed to write xworkmate task run state");
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
function readXWorkmateTaskRun(api, openclawSessionKey, runId) {
|
||||
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
|
||||
return readTaskRunsFromEntry(entry)[runId];
|
||||
}
|
||||
function readTaskRunsFromEntry(entry) {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
|
||||
if (store?.schemaVersion !== 1) {
|
||||
return {};
|
||||
}
|
||||
const runs = asRecord(store.runs) ?? {};
|
||||
const result = {};
|
||||
for (const [key, rawValue] of Object.entries(runs)) {
|
||||
const raw = asRecord(rawValue);
|
||||
const runId = optionalString(raw?.runId) || key;
|
||||
const status = optionalString(raw?.status);
|
||||
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
|
||||
continue;
|
||||
}
|
||||
result[runId] = compactObject({
|
||||
schemaVersion: 1,
|
||||
runId,
|
||||
status,
|
||||
success: raw?.success === true,
|
||||
startedAt: optionalString(raw?.startedAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw?.updatedAt) || new Date(0).toISOString(),
|
||||
completedAt: optionalString(raw?.completedAt),
|
||||
output: optionalString(raw?.output),
|
||||
error: optionalString(raw?.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sanitizeTaskRunOutput(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw.slice(0, 16 * 1024);
|
||||
}
|
||||
function sanitizeTaskRunError(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw
|
||||
.replace(/\b(sk|nvapi)-[A-Za-z0-9._-]+\b/gi, "$1-<redacted>")
|
||||
.replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1<redacted>")
|
||||
.slice(0, 2048);
|
||||
}
|
||||
async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) {
|
||||
return exportXWorkmateArtifacts({
|
||||
params: {
|
||||
|
||||
@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import plugin, { lastAssistantText } from "./index.js";
|
||||
import { prepareXWorkmateArtifacts } from "./src/exportArtifacts.js";
|
||||
|
||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
@ -14,6 +14,12 @@ type GatewayMethodResponse = {
|
||||
};
|
||||
|
||||
describe("plugin registration", () => {
|
||||
it("extracts only the final assistant display text", () => {
|
||||
expect(lastAssistantText([
|
||||
{ role: "user", content: "secret prompt" },
|
||||
{ role: "assistant", content: [{ type: "tool_call", text: "ignored" }, { type: "text", text: "完成并已保存。" }] },
|
||||
])).toBe("完成并已保存。");
|
||||
});
|
||||
it("declares registered agent tools in the manifest contract", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
|
||||
contracts?: { tools?: string[]; sessionScopedTools?: string[] };
|
||||
@ -42,6 +48,7 @@ describe("plugin registration", () => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
@ -73,6 +80,7 @@ describe("plugin registration", () => {
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
@ -139,7 +147,7 @@ describe("plugin registration", () => {
|
||||
it("registers xworkmate task state against the native session extension and task runtime seams", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-task-state-"));
|
||||
const methods = new Map<string, GatewayMethodHandler>();
|
||||
const hooks = new Map<string, (event: unknown) => Promise<void>>();
|
||||
const hooks = new Map<string, (event: unknown, ctx?: unknown) => Promise<void>>();
|
||||
const sessionExtensions: Array<Record<string, unknown>> = [];
|
||||
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
|
||||
const detachedRuntimes: Array<Record<string, unknown>> = [];
|
||||
@ -197,7 +205,10 @@ describe("plugin registration", () => {
|
||||
methods.set(method, handler);
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
|
||||
registerHook: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
on: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
|
||||
@ -257,6 +268,15 @@ describe("plugin registration", () => {
|
||||
});
|
||||
expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" });
|
||||
expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]);
|
||||
|
||||
await hooks.get("agent_end")?.(
|
||||
{ runId: "turn-1", success: false, error: "401 authentication failed", messages: [{ role: "assistant", content: [{ type: "text", text: "上游认证失败。" }] }] },
|
||||
{ sessionKey: "draft:1780636411666238-3", runId: "turn-1" },
|
||||
);
|
||||
expect(sessionExtensionPatches.at(-1)).toMatchObject({
|
||||
sessionKey: "draft:1780636411666238-3",
|
||||
preserveActivity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent default session or run ids for the optional agent tool", async () => {
|
||||
@ -266,6 +286,7 @@ describe("plugin registration", () => {
|
||||
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
@ -295,6 +316,7 @@ describe("plugin registration", () => {
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: { names?: string[] }) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
@ -325,6 +347,7 @@ describe("plugin registration", () => {
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
on: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
|
||||
55
index.ts
55
index.ts
@ -15,6 +15,8 @@ import {
|
||||
import {
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
registerXWorkmateSessionExtension,
|
||||
} from "./src/taskState.js";
|
||||
|
||||
@ -84,6 +86,31 @@ function stringParam(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function lastAssistantText(messages: unknown): string | undefined {
|
||||
if (!Array.isArray(messages)) return undefined;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const record = message as Record<string, unknown>;
|
||||
if (stringParam(record.role).toLowerCase() !== "assistant") continue;
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim()) return content.trim();
|
||||
if (!Array.isArray(content)) continue;
|
||||
const text = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const item = block as Record<string, unknown>;
|
||||
const type = stringParam(item.type).toLowerCase();
|
||||
return type === "text" || type === "output_text" ? stringParam(item.text) : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const plugin = definePluginEntry({
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
@ -123,6 +150,29 @@ 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);
|
||||
@ -140,6 +190,11 @@ function register(api: OpenClawPluginApi) {
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
runId: stringParam(params.runId),
|
||||
});
|
||||
opts.respond(
|
||||
true,
|
||||
{
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
recordXWorkmateTaskRunStarted,
|
||||
recordXWorkmateTaskRunTerminal,
|
||||
} from "./taskState.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
@ -252,6 +254,95 @@ describe("xworkmate task state mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a durable failed agent terminal state when the native task record is absent", async () => {
|
||||
const workspaceDir = await createWorkspaceFixture();
|
||||
const { api } = createApiFixture({}, { workspaceDir });
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:failed-run",
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
});
|
||||
await recordXWorkmateTaskRunTerminal({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
success: false,
|
||||
output: "任务执行失败前的说明",
|
||||
error: "401 Authentication Fails, api_key=sk-secret-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:failed-run",
|
||||
runId: "turn-failed",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: "failed",
|
||||
taskStatus: "failed",
|
||||
terminal: true,
|
||||
terminalSource: "agent_end",
|
||||
output: "任务执行失败前的说明",
|
||||
resultSummary: "任务执行失败前的说明",
|
||||
message: "任务执行失败前的说明",
|
||||
task: {
|
||||
runId: "turn-failed",
|
||||
status: "failed",
|
||||
source: "xworkmate_run_state",
|
||||
},
|
||||
error: "401 Authentication Fails, api_key=<redacted>",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a recorded running state while the agent turn is still active", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:running-run",
|
||||
openclawSessionKey: "agent:main:draft:running-run",
|
||||
runId: "turn-running",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateTaskRunStarted({
|
||||
api,
|
||||
openclawSessionKey: "agent:main:draft:running-run",
|
||||
runId: "turn-running",
|
||||
});
|
||||
|
||||
await expect(
|
||||
getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:running-run",
|
||||
runId: "turn-running",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: "running",
|
||||
terminal: false,
|
||||
terminalSource: "session_prepare",
|
||||
task: {
|
||||
runId: "turn-running",
|
||||
status: "running",
|
||||
source: "xworkmate_run_state",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept legacy sessionKey as a task lookup alias", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:legacy:run-1": {
|
||||
|
||||
211
src/taskState.ts
211
src/taskState.ts
@ -4,6 +4,8 @@ import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
||||
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
|
||||
const MAX_RECORDED_TASK_RUNS = 32;
|
||||
|
||||
export type XWorkmateTaskMetadataV1 = {
|
||||
schemaVersion: 1;
|
||||
@ -44,6 +46,18 @@ 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;
|
||||
};
|
||||
|
||||
type SessionEntry = Record<string, unknown> & {
|
||||
pluginExtensions?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
@ -102,6 +116,43 @@ 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 {
|
||||
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
|
||||
const schemaVersion = Number(envelope.schemaVersion ?? 1);
|
||||
@ -233,9 +284,52 @@ export async function getXWorkmateTaskSnapshot(input: {
|
||||
});
|
||||
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,
|
||||
@ -308,6 +402,123 @@ export async function getXWorkmateTaskSnapshot(input: {
|
||||
};
|
||||
}
|
||||
|
||||
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>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user