fix: resolve xworkmate task snapshots from artifacts

This commit is contained in:
Haitao Pan 2026-06-08 07:13:40 +08:00
parent f0ffa62f52
commit 35379f2fb0
7 changed files with 183 additions and 36 deletions

View File

@ -1,20 +1,27 @@
# openclaw-multi-session-plugins # 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 ## Why
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing
`/acp` and `/acp/rpc` contract with OpenClaw routing metadata. The bridge sends `/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. `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. changing the UI or adding provider-specific routes.
This plugin is not a scheduler or bridge client. OpenClaw core owns sub-agents, 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 multi-agent routing, queues, cron, task registry state, and cross-session
execution. This package only adapts those existing OpenClaw task/session execution. This package only adapts existing OpenClaw task and session
identities into isolated artifact directories, session key mapping, and signed identities into isolated artifact directories, durable session key mappings,
artifact reads. 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: It registers the minimal Gateway methods needed by XWorkmate:

View File

@ -752,7 +752,7 @@ function resolveWorkspaceDir(input) {
if (explicit) { if (explicit) {
return expandUserPath(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) { function safeRelativePath(root, target) {
const relative = path.relative(root, target); const relative = path.relative(root, target);

60
dist/src/taskState.js vendored
View File

@ -158,24 +158,47 @@ export async function getXWorkmateTaskSnapshot(input) {
runId, runId,
taskId, taskId,
}); });
const includeArtifacts = params.includeArtifacts !== false;
if (!task) { 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"; const code = runId || taskId ? "no_native_task_record" : "task_not_found";
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping); return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
} }
const taskStatus = optionalString(task.status) || "running"; const taskStatus = optionalString(task.status) || "running";
const includeArtifacts = params.includeArtifacts !== false;
const exported = includeArtifacts const exported = includeArtifacts
? await exportXWorkmateArtifacts({ ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId || optionalString(task.runId) || optionalString(task.taskId), mapping)
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,
})
: undefined; : undefined;
return { return {
success: true, success: true,
@ -198,6 +221,19 @@ export async function getXWorkmateTaskSnapshot(input) {
artifactCount: exported?.artifacts.length ?? 0, 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) { function resolveNativeTask(api, input) {
try { try {
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey }); const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey });

View File

@ -1,7 +1,7 @@
{ {
"name": "openclaw-multi-session-plugins", "name": "openclaw-multi-session-plugins",
"version": "2026.6.1", "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", "type": "module",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View File

@ -935,7 +935,7 @@ function resolveWorkspaceDir(input: {
if (explicit) { if (explicit) {
return expandUserPath(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 { function safeRelativePath(root: string, target: string): string {

View File

@ -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 { describe, expect, it } from "vitest";
import { import {
XWORKMATE_PLUGIN_ID, XWORKMATE_PLUGIN_ID,
@ -8,11 +11,11 @@ import {
readXWorkmateSessionMapping, readXWorkmateSessionMapping,
} from "./taskState.js"; } from "./taskState.js";
function createApiFixture(tasks: Record<string, unknown> = {}) { function createApiFixture(tasks: Record<string, unknown> = {}, pluginConfig: Record<string, unknown> = {}) {
const sessions = new Map<string, any>(); const sessions = new Map<string, any>();
const api = { const api = {
config: {}, config: {},
pluginConfig: {}, pluginConfig,
logger: { warn: () => {} }, logger: { warn: () => {} },
runtime: { runtime: {
agent: { agent: {
@ -58,6 +61,10 @@ function createApiFixture(tasks: Record<string, unknown> = {}) {
return { api: api as any, sessions }; return { api: api as any, sessions };
} }
async function createWorkspaceFixture() {
return fs.mkdtemp(path.join(os.tmpdir(), "xworkmate-task-state-"));
}
describe("xworkmate task state mapping", () => { describe("xworkmate task state mapping", () => {
it("requires typed appThreadKey metadata", () => { it("requires typed appThreadKey metadata", () => {
expect(() => expect(() =>
@ -159,8 +166,56 @@ describe("xworkmate task state mapping", () => {
}); });
}); });
it("returns no_native_task_record instead of inferring success from artifacts", async () => { it("resolves completed snapshot from task artifacts when native task record is unavailable", async () => {
const { api } = createApiFixture(); 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({ await recordXWorkmateSessionMapping({
api, api,
params: { params: {

View File

@ -253,25 +253,54 @@ export async function getXWorkmateTaskSnapshot(input: {
runId, runId,
taskId, taskId,
}); });
const includeArtifacts = params.includeArtifacts !== false;
if (!task) { 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"; const code: XWorkmateTaskLookupErrorCode = runId || taskId ? "no_native_task_record" : "task_not_found";
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping); return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
} }
const taskStatus = optionalString((task as any).status) || "running"; const taskStatus = optionalString((task as any).status) || "running";
const includeArtifacts = params.includeArtifacts !== false;
const exported = includeArtifacts const exported = includeArtifacts
? await exportXWorkmateArtifacts({ ? await exportArtifactsForTaskLookup(
params: { input,
...params, params,
openclawSessionKey, openclawSessionKey,
runId: runId || optionalString((task as any).runId) || optionalString((task as any).taskId), runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs), mapping,
includeContent: params.includeContent ?? false, )
},
config: input.api.config,
pluginConfig: input.api.pluginConfig,
})
: undefined; : undefined;
return { return {
@ -296,6 +325,26 @@ export async function getXWorkmateTaskSnapshot(input: {
}; };
} }
async function exportArtifactsForTaskLookup(
input: { api: OpenClawPluginApi; params: Record<string, unknown> },
params: Record<string, unknown>,
openclawSessionKey: string,
runId: string,
mapping?: XWorkmateSessionMappingV1,
) {
return exportXWorkmateArtifacts({
params: {
...params,
openclawSessionKey,
runId,
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
includeContent: params.includeContent ?? false,
},
config: input.api.config,
pluginConfig: input.api.pluginConfig,
});
}
function resolveNativeTask( function resolveNativeTask(
api: OpenClawPluginApi, api: OpenClawPluginApi,
input: { openclawSessionKey: string; runId?: string; taskId?: string }, input: { openclawSessionKey: string; runId?: string; taskId?: string },