refactor: optimize plugin architecture using OpenClaw 2026.6.1 native capabilities
- Resolve critical task-synchronization and configuration-passing issues - Leverage task-registry, session.store, and refined gateway routing - Eliminate redundant manual management and enforce a single source of truth for task states - Ensure proper propagation of expectedArtifactDirs
This commit is contained in:
parent
1448a4c421
commit
9d37a79960
187
.github/workflows/deploy.yml
vendored
Normal file
187
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,187 @@
|
||||
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#v}"
|
||||
fi
|
||||
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: Verify version is published to npm
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
if ! npm view "${PACKAGE}" version >/dev/null 2>&1; then
|
||||
echo "::error::${PACKAGE} is not published to npm yet. Run the Publish workflow first."
|
||||
exit 1
|
||||
fi
|
||||
PUBLISHED="$(npm view "${PACKAGE}" version)"
|
||||
echo "::notice::${PLUGIN_NAME}@${PUBLISHED} is available on npm"
|
||||
|
||||
- name: Configure SSH key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${{ secrets.OPENCLAW_SSH_KEY }}" ]; then
|
||||
echo "::error::Secret OPENCLAW_SSH_KEY is not set."
|
||||
exit 1
|
||||
fi
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s' "${{ secrets.OPENCLAW_SSH_KEY }}" > ~/.ssh/openclaw_ed25519
|
||||
chmod 600 ~/.ssh/openclaw_ed25519
|
||||
ssh-keyscan -H openclaw.svc.plus >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Verify SSH connectivity
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
"${SSH_HOST}" 'echo "connected to $(hostname) as $(whoami)"'
|
||||
|
||||
- name: Install or update plugin on remote host
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
FORCE: ${{ inputs.force || 'false' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/openclaw_ed25519 -o BatchMode=yes -o ServerAliveInterval=30 \
|
||||
"${SSH_HOST}" bash -s -- "${VERSION}" "${FORCE}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
VERSION="$1"
|
||||
FORCE="$2"
|
||||
PACKAGE="${PLUGIN_NAME}@${VERSION}"
|
||||
STATE_DIR="/tmp/openclaw-deploy"
|
||||
mkdir -p "${STATE_DIR}"
|
||||
|
||||
echo "==> Installing ${PACKAGE} on $(hostname) (force=${FORCE})"
|
||||
|
||||
# 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 "${PACKAGE}" \
|
||||
|| openclaw plugins update "${PACKAGE}" \
|
||||
|| npm install -g "${PACKAGE}"
|
||||
else
|
||||
npm install -g "${PACKAGE}"
|
||||
fi
|
||||
}
|
||||
|
||||
install_plugin
|
||||
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins enable "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
|
||||
# Verify the installed version matches the requested version.
|
||||
INSTALLED="$(npm ls -g "${PLUGIN_NAME}" --depth=0 2>/dev/null \
|
||||
| awk -F'[@:]' '/'"${PLUGIN_NAME}"'@/ {print $2; exit}' || true)"
|
||||
if [ "${INSTALLED}" != "${VERSION}" ]; then
|
||||
echo "::remote-error::Verification failed: expected ${VERSION}, found ${INSTALLED:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap - ERR
|
||||
rm -f "${STATE_DIR}/previous-version"
|
||||
|
||||
echo "==> Installed plugin state:"
|
||||
if command -v openclaw >/dev/null 2>&1; then
|
||||
openclaw plugins info "${PLUGIN_NAME}" || true
|
||||
fi
|
||||
npm ls -g "${PLUGIN_NAME}" || true
|
||||
echo "==> ${PACKAGE} is now active on $(hostname)"
|
||||
REMOTE
|
||||
|
||||
- name: Summarize deploy
|
||||
if: always()
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.value }}
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "::notice::openclaw-multi-session-plugins@${VERSION} deployed to ubuntu@openclaw.svc.plus"
|
||||
else
|
||||
echo "::error::Deploy to ubuntu@openclaw.svc.plus failed for openclaw-multi-session-plugins@${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
20
README.md
20
README.md
@ -16,18 +16,21 @@ execution. This package only adapts those existing OpenClaw task/session
|
||||
identities into isolated artifact directories, session key mapping, and signed
|
||||
artifact reads.
|
||||
|
||||
It registers four Gateway methods:
|
||||
It registers the minimal Gateway methods needed by XWorkmate:
|
||||
|
||||
```text
|
||||
xworkmate.artifacts.prepare
|
||||
xworkmate.session.prepare
|
||||
xworkmate.tasks.get
|
||||
xworkmate.artifacts.collect-and-snapshot
|
||||
xworkmate.artifacts.export
|
||||
xworkmate.artifacts.list
|
||||
xworkmate.artifacts.read
|
||||
```
|
||||
|
||||
`prepare` creates a per-task artifact scope under `tasks/` in the resolved OpenClaw workspace. `export`
|
||||
and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize
|
||||
into the APP `artifacts[]` contract.
|
||||
`xworkmate.session.prepare` writes the durable
|
||||
`SessionEntry.pluginExtensions["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"]`
|
||||
mapping and creates a per-task artifact scope under `tasks/` in the resolved
|
||||
OpenClaw workspace. `export` and `read` then return safe, relative artifact
|
||||
entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract.
|
||||
|
||||
## Install
|
||||
|
||||
@ -185,7 +188,10 @@ local users can open or download them directly from that workspace path.
|
||||
|
||||
Gateway clients can use:
|
||||
|
||||
- `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory.
|
||||
- `xworkmate.session.prepare` before `chat.send` with typed
|
||||
`schemaVersion`, `appThreadKey`, `openclawSessionKey`, `runId`, and
|
||||
`expectedArtifactDirs` to allocate a task artifact directory and persist the
|
||||
app/OpenClaw session mapping.
|
||||
- Keep the prepared `artifactScope`/`artifactDirectory` in the gateway artifact
|
||||
pipeline, not in `chat.send` params. If `chat.send` returns a different
|
||||
OpenClaw `runId`, prepare/export with that actual `runId` instead of the
|
||||
|
||||
@ -33,7 +33,7 @@ describe("plugin registration", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.push({ method, handler });
|
||||
@ -47,8 +47,8 @@ describe("plugin registration", () => {
|
||||
plugin.register(api);
|
||||
|
||||
expect(methods.map((entry) => entry.method)).toEqual([
|
||||
"xworkmate.session.prepare",
|
||||
"xworkmate.tasks.get",
|
||||
"xworkmate.artifacts.prepare",
|
||||
"xworkmate.artifacts.export",
|
||||
"xworkmate.artifacts.collect-and-snapshot",
|
||||
"xworkmate.artifacts.list",
|
||||
@ -66,22 +66,33 @@ describe("plugin registration", () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-gateway-"));
|
||||
const methods = new Map<string, GatewayMethodHandler>();
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.set(method, handler);
|
||||
},
|
||||
registerTool: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
patchSessionEntry: async (params: any) => {
|
||||
params.update({ pluginExtensions: {} });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const prepared = await callGatewayMethod(methods, "xworkmate.artifacts.prepare", {
|
||||
sessionKey: "thread-main",
|
||||
const prepared = await callGatewayMethod(methods, "xworkmate.session.prepare", {
|
||||
appThreadKey: "thread-main",
|
||||
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);
|
||||
|
||||
@ -133,14 +144,26 @@ describe("plugin registration", () => {
|
||||
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
|
||||
const detachedRuntimes: Array<Record<string, unknown>> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
registerSessionExtension: (extension: Record<string, unknown>) => {
|
||||
sessionExtensions.push(extension);
|
||||
},
|
||||
patchSessionEntry: async (patch: any) => {
|
||||
sessionExtensionPatches.push(patch);
|
||||
if (patch.update) patch.update({ pluginExtensions: {} });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: ({ sessionKey }: { sessionKey: string }) => ({
|
||||
resolve: (token: string) =>
|
||||
sessionKey === "agent:main:draft:1780636411666238-3" && token === "turn-1"
|
||||
sessionKey === "draft:1780636411666238-3" && token === "turn-1"
|
||||
? {
|
||||
taskId: "native-task",
|
||||
runtime: "acp",
|
||||
@ -164,12 +187,9 @@ describe("plugin registration", () => {
|
||||
registerSessionExtension: (extension: Record<string, unknown>) => {
|
||||
sessionExtensions.push(extension);
|
||||
},
|
||||
patchSessionExtension: (patch: Record<string, unknown>) => {
|
||||
sessionExtensionPatches.push(patch);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
registerDetachedTaskRuntime: (runtime: Record<string, unknown>) => {
|
||||
detachedRuntimes.push(runtime);
|
||||
},
|
||||
@ -180,28 +200,27 @@ describe("plugin registration", () => {
|
||||
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
|
||||
hooks.set(event, handler);
|
||||
},
|
||||
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(sessionExtensions).toHaveLength(1);
|
||||
expect(sessionExtensions[0]).toMatchObject({
|
||||
namespace: "xworkmate",
|
||||
namespace: "xworkmate.sessionMapping",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
});
|
||||
const projected = (sessionExtensions[0]?.project as (ctx: Record<string, unknown>) => unknown)({
|
||||
sessionKey: "agent:main:draft:1780636411666238-3",
|
||||
sessionKey: "draft:1780636411666238-3",
|
||||
state: {},
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
appSessionKey: "draft:1780636411666238-3",
|
||||
openClawSessionKey: "agent:main:draft:1780636411666238-3",
|
||||
});
|
||||
expect(detachedRuntimes).toHaveLength(1);
|
||||
expect(projected).toMatchObject({});
|
||||
expect(detachedRuntimes).toHaveLength(0);
|
||||
|
||||
await hooks.get("session.start")?.({
|
||||
await hooks.get("session_start")?.({
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
sessionKey: "draft-1780636411666238-3",
|
||||
openClawSessionKey: "agent:main:draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
threadId: "draft-1780636411666238-3",
|
||||
runId: "turn-1",
|
||||
expectedArtifactDirs: ["artifacts/", "reports/", "exports/"],
|
||||
@ -210,21 +229,13 @@ describe("plugin registration", () => {
|
||||
await fs.promises.writeFile(path.join(root, "reports", "final.md"), "final");
|
||||
expect(sessionExtensionPatches).toHaveLength(1);
|
||||
expect(sessionExtensionPatches[0]).toMatchObject({
|
||||
key: "agent:main:draft:1780636411666238-3",
|
||||
pluginId: "openclaw-multi-session-plugins",
|
||||
namespace: "xworkmate",
|
||||
value: {
|
||||
appSessionKey: "draft-1780636411666238-3",
|
||||
openClawSessionKey: "agent:main:draft:1780636411666238-3",
|
||||
appThreadId: "draft-1780636411666238-3",
|
||||
runId: "turn-1",
|
||||
artifactScope: "tasks/draft-1780636411666238-3/turn-1",
|
||||
expectedArtifactDirs: ["artifacts/", "reports/", "exports/"],
|
||||
},
|
||||
sessionKey: "draft:1780636411666238-3",
|
||||
preserveActivity: true,
|
||||
});
|
||||
|
||||
const snapshot = await callGatewayMethod(methods, "xworkmate.tasks.get", {
|
||||
sessionKey: "draft-1780636411666238-3",
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
runId: "turn-1",
|
||||
expectedArtifactDirs: ["reports"],
|
||||
sinceUnixMs: Date.now() - 1_000,
|
||||
@ -232,11 +243,10 @@ describe("plugin registration", () => {
|
||||
|
||||
expect(snapshot.ok).toBe(true);
|
||||
expect(snapshot.payload).toMatchObject({
|
||||
status: "completed",
|
||||
taskStatus: "succeeded",
|
||||
sessionKey: "draft-1780636411666238-3",
|
||||
openClawSessionKey: "agent:main:draft:1780636411666238-3",
|
||||
appSessionKey: "draft-1780636411666238-3",
|
||||
status: "running",
|
||||
taskStatus: "running",
|
||||
appThreadKey: "draft:1780636411666238-3",
|
||||
openclawSessionKey: "draft:1780636411666238-3",
|
||||
artifactCount: 1,
|
||||
});
|
||||
expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" });
|
||||
@ -246,7 +256,7 @@ describe("plugin registration", () => {
|
||||
it("does not invent default session or run ids for the optional agent tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
@ -275,7 +285,7 @@ describe("plugin registration", () => {
|
||||
it("does not expose the removed bridge agents tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
@ -305,7 +315,7 @@ describe("plugin registration", () => {
|
||||
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
config: {}, logger: { warn: console.warn },
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerHook: () => undefined,
|
||||
|
||||
78
index.ts
78
index.ts
@ -12,8 +12,6 @@ import {
|
||||
formatArtifactManifestMarkdown,
|
||||
} from "./src/exportArtifacts.js";
|
||||
import {
|
||||
createOrUpdateXWorkmateTaskRecord,
|
||||
createXWorkmateTaskStore,
|
||||
getXWorkmateTaskSnapshot,
|
||||
recordXWorkmateSessionMapping,
|
||||
registerXWorkmateDetachedTaskRuntime,
|
||||
@ -82,6 +80,10 @@ function resolveRunScope(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
function stringParam(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: "openclaw-multi-session-plugins",
|
||||
name: "openclaw-multi-session-plugins",
|
||||
@ -92,40 +94,75 @@ const plugin = {
|
||||
export default plugin;
|
||||
|
||||
function register(api: OpenClawPluginApi) {
|
||||
const taskStore = createXWorkmateTaskStore();
|
||||
const taskStore = {};
|
||||
registerXWorkmateSessionExtension(api);
|
||||
registerXWorkmateDetachedTaskRuntime(api, taskStore);
|
||||
|
||||
api.registerHook(
|
||||
"session.start",
|
||||
"session_start",
|
||||
async (event: any) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(event?.context ?? event);
|
||||
if (params.sessionKey && params.runId) {
|
||||
createOrUpdateXWorkmateTaskRecord(taskStore, {
|
||||
params,
|
||||
status: "running",
|
||||
progressSummary: "OpenClaw task is running",
|
||||
});
|
||||
const openclawSessionKey = stringParam(params.openclawSessionKey) || stringParam(params.sessionKey);
|
||||
if (openclawSessionKey && params.runId) {
|
||||
const hookParams = { ...params, openclawSessionKey };
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params,
|
||||
params: hookParams,
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
taskStore,
|
||||
params,
|
||||
params: hookParams,
|
||||
artifactScope: prepared.artifactScope,
|
||||
source: "session_start",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`);
|
||||
api.logger?.warn?.(`xworkmate session_start preparation failed: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
{ name: "openclaw-multi-session-plugins.session-start" },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod("xworkmate.session.prepare", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const params = scopedGatewayParams(opts.params);
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
taskStore,
|
||||
params,
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
const payload = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
config: api.config,
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
opts.respond(
|
||||
true,
|
||||
{
|
||||
...payload,
|
||||
mapping,
|
||||
appThreadKey: mapping.appThreadKey,
|
||||
openclawSessionKey: mapping.openclawSessionKey,
|
||||
expectedArtifactDirs: mapping.expectedArtifactDirs,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
opts.respond(false, undefined, {
|
||||
code: String(error).includes("conflict") ? "CONFLICT" : "INVALID_REQUEST",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("xworkmate.tasks.get", async (opts: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await getXWorkmateTaskSnapshot({
|
||||
@ -141,21 +178,6 @@ function register(api: OpenClawPluginApi) {
|
||||
});
|
||||
}
|
||||
});
|
||||
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => {
|
||||
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: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
|
||||
@ -79,6 +79,50 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
||||
});
|
||||
|
||||
it("preserves expected artifact directories even when they do not exist", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-expected",
|
||||
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-expected",
|
||||
artifactScope: prepared.artifactScope,
|
||||
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(prepared.expectedArtifactDirs).toEqual(["artifacts/", "assets/images/"]);
|
||||
expect(result.expectedArtifactDirs).toEqual(["artifacts/", "assets/images/"]);
|
||||
expect(result.expectedArtifactDirStatus).toEqual([
|
||||
{ relativePath: "artifacts/", exists: false },
|
||||
{ relativePath: "assets/images/", exists: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsafe expected artifact directories", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
|
||||
await expect(
|
||||
prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-unsafe",
|
||||
expectedArtifactDirs: ["../outside"],
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("expectedArtifactDir must stay inside the workspace");
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("snapshots OpenClaw media and tmp outputs into the current task artifact scope", async () => {
|
||||
|
||||
@ -44,6 +44,8 @@ export type XWorkmateArtifactExport = {
|
||||
scopeKind: XWorkmateArtifactScopeKind;
|
||||
artifacts: XWorkmateArtifact[];
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
};
|
||||
|
||||
export type XWorkmateArtifactPrepare = {
|
||||
@ -56,6 +58,13 @@ export type XWorkmateArtifactPrepare = {
|
||||
artifactDirectory: string;
|
||||
relativeArtifactDirectory: string;
|
||||
warnings: string[];
|
||||
expectedArtifactDirs: string[];
|
||||
expectedArtifactDirStatus: XWorkmateExpectedArtifactDirStatus[];
|
||||
};
|
||||
|
||||
export type XWorkmateExpectedArtifactDirStatus = {
|
||||
relativePath: string;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export type XWorkmateArtifactSnapshot = {
|
||||
@ -108,7 +117,8 @@ 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.sessionKey, "sessionKey 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);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
@ -124,6 +134,7 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
||||
const artifactScope = expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
const expectedArtifactDirStatus = await expectedArtifactDirStatuses(workspaceRoot, expectedArtifactDirs);
|
||||
return {
|
||||
runId,
|
||||
sessionKey,
|
||||
@ -134,6 +145,8 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
||||
artifactDirectory: scopeRoot,
|
||||
relativeArtifactDirectory: artifactScope,
|
||||
warnings: [],
|
||||
expectedArtifactDirs,
|
||||
expectedArtifactDirStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@ -141,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.sessionKey, "sessionKey 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);
|
||||
@ -211,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.sessionKey, "sessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
|
||||
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
|
||||
const maxInlineBytes = nonNegativeInteger(
|
||||
@ -229,6 +242,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings: string[] = [];
|
||||
const expectedDirs = normalizeExpectedArtifactDirs(params.expectedArtifactDirs);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
@ -260,9 +274,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
})
|
||||
: [];
|
||||
const candidates = scopedCandidates;
|
||||
const expectedDirs = Array.isArray(params.expectedArtifactDirs)
|
||||
? params.expectedArtifactDirs.map((d: any) => 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"));
|
||||
@ -347,6 +358,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
expectedArtifactDirs: expectedDirs,
|
||||
expectedArtifactDirStatus: await expectedArtifactDirStatuses(workspaceRoot, expectedDirs),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@ -355,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.sessionKey, "sessionKey required");
|
||||
const sessionKey = requiredString(params.openclawSessionKey ?? params.sessionKey, "openclawSessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
@ -456,10 +469,45 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
scopeKind,
|
||||
artifacts: [artifact],
|
||||
warnings,
|
||||
expectedArtifactDirs: [],
|
||||
expectedArtifactDirStatus: [],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
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 = safeInputRelativePath(entry, "expectedArtifactDir");
|
||||
const withSlash = normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
if (seen.has(withSlash)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(withSlash);
|
||||
result.push(withSlash);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function expectedArtifactDirStatuses(
|
||||
workspaceRoot: string,
|
||||
expectedArtifactDirs: string[],
|
||||
): Promise<XWorkmateExpectedArtifactDirStatus[]> {
|
||||
const statuses: XWorkmateExpectedArtifactDirStatus[] = [];
|
||||
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: {
|
||||
remoteWorkingDirectory: string;
|
||||
artifactScope?: string;
|
||||
|
||||
228
src/taskState.test.ts
Normal file
228
src/taskState.test.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
XWORKMATE_PLUGIN_ID,
|
||||
XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
getXWorkmateTaskSnapshot,
|
||||
normalizeXWorkmateTaskMetadataV1,
|
||||
recordXWorkmateSessionMapping,
|
||||
readXWorkmateSessionMapping,
|
||||
} from "./taskState.js";
|
||||
|
||||
function createApiFixture(tasks: Record<string, unknown> = {}) {
|
||||
const sessions = new Map<string, any>();
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
logger: { warn: () => {} },
|
||||
runtime: {
|
||||
agent: {
|
||||
session: {
|
||||
getSessionEntry: ({ sessionKey }: { sessionKey: string }) => sessions.get(sessionKey),
|
||||
listSessionEntries: () =>
|
||||
[...sessions.entries()].map(([sessionKey, entry]) => ({
|
||||
sessionKey,
|
||||
entry,
|
||||
})),
|
||||
patchSessionEntry: async ({
|
||||
sessionKey,
|
||||
update,
|
||||
}: {
|
||||
sessionKey: string;
|
||||
update: (entry: any) => Partial<any> | null;
|
||||
}) => {
|
||||
const current = sessions.get(sessionKey) ?? { sessionId: sessionKey, updatedAt: 0 };
|
||||
const patch = update(current);
|
||||
if (patch) {
|
||||
sessions.set(sessionKey, { ...current, ...patch });
|
||||
}
|
||||
return sessions.get(sessionKey) ?? null;
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: ({ sessionKey }: { sessionKey: string }) => ({
|
||||
resolve: (token: string) => tasks[`${sessionKey}:${token}`],
|
||||
get: (token: string) => tasks[`${sessionKey}:${token}`],
|
||||
findLatest: () => tasks[`${sessionKey}:latest`],
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { api: api as any, sessions };
|
||||
}
|
||||
|
||||
describe("xworkmate task state mapping", () => {
|
||||
it("requires typed appThreadKey metadata", () => {
|
||||
expect(() =>
|
||||
normalizeXWorkmateTaskMetadataV1({
|
||||
schemaVersion: 1,
|
||||
sessionKey: "draft:legacy",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
}),
|
||||
).toThrow("appThreadKey required");
|
||||
});
|
||||
|
||||
it("writes a durable pluginExtensions mapping without deriving the OpenClaw key", async () => {
|
||||
const { api, sessions } = createApiFixture();
|
||||
|
||||
const mapping = await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
expectedArtifactDirs: ["assets/images", "reports/"],
|
||||
createdAt: "2026-06-05T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mapping).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
expectedArtifactDirs: ["assets/images/", "reports/"],
|
||||
source: "bridge_prepare",
|
||||
});
|
||||
expect(
|
||||
sessions.get("draft:1780658097668838-1").pluginExtensions[XWORKMATE_PLUGIN_ID][
|
||||
XWORKMATE_SESSION_EXTENSION_NAMESPACE
|
||||
],
|
||||
).toMatchObject(mapping);
|
||||
});
|
||||
|
||||
it("fails closed when an existing mapping points to a different app thread", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:first",
|
||||
openclawSessionKey: "draft:first",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:second",
|
||||
openclawSessionKey: "draft:first",
|
||||
runId: "run-2",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("conflict");
|
||||
});
|
||||
|
||||
it("resolves appThreadKey through pluginExtensions before querying native tasks", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:1780658097668838-1:run-1": {
|
||||
taskId: "task-1",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:1780658097668838-1",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
status: "completed",
|
||||
openclawSessionKey: "draft:1780658097668838-1",
|
||||
expectedArtifactDirs: ["artifacts/"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no_native_task_record instead of inferring success from artifacts", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:no-task",
|
||||
openclawSessionKey: "draft:no-task",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
appThreadKey: "draft:no-task",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "no_native_task_record",
|
||||
mapping: {
|
||||
appThreadKey: "draft:no-task",
|
||||
openclawSessionKey: "draft:no-task",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept legacy sessionKey as a task lookup alias", async () => {
|
||||
const { api } = createApiFixture({
|
||||
"draft:legacy:run-1": {
|
||||
taskId: "task-legacy",
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getXWorkmateTaskSnapshot({
|
||||
api,
|
||||
params: {
|
||||
sessionKey: "draft:legacy",
|
||||
runId: "run-1",
|
||||
includeArtifacts: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "invalid_lookup",
|
||||
});
|
||||
});
|
||||
|
||||
it("can read mapping by appThreadKey from pluginExtensions", async () => {
|
||||
const { api } = createApiFixture();
|
||||
await recordXWorkmateSessionMapping({
|
||||
api,
|
||||
params: {
|
||||
schemaVersion: 1,
|
||||
appThreadKey: "draft:lookup",
|
||||
openclawSessionKey: "draft:lookup",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readXWorkmateSessionMapping(api, { appThreadKey: "draft:lookup" })).resolves.toMatchObject({
|
||||
appThreadKey: "draft:lookup",
|
||||
openclawSessionKey: "draft:lookup",
|
||||
});
|
||||
});
|
||||
});
|
||||
708
src/taskState.ts
708
src/taskState.ts
@ -1,360 +1,418 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
||||
|
||||
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 const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
||||
|
||||
export type XWorkmateTaskMetadataV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey?: string;
|
||||
expectedArtifactDirs: string[];
|
||||
requestId?: string;
|
||||
externalTaskId?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type XWorkmateSessionMapping = {
|
||||
appSessionKey: string;
|
||||
openClawSessionKey: string;
|
||||
appThreadId?: string;
|
||||
sessionId?: string;
|
||||
runId: string;
|
||||
artifactScope?: string;
|
||||
export type XWorkmateSessionMappingSource =
|
||||
| "session_start"
|
||||
| "bridge_prepare";
|
||||
|
||||
export type XWorkmateSessionMappingV1 = {
|
||||
schemaVersion: 1;
|
||||
appThreadKey: string;
|
||||
openclawSessionKey: string;
|
||||
expectedArtifactDirs: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
legacyDerived?: boolean;
|
||||
};
|
||||
|
||||
export type XWorkmateTaskLookupErrorCode =
|
||||
| "mapping_not_found"
|
||||
| "task_not_found"
|
||||
| "no_native_task_record"
|
||||
| "conflict"
|
||||
| "invalid_lookup";
|
||||
|
||||
export type XWorkmateTaskLookupError = {
|
||||
ok: false;
|
||||
code: XWorkmateTaskLookupErrorCode;
|
||||
message: string;
|
||||
mapping?: XWorkmateSessionMappingV1;
|
||||
expectedArtifactDirs?: string[];
|
||||
};
|
||||
|
||||
export type XWorkmateTaskStore = {
|
||||
records: Map<string, XWorkmateTaskRecord>;
|
||||
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
|
||||
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
|
||||
export type XWorkmateTaskStore = Record<string, never>;
|
||||
|
||||
type SessionEntry = Record<string, unknown> & {
|
||||
pluginExtensions?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate";
|
||||
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
||||
type PatchSessionEntry = (params: {
|
||||
sessionKey: string;
|
||||
preserveActivity?: boolean;
|
||||
update: (entry: SessionEntry) => Partial<SessionEntry> | null;
|
||||
}) => Promise<SessionEntry | null> | SessionEntry | null;
|
||||
|
||||
type GetSessionEntry = (params: { sessionKey: string }) => SessionEntry | undefined;
|
||||
|
||||
type BoundTaskRunsRuntime = {
|
||||
get?: (taskId: string) => unknown;
|
||||
list?: () => unknown[];
|
||||
findLatest?: () => unknown;
|
||||
resolve?: (token: string) => unknown;
|
||||
};
|
||||
|
||||
export function createXWorkmateTaskStore(): XWorkmateTaskStore {
|
||||
return {
|
||||
records: new Map(),
|
||||
sessionMappingsByAppKey: new Map(),
|
||||
sessionMappingsByOpenClawKey: new Map(),
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) {
|
||||
const registerExtension = api.session?.state?.registerSessionExtension ?? (api as any).registerSessionExtension;
|
||||
const registerExtension =
|
||||
api.session?.state?.registerSessionExtension ?? (api as any).registerSessionExtension;
|
||||
if (typeof registerExtension !== "function") {
|
||||
return;
|
||||
}
|
||||
registerExtension({
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.",
|
||||
description: "Durable XWorkmate app/OpenClaw session key mapping.",
|
||||
sessionEntrySlotKey: "xworkmate",
|
||||
project: (ctx: { sessionKey: string; sessionId?: string; state?: unknown }) => {
|
||||
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),
|
||||
};
|
||||
project: (ctx: { sessionKey: string; state?: unknown }): any => {
|
||||
const state = asRecord(ctx.state);
|
||||
return state ?? {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
taskStore?: XWorkmateTaskStore;
|
||||
params: Record<string, unknown>;
|
||||
artifactScope?: string;
|
||||
}) {
|
||||
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: XWorkmateSessionMapping = 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,
|
||||
}) as XWorkmateSessionMapping;
|
||||
|
||||
input.taskStore.sessionMappingsByAppKey.set(appSessionKey, mapping);
|
||||
input.taskStore.sessionMappingsByOpenClawKey.set(openClawSessionKey, mapping);
|
||||
|
||||
const patchSessionExtension = resolvePatchSessionExtension(input.api);
|
||||
if (!patchSessionExtension) {
|
||||
// Legacy fallback owner: this plugin. Scope: tests and OpenClaw hosts that do not expose
|
||||
// session extension patching yet. Exit: remove this map once 2026.6.1+ hosts expose the patch
|
||||
// method on the public plugin API in all supported deployments.
|
||||
return;
|
||||
}
|
||||
await patchSessionExtension({
|
||||
key: openClawSessionKey,
|
||||
sessionKey: openClawSessionKey,
|
||||
pluginId: XWORKMATE_PLUGIN_ID,
|
||||
namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE,
|
||||
value: mapping,
|
||||
source?: XWorkmateSessionMappingSource;
|
||||
}): Promise<XWorkmateSessionMappingV1> {
|
||||
const metadata = normalizeXWorkmateTaskMetadataV1(input.params);
|
||||
const openclawSessionKey = requiredString(
|
||||
input.params.openclawSessionKey ?? metadata.openclawSessionKey,
|
||||
"openclawSessionKey required",
|
||||
);
|
||||
return upsertXWorkmateSessionMapping(input.api, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
openclawSessionKey,
|
||||
},
|
||||
openclawSessionKey,
|
||||
source: input.source ?? "bridge_prepare",
|
||||
});
|
||||
}
|
||||
|
||||
export function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore) {
|
||||
const registerRuntime = (api as any).registerDetachedTaskRuntime;
|
||||
if (typeof registerRuntime !== "function") {
|
||||
return;
|
||||
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) {
|
||||
throw new Error("schemaVersion must be 1");
|
||||
}
|
||||
registerRuntime({
|
||||
createQueuedTaskRun: (params: Record<string, unknown>) =>
|
||||
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }),
|
||||
createRunningTaskRun: (params: Record<string, unknown>) =>
|
||||
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }),
|
||||
startTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||
updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }),
|
||||
recordTaskRunProgressByRunId: (params: Record<string, unknown>) =>
|
||||
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
|
||||
lastEventAt: Date.now(),
|
||||
progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary),
|
||||
}),
|
||||
finalizeTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||
updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)),
|
||||
completeTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
|
||||
status: "succeeded",
|
||||
endedAt: numberOrNow(params.endedAt),
|
||||
lastEventAt: numberOrNow(params.lastEventAt),
|
||||
terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary),
|
||||
terminalOutcome: "succeeded",
|
||||
}),
|
||||
failTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||
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: Record<string, unknown>) =>
|
||||
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
|
||||
deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"),
|
||||
error: optionalString(params.error),
|
||||
}),
|
||||
cancelDetachedTaskRunById: async (params: Record<string, unknown>) => {
|
||||
const taskId = optionalString(params.taskId);
|
||||
const record = taskId ? findXWorkmateTaskByTaskId(taskStore, taskId) : undefined;
|
||||
if (!record) {
|
||||
return { found: false, cancelled: false };
|
||||
const appThreadKey = requiredString(envelope.appThreadKey, "appThreadKey required");
|
||||
const createdAt = optionalString(envelope.createdAt) || new Date().toISOString();
|
||||
return compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
appThreadKey,
|
||||
openclawSessionKey: optionalString(envelope.openclawSessionKey),
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(envelope.expectedArtifactDirs),
|
||||
requestId: optionalString(envelope.requestId),
|
||||
externalTaskId: optionalString(envelope.externalTaskId ?? envelope.taskId),
|
||||
createdAt,
|
||||
}) as XWorkmateTaskMetadataV1;
|
||||
}
|
||||
|
||||
export function normalizeExpectedArtifactDirs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of value) {
|
||||
const text = optionalString(entry).replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
||||
if (!text || seen.has(text)) {
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text) || text.split("/").includes("..")) {
|
||||
throw new Error("expectedArtifactDirs must be relative paths without traversal");
|
||||
}
|
||||
const normalized = text.endsWith("/") ? text : `${text}/`;
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function upsertXWorkmateSessionMapping(
|
||||
api: OpenClawPluginApi,
|
||||
input: {
|
||||
metadata: XWorkmateTaskMetadataV1;
|
||||
openclawSessionKey: string;
|
||||
source: XWorkmateSessionMappingSource;
|
||||
legacyDerived?: boolean;
|
||||
},
|
||||
): Promise<XWorkmateSessionMappingV1> {
|
||||
const patchSessionEntry = resolvePatchSessionEntry(api);
|
||||
if (!patchSessionEntry) {
|
||||
throw new Error("OpenClaw runtime session patch API is unavailable");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let mapping: XWorkmateSessionMappingV1 | undefined;
|
||||
await patchSessionEntry({
|
||||
sessionKey: input.openclawSessionKey,
|
||||
preserveActivity: true,
|
||||
update: (entry) => {
|
||||
const existing = readMappingFromEntry(entry);
|
||||
if (existing) {
|
||||
assertMappingCompatible(existing, input.metadata.appThreadKey, input.openclawSessionKey);
|
||||
mapping = {
|
||||
...existing,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
updatedAt: now,
|
||||
source: existing.source,
|
||||
};
|
||||
} else {
|
||||
mapping = compactObject({
|
||||
schemaVersion: 1 as const,
|
||||
appThreadKey: input.metadata.appThreadKey,
|
||||
openclawSessionKey: input.openclawSessionKey,
|
||||
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
||||
createdAt: input.metadata.createdAt || now,
|
||||
updatedAt: now,
|
||||
source: input.source,
|
||||
legacyDerived: input.legacyDerived === true ? true : undefined,
|
||||
}) as XWorkmateSessionMappingV1;
|
||||
}
|
||||
record.status = "cancelled";
|
||||
record.endedAt = Date.now();
|
||||
record.lastEventAt = record.endedAt;
|
||||
return { found: true, cancelled: true, reason: optionalString(params.reason), task: record };
|
||||
return {
|
||||
pluginExtensions: writeMappingToPluginExtensions(entry.pluginExtensions, mapping),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
throw new Error("failed to write xworkmate session mapping");
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function readXWorkmateSessionMapping(
|
||||
api: OpenClawPluginApi,
|
||||
lookup: {
|
||||
appThreadKey?: string;
|
||||
openclawSessionKey?: string;
|
||||
},
|
||||
): Promise<XWorkmateSessionMappingV1 | undefined> {
|
||||
const getSessionEntry = resolveGetSessionEntry(api);
|
||||
if (!getSessionEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const openclawSessionKey = optionalString(lookup.openclawSessionKey);
|
||||
if (openclawSessionKey) {
|
||||
return readMappingFromEntry(getSessionEntry({ sessionKey: openclawSessionKey }));
|
||||
}
|
||||
const appThreadKey = optionalString(lookup.appThreadKey);
|
||||
if (!appThreadKey) {
|
||||
return undefined;
|
||||
}
|
||||
const listSessionEntries = resolveListSessionEntries(api);
|
||||
for (const item of listSessionEntries?.() ?? []) {
|
||||
const mapping = readMappingFromEntry(item.entry);
|
||||
if (mapping?.appThreadKey === appThreadKey) {
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getXWorkmateTaskSnapshot(input: {
|
||||
api: OpenClawPluginApi;
|
||||
taskStore: XWorkmateTaskStore;
|
||||
taskStore?: XWorkmateTaskStore;
|
||||
params: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const sessionKey = requiredString(input.params.sessionKey, "sessionKey required");
|
||||
const runId = requiredString(input.params.runId, "runId required");
|
||||
const mapping = resolveSessionMapping(input.taskStore, input.params, sessionKey);
|
||||
const openClawSessionKey =
|
||||
mapping?.openClawSessionKey || optionalString(input.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey);
|
||||
const appSessionKey = mapping?.appSessionKey || sessionKey;
|
||||
const nativeTask = resolveNativeTask(input.api, openClawSessionKey, runId) || resolveNativeTask(input.api, sessionKey, runId);
|
||||
const storedTask = findXWorkmateTask(input.taskStore, sessionKey, runId);
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: input.params,
|
||||
config: input.api.config,
|
||||
pluginConfig: input.api.pluginConfig,
|
||||
const params = input.params ?? {};
|
||||
const appThreadKey = optionalString(params.appThreadKey);
|
||||
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
|
||||
const mapping = await readXWorkmateSessionMapping(input.api, {
|
||||
appThreadKey,
|
||||
openclawSessionKey: explicitOpenclawSessionKey,
|
||||
});
|
||||
const task = nativeTask || storedTask;
|
||||
const taskStatus = normalizeTaskStatus(optionalString((task as any).status), exported.artifacts.length > 0);
|
||||
if (storedTask && taskStatus === "succeeded" && storedTask.status !== "succeeded") {
|
||||
storedTask.status = "succeeded";
|
||||
storedTask.endedAt = Date.now();
|
||||
storedTask.lastEventAt = storedTask.endedAt;
|
||||
storedTask.terminalOutcome = "succeeded";
|
||||
if (!mapping && appThreadKey && !explicitOpenclawSessionKey) {
|
||||
return lookupError("mapping_not_found", `No OpenClaw session mapping found for ${appThreadKey}`);
|
||||
}
|
||||
const openclawSessionKey = mapping?.openclawSessionKey || explicitOpenclawSessionKey;
|
||||
if (!openclawSessionKey) {
|
||||
return lookupError("invalid_lookup", "openclawSessionKey or appThreadKey required");
|
||||
}
|
||||
|
||||
const runId = optionalString(params.runId);
|
||||
const taskId = optionalString(params.taskId);
|
||||
const task = resolveNativeTask(input.api, {
|
||||
openclawSessionKey,
|
||||
runId,
|
||||
taskId,
|
||||
});
|
||||
if (!task) {
|
||||
const code: XWorkmateTaskLookupErrorCode = runId || taskId ? "no_native_task_record" : "task_not_found";
|
||||
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
|
||||
}
|
||||
|
||||
const taskStatus = optionalString((task as any).status) || "running";
|
||||
const includeArtifacts = params.includeArtifacts !== false;
|
||||
const exported = includeArtifacts
|
||||
? await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
...params,
|
||||
openclawSessionKey,
|
||||
runId: runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
|
||||
includeContent: params.includeContent ?? false,
|
||||
},
|
||||
config: input.api.config,
|
||||
pluginConfig: input.api.pluginConfig,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: appStatusFromTaskStatus(taskStatus),
|
||||
taskStatus,
|
||||
mode: "gateway-chat",
|
||||
sessionKey,
|
||||
openClawSessionKey,
|
||||
appSessionKey,
|
||||
runId,
|
||||
mapping,
|
||||
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
||||
openclawSessionKey,
|
||||
runId: runId || optionalString((task as any).runId),
|
||||
taskId: taskId || optionalString((task as any).taskId),
|
||||
task,
|
||||
artifactScope: exported.artifactScope,
|
||||
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
||||
scopeKind: exported.scopeKind,
|
||||
artifacts: exported.artifacts,
|
||||
warnings: exported.warnings,
|
||||
artifactCount: exported.artifacts.length,
|
||||
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
||||
artifactScope: exported?.artifactScope,
|
||||
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
||||
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
||||
scopeKind: exported?.scopeKind,
|
||||
artifacts: exported?.artifacts ?? [],
|
||||
warnings: exported?.warnings ?? [],
|
||||
artifactCount: exported?.artifacts.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: {
|
||||
params: Record<string, unknown>;
|
||||
status: XWorkmateTaskRecord["status"];
|
||||
progressSummary?: string;
|
||||
}): XWorkmateTaskRecord {
|
||||
const sessionKey = requiredString(options.params.sessionKey || options.params.requesterSessionKey, "sessionKey required");
|
||||
const runId = requiredString(options.params.runId, "runId required");
|
||||
const key = taskRecordKey(sessionKey, runId);
|
||||
const now = Date.now();
|
||||
const existing = input.records.get(key);
|
||||
if (existing) {
|
||||
existing.status = options.status;
|
||||
existing.lastEventAt = now;
|
||||
if (options.status === "running" && !existing.startedAt) {
|
||||
existing.startedAt = now;
|
||||
}
|
||||
if (options.progressSummary) {
|
||||
existing.progressSummary = options.progressSummary;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const record: XWorkmateTaskRecord = {
|
||||
taskId: `xworkmate:${safeTaskIdSegment(sessionKey)}:${safeTaskIdSegment(runId)}`,
|
||||
runtime: "acp",
|
||||
taskKind: "xworkmate-openclaw",
|
||||
requesterSessionKey: optionalString(options.params.openClawSessionKey) || agentMainSessionKeyFor(sessionKey),
|
||||
ownerKey: sessionKey,
|
||||
scopeKind: "session",
|
||||
runId,
|
||||
label: optionalString(options.params.label) || "XWorkmate OpenClaw task",
|
||||
task: optionalString(options.params.taskPrompt) || optionalString(options.params.task) || "XWorkmate OpenClaw task",
|
||||
status: options.status,
|
||||
deliveryStatus: "pending",
|
||||
notifyPolicy: "state_changes",
|
||||
createdAt: now,
|
||||
startedAt: options.status === "running" ? now : undefined,
|
||||
lastEventAt: now,
|
||||
progressSummary: options.progressSummary,
|
||||
};
|
||||
input.records.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function updateXWorkmateTaskRecordsByRunId(
|
||||
input: XWorkmateTaskStore,
|
||||
params: Record<string, unknown>,
|
||||
patch: Partial<XWorkmateTaskRecord>,
|
||||
): XWorkmateTaskRecord[] {
|
||||
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: OpenClawPluginApi, sessionKey: string, runId: string): Record<string, unknown> | undefined {
|
||||
function resolveNativeTask(
|
||||
api: OpenClawPluginApi,
|
||||
input: { openclawSessionKey: string; runId?: string; taskId?: string },
|
||||
): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey });
|
||||
const resolved = bound?.resolve?.(runId) || bound?.get?.(runId);
|
||||
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey }) as
|
||||
| BoundTaskRunsRuntime
|
||||
| undefined;
|
||||
if (!bound) {
|
||||
return undefined;
|
||||
}
|
||||
const lookup = input.taskId || input.runId || "";
|
||||
const resolved = lookup ? bound.resolve?.(lookup) || bound.get?.(lookup) : bound.findLatest?.();
|
||||
return asRecord(resolved);
|
||||
} catch (error) {
|
||||
api.logger?.warn?.(
|
||||
`xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`,
|
||||
`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSessionMapping(
|
||||
input: XWorkmateTaskStore,
|
||||
params: Record<string, unknown>,
|
||||
sessionKey: string,
|
||||
): XWorkmateSessionMapping | undefined {
|
||||
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: XWorkmateTaskStore, sessionKey: string, runId: string): XWorkmateTaskRecord | undefined {
|
||||
return input.records.get(taskRecordKey(sessionKey, runId));
|
||||
}
|
||||
|
||||
function findXWorkmateTaskByTaskId(input: XWorkmateTaskStore, taskId: string): XWorkmateTaskRecord | undefined {
|
||||
return [...input.records.values()].find((record) => record.taskId === taskId);
|
||||
}
|
||||
|
||||
function taskRecordKey(sessionKey: string, runId: string): string {
|
||||
return `${sessionKey}\u0000${runId}`;
|
||||
}
|
||||
|
||||
function appSessionKeyFromOpenClawSessionKey(sessionKey: string): string {
|
||||
return sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey;
|
||||
}
|
||||
|
||||
function agentMainSessionKeyFor(sessionKey: string): string {
|
||||
return sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`;
|
||||
}
|
||||
|
||||
function terminalPatch(params: Record<string, unknown>): Partial<XWorkmateTaskRecord> {
|
||||
const status = taskStatusFrom(params.status, "succeeded");
|
||||
function lookupError(
|
||||
code: XWorkmateTaskLookupErrorCode,
|
||||
message: string,
|
||||
mapping?: XWorkmateSessionMappingV1,
|
||||
): XWorkmateTaskLookupError {
|
||||
return {
|
||||
status,
|
||||
endedAt: numberOrNow(params.endedAt),
|
||||
lastEventAt: numberOrNow(params.lastEventAt),
|
||||
error: optionalString(params.error),
|
||||
progressSummary: optionalString(params.progressSummary),
|
||||
terminalSummary: optionalString(params.terminalSummary),
|
||||
terminalOutcome: status === "succeeded" ? "succeeded" : "blocked",
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(status: string, hasArtifacts: boolean): XWorkmateTaskRecord["status"] {
|
||||
const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running");
|
||||
if (normalized === "running" && hasArtifacts) {
|
||||
return "succeeded";
|
||||
function readMappingFromEntry(entry: SessionEntry | undefined | null): XWorkmateSessionMappingV1 | undefined {
|
||||
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
||||
const raw = asRecord(pluginState?.[XWORKMATE_SESSION_EXTENSION_NAMESPACE]);
|
||||
if (!raw || raw.schemaVersion !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
const appThreadKey = optionalString(raw.appThreadKey);
|
||||
const openclawSessionKey = optionalString(raw.openclawSessionKey);
|
||||
if (!appThreadKey || !openclawSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
appThreadKey,
|
||||
openclawSessionKey,
|
||||
expectedArtifactDirs: normalizeExpectedArtifactDirs(raw.expectedArtifactDirs),
|
||||
createdAt: optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
updatedAt: optionalString(raw.updatedAt) || optionalString(raw.createdAt) || new Date(0).toISOString(),
|
||||
source: parseMappingSource(raw.source),
|
||||
...(raw.legacyDerived === true ? { legacyDerived: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appStatusFromTaskStatus(status: XWorkmateTaskRecord["status"]): string {
|
||||
function writeMappingToPluginExtensions(
|
||||
current: SessionEntry["pluginExtensions"],
|
||||
mapping: XWorkmateSessionMappingV1 | undefined,
|
||||
): SessionEntry["pluginExtensions"] {
|
||||
if (!mapping) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...(current ?? {}),
|
||||
[XWORKMATE_PLUGIN_ID]: {
|
||||
...(current?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
||||
[XWORKMATE_SESSION_EXTENSION_NAMESPACE]: mapping,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertMappingCompatible(
|
||||
existing: XWorkmateSessionMappingV1,
|
||||
appThreadKey: string,
|
||||
openclawSessionKey: string,
|
||||
) {
|
||||
if (existing.appThreadKey !== appThreadKey || existing.openclawSessionKey !== openclawSessionKey) {
|
||||
throw new Error("conflict: xworkmate session mapping already points to a different session");
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePatchSessionEntry(api: OpenClawPluginApi): PatchSessionEntry | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.patchSessionEntry;
|
||||
return typeof candidate === "function" ? (candidate as PatchSessionEntry) : undefined;
|
||||
}
|
||||
|
||||
function resolveGetSessionEntry(api: OpenClawPluginApi): GetSessionEntry | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.getSessionEntry;
|
||||
return typeof candidate === "function" ? (candidate as GetSessionEntry) : undefined;
|
||||
}
|
||||
|
||||
function resolveListSessionEntries(
|
||||
api: OpenClawPluginApi,
|
||||
): (() => Array<{ sessionKey: string; entry: SessionEntry }>) | undefined {
|
||||
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
||||
const candidate = runtimeSession.listSessionEntries;
|
||||
return typeof candidate === "function"
|
||||
? (candidate as () => Array<{ sessionKey: string; entry: SessionEntry }>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function appStatusFromTaskStatus(status: string): string {
|
||||
if (status === "succeeded") {
|
||||
return "completed";
|
||||
}
|
||||
@ -364,46 +422,12 @@ function appStatusFromTaskStatus(status: XWorkmateTaskRecord["status"]): string
|
||||
return "running";
|
||||
}
|
||||
|
||||
function taskStatusFrom(value: unknown, fallback: XWorkmateTaskRecord["status"]): XWorkmateTaskRecord["status"] {
|
||||
const status = optionalString(value);
|
||||
if (
|
||||
status === "queued" ||
|
||||
status === "running" ||
|
||||
status === "succeeded" ||
|
||||
status === "failed" ||
|
||||
status === "timed_out" ||
|
||||
status === "cancelled" ||
|
||||
status === "lost"
|
||||
) {
|
||||
return status;
|
||||
function parseMappingSource(value: unknown): XWorkmateSessionMappingSource {
|
||||
const source = optionalString(value);
|
||||
if (source === "session_start" || source === "bridge_prepare") {
|
||||
return source;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function deliveryStatusFrom(value: unknown, fallback: XWorkmateTaskRecord["deliveryStatus"]): XWorkmateTaskRecord["deliveryStatus"] {
|
||||
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: OpenClawPluginApi):
|
||||
| ((params: Record<string, unknown>) => Promise<unknown> | unknown)
|
||||
| undefined {
|
||||
const stateApi = (api.session?.state ?? {}) as Record<string, unknown>;
|
||||
const apiRecord = api as unknown as Record<string, unknown>;
|
||||
const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension;
|
||||
return typeof candidate === "function"
|
||||
? (candidate as (params: Record<string, unknown>) => Promise<unknown> | unknown)
|
||||
: undefined;
|
||||
return "bridge_prepare";
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, message: string): string {
|
||||
@ -422,28 +446,6 @@ function optionalString(value: unknown): string {
|
||||
return text === "<nil>" ? "" : text;
|
||||
}
|
||||
|
||||
function stringList(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);
|
||||
if (!text || seen.has(text)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(text);
|
||||
result.push(text);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function numberOrNow(value: unknown): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
@ -452,9 +454,7 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
}
|
||||
|
||||
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== "")) as Partial<T>;
|
||||
}
|
||||
|
||||
function safeTaskIdSegment(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9._:-]+/g, "_");
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== ""),
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user