From 35379f2fb0dd9a146f51c4b27bce6bffb25db4e4 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 07:13:40 +0800 Subject: [PATCH] fix: resolve xworkmate task snapshots from artifacts --- README.md | 17 ++++++--- dist/src/exportArtifacts.js | 2 +- dist/src/taskState.js | 60 ++++++++++++++++++++++++------ package.json | 2 +- src/exportArtifacts.ts | 2 +- src/taskState.test.ts | 63 ++++++++++++++++++++++++++++++-- src/taskState.ts | 73 +++++++++++++++++++++++++++++++------ 7 files changed, 183 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 84d8416..8d745ca 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,27 @@ # openclaw-multi-session-plugins -OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifact manifests. +OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling. ## Why XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing `/acp` and `/acp/rpc` contract with OpenClaw routing metadata. The bridge sends `chat.send`, waits for `agent.wait`, then asks this plugin for a session/run-scoped artifact manifest. -The APP can then sync generated files into its local thread workspace without +The app can then sync generated files into its local thread workspace without changing the UI or adding provider-specific routes. This plugin is not a scheduler or bridge client. OpenClaw core owns sub-agents, multi-agent routing, queues, cron, task registry state, and cross-session -execution. This package only adapts those existing OpenClaw task/session -identities into isolated artifact directories, session key mapping, and signed -artifact reads. +execution. This package only adapts existing OpenClaw task and session +identities into isolated artifact directories, durable session key mappings, +and signed artifact reads. + +In practice, it provides: + +- session preparation for a specific app thread and run +- task-scoped artifact directories under the resolved OpenClaw workspace +- safe export and read operations for XWorkmate Bridge +- signed artifact references that are bound to the issuing session and run It registers the minimal Gateway methods needed by XWorkmate: diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 8895a8a..9e9ab03 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -752,7 +752,7 @@ function resolveWorkspaceDir(input) { if (explicit) { return expandUserPath(explicit); } - throw new Error("UnsupportedError: workspaceDir must be explicitly provided in params or pluginConfig"); + return expandUserPath(path.join("~", ".openclaw", "workspace")); } function safeRelativePath(root, target) { const relative = path.relative(root, target); diff --git a/dist/src/taskState.js b/dist/src/taskState.js index 0506021..91f3868 100644 --- a/dist/src/taskState.js +++ b/dist/src/taskState.js @@ -158,24 +158,47 @@ export async function getXWorkmateTaskSnapshot(input) { runId, taskId, }); + const includeArtifacts = params.includeArtifacts !== false; if (!task) { + const exported = includeArtifacts && runId + ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) + : undefined; + if (exported?.artifacts.length) { + return { + success: true, + status: "completed", + taskStatus: "succeeded", + mode: "gateway-chat", + mapping, + appThreadKey: mapping?.appThreadKey ?? appThreadKey, + openclawSessionKey, + runId, + taskId: taskId || runId, + task: { + taskId: taskId || runId, + runId, + status: "succeeded", + source: "artifact_fallback", + }, + expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [], + artifactScope: exported.artifactScope, + remoteWorkingDirectory: exported.remoteWorkingDirectory, + remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind, + scopeKind: exported.scopeKind, + artifacts: exported.artifacts, + warnings: [ + ...exported.warnings, + `Native OpenClaw task record was unavailable for ${openclawSessionKey}; resolved from task artifacts.`, + ], + artifactCount: exported.artifacts.length, + }; + } const code = runId || taskId ? "no_native_task_record" : "task_not_found"; return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping); } const taskStatus = optionalString(task.status) || "running"; - const includeArtifacts = params.includeArtifacts !== false; const exported = includeArtifacts - ? await exportXWorkmateArtifacts({ - params: { - ...params, - openclawSessionKey, - runId: runId || optionalString(task.runId) || optionalString(task.taskId), - expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs), - includeContent: params.includeContent ?? false, - }, - config: input.api.config, - pluginConfig: input.api.pluginConfig, - }) + ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId || optionalString(task.runId) || optionalString(task.taskId), mapping) : undefined; return { success: true, @@ -198,6 +221,19 @@ export async function getXWorkmateTaskSnapshot(input) { artifactCount: exported?.artifacts.length ?? 0, }; } +async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) { + return exportXWorkmateArtifacts({ + params: { + ...params, + openclawSessionKey, + runId, + expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs), + includeContent: params.includeContent ?? false, + }, + config: input.api.config, + pluginConfig: input.api.pluginConfig, + }); +} function resolveNativeTask(api, input) { try { const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey }); diff --git a/package.json b/package.json index fe443b0..e2d43da 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openclaw-multi-session-plugins", "version": "2026.6.1", - "description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts", + "description": "OpenClaw plugin for per-session workspace isolation and scoped XWorkmate artifact handling", "type": "module", "license": "MIT", "keywords": [ diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index c8ced4e..16d1932 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -935,7 +935,7 @@ function resolveWorkspaceDir(input: { if (explicit) { return expandUserPath(explicit); } - throw new Error("UnsupportedError: workspaceDir must be explicitly provided in params or pluginConfig"); + return expandUserPath(path.join("~", ".openclaw", "workspace")); } function safeRelativePath(root: string, target: string): string { diff --git a/src/taskState.test.ts b/src/taskState.test.ts index c202c23..2ae6ba3 100644 --- a/src/taskState.test.ts +++ b/src/taskState.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { XWORKMATE_PLUGIN_ID, @@ -8,11 +11,11 @@ import { readXWorkmateSessionMapping, } from "./taskState.js"; -function createApiFixture(tasks: Record = {}) { +function createApiFixture(tasks: Record = {}, pluginConfig: Record = {}) { const sessions = new Map(); const api = { config: {}, - pluginConfig: {}, + pluginConfig, logger: { warn: () => {} }, runtime: { agent: { @@ -58,6 +61,10 @@ function createApiFixture(tasks: Record = {}) { return { api: api as any, sessions }; } +async function createWorkspaceFixture() { + return fs.mkdtemp(path.join(os.tmpdir(), "xworkmate-task-state-")); +} + describe("xworkmate task state mapping", () => { it("requires typed appThreadKey metadata", () => { expect(() => @@ -159,8 +166,56 @@ describe("xworkmate task state mapping", () => { }); }); - it("returns no_native_task_record instead of inferring success from artifacts", async () => { - const { api } = createApiFixture(); + it("resolves completed snapshot from task artifacts when native task record is unavailable", async () => { + const workspaceDir = await createWorkspaceFixture(); + const appThreadKey = "draft:sample-task"; + const openclawSessionKey = "agent:main:draft:sample-task"; + const runId = "turn-sample"; + const artifactDir = path.join(workspaceDir, "tasks", "agent_main_draft_sample-task", runId); + await fs.mkdir(artifactDir, { recursive: true }); + await fs.writeFile(path.join(artifactDir, "report.md"), "# Report\n", "utf8"); + + const { api } = createApiFixture({}, { workspaceDir }); + await recordXWorkmateSessionMapping({ + api, + params: { + schemaVersion: 1, + appThreadKey, + openclawSessionKey, + runId, + }, + }); + + const result = await getXWorkmateTaskSnapshot({ + api, + params: { + appThreadKey, + openclawSessionKey, + runId, + }, + }); + + expect(result).toMatchObject({ + success: true, + status: "completed", + taskStatus: "succeeded", + openclawSessionKey, + runId, + task: { + source: "artifact_fallback", + }, + artifacts: [ + { + relativePath: "report.md", + contentType: "text/markdown", + }, + ], + }); + }); + + it("returns no_native_task_record when neither native task record nor task artifacts exist", async () => { + const workspaceDir = await createWorkspaceFixture(); + const { api } = createApiFixture({}, { workspaceDir }); await recordXWorkmateSessionMapping({ api, params: { diff --git a/src/taskState.ts b/src/taskState.ts index 88b4c6c..b796e9b 100644 --- a/src/taskState.ts +++ b/src/taskState.ts @@ -253,25 +253,54 @@ export async function getXWorkmateTaskSnapshot(input: { runId, taskId, }); + const includeArtifacts = params.includeArtifacts !== false; if (!task) { + const exported = includeArtifacts && runId + ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) + : undefined; + if (exported?.artifacts.length) { + return { + success: true, + status: "completed", + taskStatus: "succeeded", + mode: "gateway-chat", + mapping, + appThreadKey: mapping?.appThreadKey ?? appThreadKey, + openclawSessionKey, + runId, + taskId: taskId || runId, + task: { + taskId: taskId || runId, + runId, + status: "succeeded", + source: "artifact_fallback", + }, + expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [], + artifactScope: exported.artifactScope, + remoteWorkingDirectory: exported.remoteWorkingDirectory, + remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind, + scopeKind: exported.scopeKind, + artifacts: exported.artifacts, + warnings: [ + ...exported.warnings, + `Native OpenClaw task record was unavailable for ${openclawSessionKey}; resolved from task artifacts.`, + ], + artifactCount: exported.artifacts.length, + }; + } const code: XWorkmateTaskLookupErrorCode = runId || taskId ? "no_native_task_record" : "task_not_found"; return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping); } const taskStatus = optionalString((task as any).status) || "running"; - const includeArtifacts = params.includeArtifacts !== false; const exported = includeArtifacts - ? await exportXWorkmateArtifacts({ - params: { - ...params, - openclawSessionKey, - runId: runId || optionalString((task as any).runId) || optionalString((task as any).taskId), - expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs), - includeContent: params.includeContent ?? false, - }, - config: input.api.config, - pluginConfig: input.api.pluginConfig, - }) + ? await exportArtifactsForTaskLookup( + input, + params, + openclawSessionKey, + runId || optionalString((task as any).runId) || optionalString((task as any).taskId), + mapping, + ) : undefined; return { @@ -296,6 +325,26 @@ export async function getXWorkmateTaskSnapshot(input: { }; } +async function exportArtifactsForTaskLookup( + input: { api: OpenClawPluginApi; params: Record }, + params: Record, + openclawSessionKey: string, + runId: string, + mapping?: XWorkmateSessionMappingV1, +) { + return exportXWorkmateArtifacts({ + params: { + ...params, + openclawSessionKey, + runId, + expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs), + includeContent: params.includeContent ?? false, + }, + config: input.api.config, + pluginConfig: input.api.pluginConfig, + }); +} + function resolveNativeTask( api: OpenClawPluginApi, input: { openclawSessionKey: string; runId?: string; taskId?: string },