diff --git a/dist/index.js b/dist/index.js index 831e8ef..295a470 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; 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) { const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope; const runScope = resolveRunScope({ sessionScope }); @@ -65,6 +65,25 @@ function register(api) { api.logger?.warn?.(`xworkmate session_start preparation failed: ${String(error)}`); } }, { 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) => { try { const params = scopedGatewayParams(opts.params); @@ -82,6 +101,11 @@ function register(api) { config: api.config, pluginConfig: api.pluginConfig, }); + await recordXWorkmateTaskRunStarted({ + api, + openclawSessionKey: mapping.openclawSessionKey, + runId: stringParam(params.runId), + }); opts.respond(true, { ...payload, mapping, diff --git a/dist/src/taskState.d.ts b/dist/src/taskState.d.ts index 91bbfd8..5c37161 100644 --- a/dist/src/taskState.d.ts +++ b/dist/src/taskState.d.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; export declare const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping"; +export declare const XWORKMATE_TASK_RUNS_EXTENSION_NAMESPACE = "xworkmate.taskRuns"; export type XWorkmateTaskMetadataV1 = { schemaVersion: 1; appThreadKey: string; @@ -27,6 +28,16 @@ export type XWorkmateTaskLookupError = { mapping?: XWorkmateSessionMappingV1; 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 recordXWorkmateSessionMapping(input: { api: OpenClawPluginApi; @@ -34,6 +45,18 @@ export declare function recordXWorkmateSessionMapping(input: { artifactScope?: string; source?: XWorkmateSessionMappingSource; }): Promise; +export declare function recordXWorkmateTaskRunStarted(input: { + api: OpenClawPluginApi; + openclawSessionKey: string; + runId: string; +}): Promise; +export declare function recordXWorkmateTaskRunTerminal(input: { + api: OpenClawPluginApi; + openclawSessionKey: string; + runId: string; + success: boolean; + error?: unknown; +}): Promise; export declare function getXWorkmateTaskSnapshot(input: { api: OpenClawPluginApi; params: Record; diff --git a/dist/src/taskState.js b/dist/src/taskState.js index 0e96753..57f5269 100644 --- a/dist/src/taskState.js +++ b/dist/src/taskState.js @@ -2,6 +2,8 @@ import { exportXWorkmateArtifacts } from "./exportArtifacts.js"; import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; 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) { const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension; if (typeof registerExtension !== "function") { @@ -29,6 +31,29 @@ export async function recordXWorkmateSessionMapping(input) { 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) { const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const schemaVersion = Number(envelope.schemaVersion ?? 1); @@ -139,9 +164,50 @@ export async function getXWorkmateTaskSnapshot(input) { }); const includeArtifacts = params.includeArtifacts !== false; if (!task) { + const recordedRun = runId + ? readXWorkmateTaskRun(input.api, openclawSessionKey, runId) + : undefined; const exported = includeArtifacts && runId ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) : 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) { return { success: false, @@ -205,6 +271,97 @@ export async function getXWorkmateTaskSnapshot(input) { 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-") + .replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1") + .slice(0, 2048); +} async function exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) { return exportXWorkmateArtifacts({ params: { diff --git a/index.test.ts b/index.test.ts index 612c0ea..4eb3a9f 100644 --- a/index.test.ts +++ b/index.test.ts @@ -42,6 +42,7 @@ describe("plugin registration", () => { tools.push({ tool, options }); }, registerHook: () => undefined, + on: () => undefined, } as unknown as OpenClawPluginApi; plugin.register(api); @@ -73,6 +74,7 @@ describe("plugin registration", () => { }, registerTool: () => undefined, registerHook: () => undefined, + on: () => undefined, runtime: { agent: { session: { @@ -139,7 +141,7 @@ describe("plugin registration", () => { 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 methods = new Map(); - const hooks = new Map Promise>(); + const hooks = new Map Promise>(); const sessionExtensions: Array> = []; const sessionExtensionPatches: Array> = []; const detachedRuntimes: Array> = []; @@ -197,7 +199,10 @@ describe("plugin registration", () => { methods.set(method, handler); }, registerTool: () => undefined, - registerHook: (event: string, handler: (payload: unknown) => Promise) => { + registerHook: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise) => { + hooks.set(event, handler); + }, + on: (event: string, handler: (payload: unknown, ctx?: unknown) => Promise) => { hooks.set(event, handler); }, @@ -257,6 +262,15 @@ describe("plugin registration", () => { }); expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" }); 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 () => { @@ -266,6 +280,7 @@ describe("plugin registration", () => { pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") }, registerGatewayMethod: () => undefined, registerHook: () => undefined, + on: () => undefined, registerTool: (tool: unknown, options: unknown) => { tools.push({ tool, options }); }, @@ -295,6 +310,7 @@ describe("plugin registration", () => { pluginConfig: {}, registerGatewayMethod: () => undefined, registerHook: () => undefined, + on: () => undefined, registerTool: (tool: unknown, options: { names?: string[] }) => { tools.push({ tool, options }); }, @@ -325,6 +341,7 @@ describe("plugin registration", () => { pluginConfig: {}, registerGatewayMethod: () => undefined, registerHook: () => undefined, + on: () => undefined, registerTool: (tool: unknown, options: unknown) => { tools.push({ tool, options }); }, diff --git a/index.ts b/index.ts index 6b4c4b6..c47ba9a 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,8 @@ import { import { getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, + recordXWorkmateTaskRunStarted, + recordXWorkmateTaskRunTerminal, registerXWorkmateSessionExtension, } from "./src/taskState.js"; @@ -123,6 +125,28 @@ function register(api: OpenClawPluginApi) { { 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) => { try { const params = scopedGatewayParams(opts.params); @@ -140,6 +164,11 @@ function register(api: OpenClawPluginApi) { config: api.config, pluginConfig: api.pluginConfig, }); + await recordXWorkmateTaskRunStarted({ + api, + openclawSessionKey: mapping.openclawSessionKey, + runId: stringParam(params.runId), + }); opts.respond( true, { diff --git a/src/taskState.test.ts b/src/taskState.test.ts index f920e17..b955e6f 100644 --- a/src/taskState.test.ts +++ b/src/taskState.test.ts @@ -6,6 +6,8 @@ import { XWORKMATE_SESSION_EXTENSION_NAMESPACE, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, + recordXWorkmateTaskRunStarted, + recordXWorkmateTaskRunTerminal, } from "./taskState.js"; 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=", + }); + }); + + 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 () => { const { api } = createApiFixture({ "draft:legacy:run-1": { diff --git a/src/taskState.ts b/src/taskState.ts index 3eda9ca..b63542c 100644 --- a/src/taskState.ts +++ b/src/taskState.ts @@ -4,6 +4,8 @@ import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js"; const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; 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 = { schemaVersion: 1; @@ -44,6 +46,17 @@ export type XWorkmateTaskLookupError = { 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 & { pluginExtensions?: Record>; }; @@ -102,6 +115,41 @@ export async function recordXWorkmateSessionMapping(input: { }); } +export async function recordXWorkmateTaskRunStarted(input: { + api: OpenClawPluginApi; + openclawSessionKey: string; + runId: string; +}): Promise { + 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 { + 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): XWorkmateTaskMetadataV1 { const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input; const schemaVersion = Number(envelope.schemaVersion ?? 1); @@ -233,9 +281,50 @@ export async function getXWorkmateTaskSnapshot(input: { }); const includeArtifacts = params.includeArtifacts !== false; if (!task) { + const recordedRun = runId + ? readXWorkmateTaskRun(input.api, openclawSessionKey, runId) + : undefined; const exported = includeArtifacts && runId ? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping) : 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) { return { success: false, @@ -308,6 +397,113 @@ export async function getXWorkmateTaskSnapshot(input: { }; } +async function upsertXWorkmateTaskRun( + api: OpenClawPluginApi, + input: Omit & { + openclawSessionKey: string; + startedAt?: string; + }, +): Promise { + 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 { + 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 = {}; + 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-") + .replace(/(api[_ -]?key\s*[:=]\s*)[^\s,;]+/gi, "$1") + .slice(0, 2048); +} + async function exportArtifactsForTaskLookup( input: { api: OpenClawPluginApi; params: Record }, params: Record,