fix(tasks): persist gateway agent terminal state

This commit is contained in:
Haitao Pan 2026-06-27 12:02:51 +08:00
parent baddb2f13d
commit d5f0e9f437
7 changed files with 535 additions and 3 deletions

26
dist/index.js vendored
View File

@ -1,7 +1,7 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js"; import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateSessionExtension, } from "./src/taskState.js"; import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, recordXWorkmateTaskRunStarted, recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js";
function scopedGatewayParams(params) { function scopedGatewayParams(params) {
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope; const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
const runScope = resolveRunScope({ sessionScope }); const runScope = resolveRunScope({ sessionScope });
@ -65,6 +65,25 @@ function register(api) {
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" }); }, { name: "openclaw-multi-session-plugins.session-start" });
api.on("agent_end", async (event, ctx) => {
try {
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
const runId = stringParam(event?.runId ?? ctx?.runId);
if (!openclawSessionKey || !runId) {
return;
}
await recordXWorkmateTaskRunTerminal({
api,
openclawSessionKey,
runId,
success: event?.success === true,
error: event?.error,
});
}
catch (error) {
api.logger?.warn?.(`xworkmate agent_end state capture failed: ${String(error)}`);
}
});
api.registerGatewayMethod("xworkmate.session.prepare", async (opts) => { api.registerGatewayMethod("xworkmate.session.prepare", async (opts) => {
try { try {
const params = scopedGatewayParams(opts.params); const params = scopedGatewayParams(opts.params);
@ -82,6 +101,11 @@ function register(api) {
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
await recordXWorkmateTaskRunStarted({
api,
openclawSessionKey: mapping.openclawSessionKey,
runId: stringParam(params.runId),
});
opts.respond(true, { opts.respond(true, {
...payload, ...payload,
mapping, mapping,

View File

@ -1,5 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export declare const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
export type XWorkmateTaskMetadataV1 = { export type XWorkmateTaskMetadataV1 = {
schemaVersion: 1; schemaVersion: 1;
appThreadKey: string; appThreadKey: string;
@ -27,6 +28,16 @@ export type XWorkmateTaskLookupError = {
mapping?: XWorkmateSessionMappingV1; mapping?: XWorkmateSessionMappingV1;
expectedArtifactDirs?: string[]; expectedArtifactDirs?: string[];
}; };
export type XWorkmateRecordedTaskRunV1 = {
schemaVersion: 1;
runId: string;
status: "running" | "completed" | "failed";
success: boolean;
startedAt: string;
updatedAt: string;
completedAt?: string;
error?: string;
};
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void; export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
export declare function recordXWorkmateSessionMapping(input: { export declare function recordXWorkmateSessionMapping(input: {
api: OpenClawPluginApi; api: OpenClawPluginApi;
@ -34,6 +45,18 @@ export declare function recordXWorkmateSessionMapping(input: {
artifactScope?: string; artifactScope?: string;
source?: XWorkmateSessionMappingSource; source?: XWorkmateSessionMappingSource;
}): Promise<XWorkmateSessionMappingV1>; }): Promise<XWorkmateSessionMappingV1>;
export declare function recordXWorkmateTaskRunStarted(input: {
api: OpenClawPluginApi;
openclawSessionKey: string;
runId: string;
}): Promise<XWorkmateRecordedTaskRunV1>;
export declare function recordXWorkmateTaskRunTerminal(input: {
api: OpenClawPluginApi;
openclawSessionKey: string;
runId: string;
success: boolean;
error?: unknown;
}): Promise<XWorkmateRecordedTaskRunV1>;
export declare function getXWorkmateTaskSnapshot(input: { export declare function getXWorkmateTaskSnapshot(input: {
api: OpenClawPluginApi; api: OpenClawPluginApi;
params: Record<string, unknown>; params: Record<string, unknown>;

157
dist/src/taskState.js vendored
View File

@ -2,6 +2,8 @@ import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js"; import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
const MAX_RECORDED_TASK_RUNS = 32;
export function registerXWorkmateSessionExtension(api) { export function registerXWorkmateSessionExtension(api) {
const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension; const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension;
if (typeof registerExtension !== "function") { if (typeof registerExtension !== "function") {
@ -29,6 +31,29 @@ export async function recordXWorkmateSessionMapping(input) {
source: input.source ?? "bridge_prepare", source: input.source ?? "bridge_prepare",
}); });
} }
export async function recordXWorkmateTaskRunStarted(input) {
const now = new Date().toISOString();
return upsertXWorkmateTaskRun(input.api, {
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
runId: requiredString(input.runId, "runId required"),
status: "running",
success: false,
startedAt: now,
updatedAt: now,
});
}
export async function recordXWorkmateTaskRunTerminal(input) {
const now = new Date().toISOString();
return upsertXWorkmateTaskRun(input.api, {
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
runId: requiredString(input.runId, "runId required"),
status: input.success ? "completed" : "failed",
success: input.success,
updatedAt: now,
completedAt: now,
error: sanitizeTaskRunError(input.error),
});
}
function normalizeXWorkmateTaskMetadataV1(input) { function normalizeXWorkmateTaskMetadataV1(input) {
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
const schemaVersion = Number(envelope.schemaVersion ?? 1); const schemaVersion = Number(envelope.schemaVersion ?? 1);
@ -139,9 +164,50 @@ export async function getXWorkmateTaskSnapshot(input) {
}); });
const includeArtifacts = params.includeArtifacts !== false; const includeArtifacts = params.includeArtifacts !== false;
if (!task) { if (!task) {
const recordedRun = runId
? readXWorkmateTaskRun(input.api, openclawSessionKey, runId)
: undefined;
const exported = includeArtifacts && runId const exported = includeArtifacts && runId
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
: undefined; : undefined;
if (recordedRun) {
return {
success: recordedRun.status === "running" ? true : recordedRun.success,
status: recordedRun.status,
taskStatus: recordedRun.status,
terminal: recordedRun.status !== "running",
terminalSource: "agent_end",
mode: "gateway-chat",
mapping,
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
openclawSessionKey,
runId,
taskId: taskId || runId,
task: {
taskId: taskId || runId,
runId,
status: recordedRun.status,
success: recordedRun.success,
source: "xworkmate_run_state",
startedAt: recordedRun.startedAt,
updatedAt: recordedRun.updatedAt,
completedAt: recordedRun.completedAt,
error: recordedRun.error,
},
error: recordedRun.error,
message: recordedRun.error,
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
artifactScope: exported?.artifactScope,
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
scopeKind: exported?.scopeKind,
artifacts: exported?.artifacts ?? [],
constraintSatisfied: exported?.constraintSatisfied,
missingRequiredExtensions: exported?.missingRequiredExtensions,
warnings: exported?.warnings ?? [],
artifactCount: exported?.artifacts.length ?? 0,
};
}
if (exported?.artifacts.length) { if (exported?.artifacts.length) {
return { return {
success: false, success: false,
@ -205,6 +271,97 @@ export async function getXWorkmateTaskSnapshot(input) {
artifactCount: exported?.artifacts.length ?? 0, artifactCount: exported?.artifacts.length ?? 0,
}; };
} }
async function upsertXWorkmateTaskRun(api, input) {
const patchSessionEntry = resolvePatchSessionEntry(api);
if (!patchSessionEntry) {
throw new Error("OpenClaw runtime session patch API is unavailable");
}
let recorded;
await patchSessionEntry({
sessionKey: input.openclawSessionKey,
fallbackEntry: {
sessionId: input.openclawSessionKey,
updatedAt: Date.now(),
},
preserveActivity: true,
update: (entry) => {
const runs = readTaskRunsFromEntry(entry);
const existing = runs[input.runId];
recorded = compactObject({
schemaVersion: 1,
runId: input.runId,
status: input.status,
success: input.success,
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
updatedAt: input.updatedAt,
completedAt: input.completedAt,
error: input.error,
});
runs[input.runId] = recorded;
const boundedRuns = Object.fromEntries(Object.entries(runs)
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
.slice(0, MAX_RECORDED_TASK_RUNS));
return {
pluginExtensions: {
...(entry.pluginExtensions ?? {}),
[XWORKMATE_PLUGIN_ID]: {
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
schemaVersion: 1,
runs: boundedRuns,
},
},
},
};
},
});
if (!recorded) {
throw new Error("failed to write xworkmate task run state");
}
return recorded;
}
function readXWorkmateTaskRun(api, openclawSessionKey, runId) {
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
return readTaskRunsFromEntry(entry)[runId];
}
function readTaskRunsFromEntry(entry) {
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
if (store?.schemaVersion !== 1) {
return {};
}
const runs = asRecord(store.runs) ?? {};
const result = {};
for (const [key, rawValue] of Object.entries(runs)) {
const raw = asRecord(rawValue);
const runId = optionalString(raw?.runId) || key;
const status = optionalString(raw?.status);
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
continue;
}
result[runId] = compactObject({
schemaVersion: 1,
runId,
status,
success: raw?.success === true,
startedAt: optionalString(raw?.startedAt) || new Date(0).toISOString(),
updatedAt: optionalString(raw?.updatedAt) || new Date(0).toISOString(),
completedAt: optionalString(raw?.completedAt),
error: optionalString(raw?.error),
});
}
return result;
}
function sanitizeTaskRunError(value) {
const raw = optionalString(value);
if (!raw) {
return undefined;
}
return raw
.replace(/\b(sk|nvapi)-[A-Za-z0-9._-]+\b/gi, "$1-<redacted>")
.replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1<redacted>")
.slice(0, 2048);
}
async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) { async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) {
return exportXWorkmateArtifacts({ return exportXWorkmateArtifacts({
params: { params: {

View File

@ -42,6 +42,7 @@ describe("plugin registration", () => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
registerHook: () => undefined, registerHook: () => undefined,
on: () => undefined,
} as unknown as OpenClawPluginApi; } as unknown as OpenClawPluginApi;
plugin.register(api); plugin.register(api);
@ -73,6 +74,7 @@ describe("plugin registration", () => {
}, },
registerTool: () => undefined, registerTool: () => undefined,
registerHook: () => undefined, registerHook: () => undefined,
on: () => undefined,
runtime: { runtime: {
agent: { agent: {
session: { session: {
@ -139,7 +141,7 @@ describe("plugin registration", () => {
it("registers xworkmate task state against the native session extension and task runtime seams", async () => { it("registers xworkmate task state against the native session extension and task runtime seams", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-task-state-")); const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-task-state-"));
const methods = new Map<string, GatewayMethodHandler>(); const methods = new Map<string, GatewayMethodHandler>();
const hooks = new Map<string, (event: unknown) => Promise<void>>(); const hooks = new Map<string, (event: unknown, ctx?: unknown) => Promise<void>>();
const sessionExtensions: Array<Record<string, unknown>> = []; const sessionExtensions: Array<Record<string, unknown>> = [];
const sessionExtensionPatches: Array<Record<string, unknown>> = []; const sessionExtensionPatches: Array<Record<string, unknown>> = [];
const detachedRuntimes: Array<Record<string, unknown>> = []; const detachedRuntimes: Array<Record<string, unknown>> = [];
@ -197,7 +199,10 @@ describe("plugin registration", () => {
methods.set(method, handler); methods.set(method, handler);
}, },
registerTool: () => undefined, registerTool: () => undefined,
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => { registerHook: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
hooks.set(event, handler);
},
on: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise<void>) => {
hooks.set(event, handler); hooks.set(event, handler);
}, },
@ -257,6 +262,15 @@ describe("plugin registration", () => {
}); });
expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" }); expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" });
expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]); expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]);
await hooks.get("agent_end")?.(
{ runId: "turn-1", success: false, error: "401 authentication failed" },
{ sessionKey: "draft:1780636411666238-3", runId: "turn-1" },
);
expect(sessionExtensionPatches.at(-1)).toMatchObject({
sessionKey: "draft:1780636411666238-3",
preserveActivity: true,
});
}); });
it("does not invent default session or run ids for the optional agent tool", async () => { it("does not invent default session or run ids for the optional agent tool", async () => {
@ -266,6 +280,7 @@ describe("plugin registration", () => {
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") }, pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
registerGatewayMethod: () => undefined, registerGatewayMethod: () => undefined,
registerHook: () => undefined, registerHook: () => undefined,
on: () => undefined,
registerTool: (tool: unknown, options: unknown) => { registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
@ -295,6 +310,7 @@ describe("plugin registration", () => {
pluginConfig: {}, pluginConfig: {},
registerGatewayMethod: () => undefined, registerGatewayMethod: () => undefined,
registerHook: () => undefined, registerHook: () => undefined,
on: () => undefined,
registerTool: (tool: unknown, options: { names?: string[] }) => { registerTool: (tool: unknown, options: { names?: string[] }) => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },
@ -325,6 +341,7 @@ describe("plugin registration", () => {
pluginConfig: {}, pluginConfig: {},
registerGatewayMethod: () => undefined, registerGatewayMethod: () => undefined,
registerHook: () => undefined, registerHook: () => undefined,
on: () => undefined,
registerTool: (tool: unknown, options: unknown) => { registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options }); tools.push({ tool, options });
}, },

View File

@ -15,6 +15,8 @@ import {
import { import {
getXWorkmateTaskSnapshot, getXWorkmateTaskSnapshot,
recordXWorkmateSessionMapping, recordXWorkmateSessionMapping,
recordXWorkmateTaskRunStarted,
recordXWorkmateTaskRunTerminal,
registerXWorkmateSessionExtension, registerXWorkmateSessionExtension,
} from "./src/taskState.js"; } from "./src/taskState.js";
@ -123,6 +125,28 @@ function register(api: OpenClawPluginApi) {
{ name: "openclaw-multi-session-plugins.session-start" }, { name: "openclaw-multi-session-plugins.session-start" },
); );
api.on(
"agent_end",
async (event: any, ctx: any) => {
try {
const openclawSessionKey = stringParam(ctx?.sessionKey ?? event?.sessionKey);
const runId = stringParam(event?.runId ?? ctx?.runId);
if (!openclawSessionKey || !runId) {
return;
}
await recordXWorkmateTaskRunTerminal({
api,
openclawSessionKey,
runId,
success: event?.success === true,
error: event?.error,
});
} catch (error) {
api.logger?.warn?.(`xworkmate agent_end state capture failed: ${String(error)}`);
}
},
);
api.registerGatewayMethod("xworkmate.session.prepare", async (opts: GatewayRequestHandlerOptions) => { api.registerGatewayMethod("xworkmate.session.prepare", async (opts: GatewayRequestHandlerOptions) => {
try { try {
const params = scopedGatewayParams(opts.params); const params = scopedGatewayParams(opts.params);
@ -140,6 +164,11 @@ function register(api: OpenClawPluginApi) {
config: api.config, config: api.config,
pluginConfig: api.pluginConfig, pluginConfig: api.pluginConfig,
}); });
await recordXWorkmateTaskRunStarted({
api,
openclawSessionKey: mapping.openclawSessionKey,
runId: stringParam(params.runId),
});
opts.respond( opts.respond(
true, true,
{ {

View File

@ -6,6 +6,8 @@ import {
XWORKMATE_SESSION_EXTENSION_NAMESPACE, XWORKMATE_SESSION_EXTENSION_NAMESPACE,
getXWorkmateTaskSnapshot, getXWorkmateTaskSnapshot,
recordXWorkmateSessionMapping, recordXWorkmateSessionMapping,
recordXWorkmateTaskRunStarted,
recordXWorkmateTaskRunTerminal,
} from "./taskState.js"; } from "./taskState.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
@ -252,6 +254,90 @@ describe("xworkmate task state mapping", () => {
}); });
}); });
it("returns a durable failed agent terminal state when the native task record is absent", async () => {
const workspaceDir = await createWorkspaceFixture();
const { api } = createApiFixture({}, { workspaceDir });
await recordXWorkmateSessionMapping({
api,
params: {
appThreadKey: "draft:failed-run",
openclawSessionKey: "agent:main:draft:failed-run",
runId: "turn-failed",
},
});
await recordXWorkmateTaskRunStarted({
api,
openclawSessionKey: "agent:main:draft:failed-run",
runId: "turn-failed",
});
await recordXWorkmateTaskRunTerminal({
api,
openclawSessionKey: "agent:main:draft:failed-run",
runId: "turn-failed",
success: false,
error: "401 Authentication Fails, api_key=sk-secret-value",
});
await expect(
getXWorkmateTaskSnapshot({
api,
params: {
appThreadKey: "draft:failed-run",
runId: "turn-failed",
},
}),
).resolves.toMatchObject({
success: false,
status: "failed",
taskStatus: "failed",
terminal: true,
terminalSource: "agent_end",
task: {
runId: "turn-failed",
status: "failed",
source: "xworkmate_run_state",
},
error: "401 Authentication Fails, api_key=<redacted>",
});
});
it("returns a recorded running state while the agent turn is still active", async () => {
const { api } = createApiFixture();
await recordXWorkmateSessionMapping({
api,
params: {
appThreadKey: "draft:running-run",
openclawSessionKey: "agent:main:draft:running-run",
runId: "turn-running",
},
});
await recordXWorkmateTaskRunStarted({
api,
openclawSessionKey: "agent:main:draft:running-run",
runId: "turn-running",
});
await expect(
getXWorkmateTaskSnapshot({
api,
params: {
appThreadKey: "draft:running-run",
runId: "turn-running",
includeArtifacts: false,
},
}),
).resolves.toMatchObject({
success: true,
status: "running",
terminal: false,
task: {
runId: "turn-running",
status: "running",
source: "xworkmate_run_state",
},
});
});
it("does not accept legacy sessionKey as a task lookup alias", async () => { it("does not accept legacy sessionKey as a task lookup alias", async () => {
const { api } = createApiFixture({ const { api } = createApiFixture({
"draft:legacy:run-1": { "draft:legacy:run-1": {

View File

@ -4,6 +4,8 @@ import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
export const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns";
const MAX_RECORDED_TASK_RUNS = 32;
export type XWorkmateTaskMetadataV1 = { export type XWorkmateTaskMetadataV1 = {
schemaVersion: 1; schemaVersion: 1;
@ -44,6 +46,17 @@ export type XWorkmateTaskLookupError = {
expectedArtifactDirs?: string[]; expectedArtifactDirs?: string[];
}; };
export type XWorkmateRecordedTaskRunV1 = {
schemaVersion: 1;
runId: string;
status: "running" | "completed" | "failed";
success: boolean;
startedAt: string;
updatedAt: string;
completedAt?: string;
error?: string;
};
type SessionEntry = Record<string, unknown> & { type SessionEntry = Record<string, unknown> & {
pluginExtensions?: Record<string, Record<string, unknown>>; pluginExtensions?: Record<string, Record<string, unknown>>;
}; };
@ -102,6 +115,41 @@ export async function recordXWorkmateSessionMapping(input: {
}); });
} }
export async function recordXWorkmateTaskRunStarted(input: {
api: OpenClawPluginApi;
openclawSessionKey: string;
runId: string;
}): Promise<XWorkmateRecordedTaskRunV1> {
const now = new Date().toISOString();
return upsertXWorkmateTaskRun(input.api, {
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
runId: requiredString(input.runId, "runId required"),
status: "running",
success: false,
startedAt: now,
updatedAt: now,
});
}
export async function recordXWorkmateTaskRunTerminal(input: {
api: OpenClawPluginApi;
openclawSessionKey: string;
runId: string;
success: boolean;
error?: unknown;
}): Promise<XWorkmateRecordedTaskRunV1> {
const now = new Date().toISOString();
return upsertXWorkmateTaskRun(input.api, {
openclawSessionKey: requiredString(input.openclawSessionKey, "openclawSessionKey required"),
runId: requiredString(input.runId, "runId required"),
status: input.success ? "completed" : "failed",
success: input.success,
updatedAt: now,
completedAt: now,
error: sanitizeTaskRunError(input.error),
});
}
function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 { function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 {
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
const schemaVersion = Number(envelope.schemaVersion ?? 1); const schemaVersion = Number(envelope.schemaVersion ?? 1);
@ -233,9 +281,50 @@ export async function getXWorkmateTaskSnapshot(input: {
}); });
const includeArtifacts = params.includeArtifacts !== false; const includeArtifacts = params.includeArtifacts !== false;
if (!task) { if (!task) {
const recordedRun = runId
? readXWorkmateTaskRun(input.api, openclawSessionKey, runId)
: undefined;
const exported = includeArtifacts && runId const exported = includeArtifacts && runId
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
: undefined; : undefined;
if (recordedRun) {
return {
success: recordedRun.status === "running" ? true : recordedRun.success,
status: recordedRun.status,
taskStatus: recordedRun.status,
terminal: recordedRun.status !== "running",
terminalSource: "agent_end",
mode: "gateway-chat",
mapping,
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
openclawSessionKey,
runId,
taskId: taskId || runId,
task: {
taskId: taskId || runId,
runId,
status: recordedRun.status,
success: recordedRun.success,
source: "xworkmate_run_state",
startedAt: recordedRun.startedAt,
updatedAt: recordedRun.updatedAt,
completedAt: recordedRun.completedAt,
error: recordedRun.error,
},
error: recordedRun.error,
message: recordedRun.error,
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
artifactScope: exported?.artifactScope,
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
scopeKind: exported?.scopeKind,
artifacts: exported?.artifacts ?? [],
constraintSatisfied: exported?.constraintSatisfied,
missingRequiredExtensions: exported?.missingRequiredExtensions,
warnings: exported?.warnings ?? [],
artifactCount: exported?.artifacts.length ?? 0,
};
}
if (exported?.artifacts.length) { if (exported?.artifacts.length) {
return { return {
success: false, success: false,
@ -308,6 +397,113 @@ export async function getXWorkmateTaskSnapshot(input: {
}; };
} }
async function upsertXWorkmateTaskRun(
api: OpenClawPluginApi,
input: Omit<XWorkmateRecordedTaskRunV1, "schemaVersion" | "startedAt"> & {
openclawSessionKey: string;
startedAt?: string;
},
): Promise<XWorkmateRecordedTaskRunV1> {
const patchSessionEntry = resolvePatchSessionEntry(api);
if (!patchSessionEntry) {
throw new Error("OpenClaw runtime session patch API is unavailable");
}
let recorded: XWorkmateRecordedTaskRunV1 | undefined;
await patchSessionEntry({
sessionKey: input.openclawSessionKey,
fallbackEntry: {
sessionId: input.openclawSessionKey,
updatedAt: Date.now(),
},
preserveActivity: true,
update: (entry) => {
const runs = readTaskRunsFromEntry(entry);
const existing = runs[input.runId];
recorded = compactObject({
schemaVersion: 1 as const,
runId: input.runId,
status: input.status,
success: input.success,
startedAt: existing?.startedAt ?? input.startedAt ?? input.updatedAt,
updatedAt: input.updatedAt,
completedAt: input.completedAt,
error: input.error,
}) as XWorkmateRecordedTaskRunV1;
runs[input.runId] = recorded;
const boundedRuns = Object.fromEntries(
Object.entries(runs)
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
.slice(0, MAX_RECORDED_TASK_RUNS),
);
return {
pluginExtensions: {
...(entry.pluginExtensions ?? {}),
[XWORKMATE_PLUGIN_ID]: {
...(entry.pluginExtensions?.[XWORKMATE_PLUGIN_ID] ?? {}),
[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]: {
schemaVersion: 1,
runs: boundedRuns,
},
},
},
};
},
});
if (!recorded) {
throw new Error("failed to write xworkmate task run state");
}
return recorded;
}
function readXWorkmateTaskRun(
api: OpenClawPluginApi,
openclawSessionKey: string,
runId: string,
): XWorkmateRecordedTaskRunV1 | undefined {
const entry = resolveGetSessionEntry(api)?.({ sessionKey: openclawSessionKey });
return readTaskRunsFromEntry(entry)[runId];
}
function readTaskRunsFromEntry(entry: SessionEntry | undefined | null): Record<string, XWorkmateRecordedTaskRunV1> {
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
const store = asRecord(pluginState?.[XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE]);
if (store?.schemaVersion !== 1) {
return {};
}
const runs = asRecord(store.runs) ?? {};
const result: Record<string, XWorkmateRecordedTaskRunV1> = {};
for (const [key, rawValue] of Object.entries(runs)) {
const raw = asRecord(rawValue);
const runId = optionalString(raw?.runId) || key;
const status = optionalString(raw?.status);
if (!runId || (status !== "running" && status !== "completed" && status !== "failed")) {
continue;
}
result[runId] = compactObject({
schemaVersion: 1 as const,
runId,
status,
success: raw?.success === true,
startedAt: optionalString(raw?.startedAt) || new Date(0).toISOString(),
updatedAt: optionalString(raw?.updatedAt) || new Date(0).toISOString(),
completedAt: optionalString(raw?.completedAt),
error: optionalString(raw?.error),
}) as XWorkmateRecordedTaskRunV1;
}
return result;
}
function sanitizeTaskRunError(value: unknown): string | undefined {
const raw = optionalString(value);
if (!raw) {
return undefined;
}
return raw
.replace(/\b(sk|nvapi)-[A-Za-z0-9._-]+\b/gi, "$1-<redacted>")
.replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1<redacted>")
.slice(0, 2048);
}
async function exportArtifactsForTaskLookup( async function exportArtifactsForTaskLookup(
input: { api: OpenClawPluginApi; params: Record<string, unknown> }, input: { api: OpenClawPluginApi; params: Record<string, unknown> },
params: Record<string, unknown>, params: Record<string, unknown>,