diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..73d4230 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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:-}" + + # 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:-}" + 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 diff --git a/README.md b/README.md index a877627..c2546d4 100644 --- a/README.md +++ b/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 diff --git a/index.test.ts b/index.test.ts index 1e26c1a..a70f07d 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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(); 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> = []; const detachedRuntimes: Array> = []; const api = { - config: {}, + config: {}, logger: { warn: console.warn }, pluginConfig: { workspaceDir: root }, runtime: { + agent: { + session: { + registerSessionExtension: (extension: Record) => { + 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) => { sessionExtensions.push(extension); }, - patchSessionExtension: (patch: Record) => { - sessionExtensionPatches.push(patch); - return { ok: true }; - }, }, }, + registerDetachedTaskRuntime: (runtime: Record) => { detachedRuntimes.push(runtime); }, @@ -180,28 +200,27 @@ describe("plugin registration", () => { registerHook: (event: string, handler: (payload: unknown) => Promise) => { 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) => 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, diff --git a/index.ts b/index.ts index 38aca31..24979b3 100644 --- a/index.ts +++ b/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({ diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 788bf58..25d1ed3 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -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 () => { diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index cae0778..e0b522c 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -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 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(); + 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 { + 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; diff --git a/src/taskState.test.ts b/src/taskState.test.ts new file mode 100644 index 0000000..31e0e4b --- /dev/null +++ b/src/taskState.test.ts @@ -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 = {}) { + const sessions = new Map(); + 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 | 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", + }); + }); +}); diff --git a/src/taskState.ts b/src/taskState.ts index 169e7f0..a455289 100644 --- a/src/taskState.ts +++ b/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; - sessionMappingsByAppKey: Map; - sessionMappingsByOpenClawKey: Map; +export type XWorkmateTaskStore = Record; + +type SessionEntry = Record & { + pluginExtensions?: Record>; }; -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 | null; +}) => Promise | 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; 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 { + 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): 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) => - createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }), - createRunningTaskRun: (params: Record) => - createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }), - startTaskRunByRunId: (params: Record) => - updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }), - recordTaskRunProgressByRunId: (params: Record) => - updateXWorkmateTaskRecordsByRunId(taskStore, params, { - lastEventAt: Date.now(), - progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary), - }), - finalizeTaskRunByRunId: (params: Record) => - updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)), - completeTaskRunByRunId: (params: Record) => - 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) => - 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) => - updateXWorkmateTaskRecordsByRunId(taskStore, params, { - deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"), - error: optionalString(params.error), - }), - cancelDetachedTaskRunById: async (params: Record) => { - 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(); + 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 { + 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 { + 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; }): Promise> { - 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; - 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, - patch: Partial, -): 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 | undefined { +function resolveNativeTask( + api: OpenClawPluginApi, + input: { openclawSessionKey: string; runId?: string; taskId?: string }, +): Record | 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, - 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): Partial { - 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; + 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; + 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; + 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) => Promise | unknown) - | undefined { - const stateApi = (api.session?.state ?? {}) as Record; - const apiRecord = api as unknown as Record; - const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension; - return typeof candidate === "function" - ? (candidate as (params: Record) => Promise | unknown) - : undefined; + return "bridge_prepare"; } function requiredString(value: unknown, message: string): string { @@ -422,28 +446,6 @@ function optionalString(value: unknown): string { return text === "" ? "" : text; } -function stringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - const seen = new Set(); - 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 | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; @@ -452,9 +454,7 @@ function asRecord(value: unknown): Record | undefined { } function compactObject>(value: T): Partial { - return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== "")) as Partial; -} - -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; }