diff --git a/README.md b/README.md index 7d5d739..a877627 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifac ## Why -XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing -`/gateway/openclaw` task contract. The bridge sends `chat.send`, waits for -`agent.wait`, then asks this plugin for a session/run-scoped artifact manifest. +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 changing the UI or adding provider-specific routes. -This plugin is not a scheduler. OpenClaw core owns sub-agents, multi-agent -routing, queues, cron, and cross-session execution. This package only adapts -those existing OpenClaw multi-task/session identities into isolated artifact -directories and signed artifact reads. +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. It registers four Gateway methods: @@ -141,12 +142,15 @@ Export response payload: Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`. When `artifactScope` is omitted, export/list defaults to the current task scope derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task -scope. The plugin does not adopt files from the workspace root; agents must -write final deliverables directly into the prepared `artifactDirectory`. +scope. The prepared task scope remains authoritative: when it contains files, +the plugin exports only that scope. -The plugin never scans workspace root, `owners/*/threads/*`, or any previous -thread workspace as a fallback and does not borrow artifacts from earlier task -scopes. +If the prepared task scope is empty, trusted Gateway callers may pass +`expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then +scans only those explicit workspace-root subdirectories and labels the exported +files with the current task `artifactScope`. It never performs a broad workspace +root scan, never scans `owners/*/threads/*`, and does not borrow artifacts from +earlier task scopes. Each exported artifact includes `artifactRef`, a plugin-signed reference over the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts @@ -186,17 +190,12 @@ Gateway clients can use: pipeline, not in `chat.send` params. If `chat.send` returns a different OpenClaw `runId`, prepare/export with that actual `runId` instead of the bridge request id. -- `openclaw_multi_session_agents` from an OpenClaw task to call XWorkmate Bridge - `/acp/rpc` with `multiAgent=true`, while deriving `sessionKey`, `runId`, and - `workspaceDir` from the host task context instead of model-controlled tool - parameters. -- `xworkmate.agents.run` for trusted gateway callers that need the same - bridge-backed multi-agent run and artifact-scope export in one method. - `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. - `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file. - `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file. - `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope. -- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. +- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. Pass `expectedArtifactDirs` when the task contract declares root-level delivery directories. +- `xworkmate.tasks.get` to read the OpenClaw native task state for a run and return the current artifact export in the same payload. Large files are metadata-only in the export payload, but XWorkmate Bridge can generate its own signed download URL and call `xworkmate.artifacts.read` as the @@ -207,7 +206,7 @@ only remote file access path. - Only files inside the resolved OpenClaw workspace are exported. - `.git`, `.openclaw`, `.xworkmate`, `.pi`, transient framework state, and dependency folders are excluded from task artifact exports. - `dist/`, `build/`, and other delivery directories inside the prepared task scope are exported recursively. -- Export never adopts files from the workspace root or OpenClaw owner/thread workspaces; agents must write into the prepared task scope. +- Export scans workspace-root files only from explicit `expectedArtifactDirs`, only when the prepared task scope is empty, and never from OpenClaw owner/thread workspaces. - Symlinks are skipped to avoid workspace escape. - Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined. - `artifactScope` must be `tasks//`. diff --git a/dist/index.js b/dist/index.js index 47b34fa..dd543f6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,6 @@ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js"; -import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; +import { createOrUpdateXWorkmateTaskRecord, createXWorkmateTaskStore, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateDetachedTaskRuntime, registerXWorkmateSessionExtension, } from "./src/taskState.js"; function scopedGatewayParams(params) { const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope; const runScope = resolveRunScope({ sessionScope }); @@ -37,19 +37,49 @@ const plugin = { }; export default plugin; function register(api) { + const taskStore = createXWorkmateTaskStore(); + registerXWorkmateSessionExtension(api); + registerXWorkmateDetachedTaskRuntime(api, taskStore); api.registerHook("session.start", async (event) => { try { const params = scopedGatewayParams(event?.context ?? event); if (params.sessionKey && params.runId) { - await prepareXWorkmateArtifacts({ + createOrUpdateXWorkmateTaskRecord(taskStore, { + params, + status: "running", + progressSummary: "OpenClaw task is running", + }); + const prepared = await prepareXWorkmateArtifacts({ params, config: api.config, pluginConfig: api.pluginConfig, }); + await recordXWorkmateSessionMapping({ + api, + taskStore, + params, + artifactScope: prepared.artifactScope, + }); } } - catch (e) { - // Ignored: best-effort preparation + catch (error) { + api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`); + } + }, { name: "openclaw-multi-session-plugins.session-start" }); + api.registerGatewayMethod("xworkmate.tasks.get", async (opts) => { + try { + const payload = await getXWorkmateTaskSnapshot({ + api, + taskStore, + params: scopedGatewayParams(opts.params), + }); + 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.prepare", async (opts) => { @@ -132,30 +162,10 @@ function register(api) { }); } }); - api.registerGatewayMethod("xworkmate.agents.run", async (opts) => { - try { - const payload = await runXWorkmateBridgeAgents({ - 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.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { names: ["openclaw_multi_session_artifacts"], optional: true, }); - api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), { - names: ["openclaw_multi_session_agents"], - optional: true, - }); } function createXWorkmateArtifactsTool(api, ctx) { return { @@ -248,92 +258,3 @@ function createXWorkmateArtifactsTool(api, ctx) { }, }; } -function createXWorkmateAgentsTool(api, ctx) { - return { - name: "openclaw_multi_session_agents", - label: "XWorkmate multi-agent bridge", - description: "Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - taskPrompt: { - type: "string", - description: "Overall multi-agent task prompt.", - }, - mode: { - type: "string", - enum: ["sequence", "parallel", "race", "conversation"], - description: "Multi-agent orchestration mode.", - }, - steps: { - type: "array", - description: "Agent steps. Each item needs providerId and prompt.", - items: { - type: "object", - additionalProperties: false, - properties: { - providerId: { type: "string" }, - prompt: { type: "string" }, - outputAs: { type: "string" }, - timeoutMs: { type: "number" }, - }, - required: ["providerId", "prompt"], - }, - }, - participants: { - type: "array", - description: "Conversation participants by providerId.", - items: { type: "string" }, - }, - maxTurns: { - type: "number", - description: "Maximum turns for conversation mode.", - }, - stopConditions: { - type: "array", - description: "Text markers that stop conversation mode.", - items: { type: "string" }, - }, - timeoutMs: { - type: "number", - description: "Overall bridge request timeout.", - }, - }, - required: ["taskPrompt"], - }, - async execute(_id, params) { - const runScope = resolveRunScope(ctx); - const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; - const runId = ctx.sessionScope?.runId || ctx.runId || ""; - if (!sessionKey) { - throw new Error("sessionKey required"); - } - if (!runId) { - throw new Error("runId required"); - } - const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir; - const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params; - const payload = await runXWorkmateBridgeAgents({ - params: { - ...operationParams, - sessionKey, - runId, - ...(workspaceDir ? { workspaceDir } : {}), - ...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}), - }, - config: ctx.config ?? api.config, - pluginConfig: api.pluginConfig, - }); - const summary = typeof payload.bridgeResult.summary === "string" - ? payload.bridgeResult.summary - : typeof payload.bridgeResult.output === "string" - ? payload.bridgeResult.output - : "Multi-agent run completed."; - return { - content: [{ type: "text", text: [summary, "", formatArtifactManifestMarkdown(payload)].join("\n") }], - details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult }, - }; - }, - }; -} diff --git a/dist/src/bridgeAgents.d.ts b/dist/src/bridgeAgents.d.ts deleted file mode 100644 index 2c3a72f..0000000 --- a/dist/src/bridgeAgents.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type XWorkmateArtifactExport } from "./exportArtifacts.js"; -type BridgeAgentInput = { - params: Record; - config?: unknown; - pluginConfig?: Record; -}; -type BridgeAgentRun = XWorkmateArtifactExport & { - bridgeResult: Record; -}; -export declare function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise; -export {}; diff --git a/dist/src/bridgeAgents.js b/dist/src/bridgeAgents.js deleted file mode 100644 index 5c898ad..0000000 --- a/dist/src/bridgeAgents.js +++ /dev/null @@ -1,205 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, } from "./exportArtifacts.js"; -export async function runXWorkmateBridgeAgents(input) { - const params = input.params ?? {}; - const pluginConfig = input.pluginConfig ?? {}; - const sessionKey = requiredString(params.sessionKey, "sessionKey required"); - const runId = requiredString(params.runId, "runId required"); - const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required"); - const bridgeUrl = bridgeRpcUrl(pluginConfig); - const bridgeToken = bridgeAuthToken(pluginConfig); - if (!bridgeToken) { - throw new Error("bridgeToken required"); - } - const prepared = await prepareXWorkmateArtifacts({ - params: { sessionKey, runId, workspaceDir: params.workspaceDir }, - config: input.config, - pluginConfig, - }); - const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence"; - const participants = safeStringList(params.participants); - const steps = safeSteps(params.steps, participants.length > 0); - if (steps.length === 0 && participants.length === 0) { - throw new Error("steps or participants required"); - } - const routing = { - orchestrationMode, - steps, - }; - if (participants.length > 0) { - routing.participants = participants; - } - const maxTurns = positiveInteger(params.maxTurns, 0); - if (maxTurns > 0) { - routing.maxTurns = maxTurns; - } - const stopConditions = safeStringList(params.stopConditions); - if (stopConditions.length > 0) { - routing.stopConditions = stopConditions; - } - const bridgeResult = await callBridgeRPC({ - bridgeUrl, - bridgeToken, - timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)), - body: { - jsonrpc: "2.0", - id: `openclaw-${Date.now()}`, - method: "session.start", - params: { - sessionId: `openclaw:${sessionKey}`, - threadId: sessionKey, - taskPrompt, - workingDirectory: prepared.artifactDirectory, - multiAgent: true, - mode: "multi-agent", - routing, - }, - }, - }); - await fs.mkdir(prepared.artifactDirectory, { recursive: true }); - await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.json"), `${JSON.stringify(bridgeResult, null, 2)}\n`); - await fs.writeFile(path.join(prepared.artifactDirectory, "multi-agent-result.md"), formatBridgeResultMarkdown(bridgeResult)); - const exported = await exportXWorkmateArtifacts({ - params: { - sessionKey, - runId, - workspaceDir: params.workspaceDir, - artifactScope: prepared.artifactScope, - includeContent: false, - }, - config: input.config, - pluginConfig, - }); - return { ...exported, bridgeResult }; -} -async function callBridgeRPC(input) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), input.timeoutMs); - try { - const response = await fetch(input.bridgeUrl, { - method: "POST", - headers: { - Authorization: bearer(input.bridgeToken), - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(input.body), - signal: controller.signal, - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`bridge request failed (${response.status}): ${text.trim()}`); - } - const decoded = JSON.parse(text); - const error = asRecord(decoded.error); - if (error) { - throw new Error(optionalString(error.message) || "bridge rpc error"); - } - const result = asRecord(decoded.result); - if (!result) { - throw new Error("bridge response missing result"); - } - return result; - } - finally { - clearTimeout(timer); - } -} -function bridgeRpcUrl(pluginConfig) { - const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL); - if (!configured) { - throw new Error("bridgeUrl required"); - } - const trimmed = configured.replace(/\/+$/, ""); - if (trimmed.endsWith("/acp/rpc")) { - return trimmed; - } - return `${trimmed}/acp/rpc`; -} -function bridgeAuthToken(pluginConfig) { - return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN); -} -function safeSteps(raw, allowEmpty) { - if (!Array.isArray(raw)) { - if (allowEmpty) { - return []; - } - throw new Error("steps required"); - } - return raw.map((item, index) => { - const mapped = asRecord(item); - if (!mapped) { - throw new Error(`steps[${index}] must be an object`); - } - const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent); - const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt); - if (!providerId) { - throw new Error(`steps[${index}].providerId required`); - } - if (!prompt) { - throw new Error(`steps[${index}].prompt required`); - } - return { - providerId, - prompt, - ...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}), - ...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}), - }; - }); -} -function safeStringList(raw) { - if (!Array.isArray(raw)) { - return []; - } - return raw.map((value) => optionalString(value)).filter((value) => value.length > 0); -} -function formatBridgeResultMarkdown(result) { - const lines = ["# Multi-Agent Result", ""]; - lines.push(`- Status: ${optionalString(result.status) || "unknown"}`); - lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`); - const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message); - if (summary) { - lines.push("", "## Summary", "", summary); - } - const steps = Array.isArray(result.steps) ? result.steps : []; - if (steps.length > 0) { - lines.push("", "## Steps", ""); - for (const item of steps) { - const step = asRecord(item) ?? {}; - lines.push(`- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${optionalString(step.error) ? ` (${optionalString(step.error)})` : ""}`); - } - } - lines.push(""); - return `${lines.join("\n")}\n`; -} -function bearer(token) { - return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; -} -function requiredString(value, message) { - const text = optionalString(value); - if (!text) { - throw new Error(message); - } - return text; -} -function optionalString(value) { - if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { - return ""; - } - const text = String(value).trim(); - return text === "" ? "" : text; -} -function positiveInteger(value, fallback) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - return Math.floor(parsed); -} -function asRecord(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value; -} diff --git a/dist/src/taskState.d.ts b/dist/src/taskState.d.ts new file mode 100644 index 0000000..ec19535 --- /dev/null +++ b/dist/src/taskState.d.ts @@ -0,0 +1,57 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +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"; +}; +type XWorkmateSessionMapping = { + appSessionKey: string; + openClawSessionKey: string; + appThreadId?: string; + sessionId?: string; + runId: string; + artifactScope?: string; + expectedArtifactDirs?: string[]; +}; +export type XWorkmateTaskStore = { + records: Map; + sessionMappingsByAppKey: Map; + sessionMappingsByOpenClawKey: Map; +}; +export declare function createXWorkmateTaskStore(): XWorkmateTaskStore; +export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void; +export declare function recordXWorkmateSessionMapping(input: { + api: OpenClawPluginApi; + taskStore: XWorkmateTaskStore; + params: Record; + artifactScope?: string; +}): Promise; +export declare function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore): void; +export declare function getXWorkmateTaskSnapshot(input: { + api: OpenClawPluginApi; + taskStore: XWorkmateTaskStore; + params: Record; +}): Promise>; +export declare function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: { + params: Record; + status: XWorkmateTaskRecord["status"]; + progressSummary?: string; +}): XWorkmateTaskRecord; +export {}; diff --git a/dist/src/taskState.js b/dist/src/taskState.js new file mode 100644 index 0000000..e762878 --- /dev/null +++ b/dist/src/taskState.js @@ -0,0 +1,351 @@ +import { exportXWorkmateArtifacts } from "./exportArtifacts.js"; +const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate"; +const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; +export function createXWorkmateTaskStore() { + return { + records: new Map(), + sessionMappingsByAppKey: new Map(), + sessionMappingsByOpenClawKey: new Map(), + }; +} +export function registerXWorkmateSessionExtension(api) { + const registerExtension = api.session?.state?.registerSessionExtension ?? api.registerSessionExtension; + if (typeof registerExtension !== "function") { + return; + } + registerExtension({ + namespace: XWORKMATE_SESSION_EXTENSION_NAMESPACE, + description: "XWorkmate OpenClaw/App session key mapping for artifact and task recovery.", + sessionEntrySlotKey: "xworkmate", + project: (ctx) => { + 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), + }; + }, + }); +} +export async function recordXWorkmateSessionMapping(input) { + 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 = 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, + }); + 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, + }); +} +export function registerXWorkmateDetachedTaskRuntime(api, taskStore) { + const registerRuntime = api.registerDetachedTaskRuntime; + if (typeof registerRuntime !== "function") { + return; + } + registerRuntime({ + createQueuedTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }), + createRunningTaskRun: (params) => createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }), + startTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }), + recordTaskRunProgressByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { + lastEventAt: Date.now(), + progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary), + }), + finalizeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)), + completeTaskRunByRunId: (params) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { + status: "succeeded", + endedAt: numberOrNow(params.endedAt), + lastEventAt: numberOrNow(params.lastEventAt), + terminalSummary: optionalString(params.terminalSummary) || optionalString(params.progressSummary), + terminalOutcome: "succeeded", + }), + failTaskRunByRunId: (params) => 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) => updateXWorkmateTaskRecordsByRunId(taskStore, params, { + deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"), + error: optionalString(params.error), + }), + cancelDetachedTaskRunById: async (params) => { + const taskId = optionalString(params.taskId); + const record = taskId ? findXWorkmateTaskByTaskId(taskStore, taskId) : undefined; + if (!record) { + return { found: false, cancelled: false }; + } + record.status = "cancelled"; + record.endedAt = Date.now(); + record.lastEventAt = record.endedAt; + return { found: true, cancelled: true, reason: optionalString(params.reason), task: record }; + }, + }); +} +export async function getXWorkmateTaskSnapshot(input) { + 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 task = nativeTask || storedTask; + const taskStatus = normalizeTaskStatus(optionalString(task.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"; + } + return { + success: true, + status: appStatusFromTaskStatus(taskStatus), + taskStatus, + mode: "gateway-chat", + sessionKey, + openClawSessionKey, + appSessionKey, + runId, + task, + artifactScope: exported.artifactScope, + remoteWorkingDirectory: exported.remoteWorkingDirectory, + remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind, + scopeKind: exported.scopeKind, + artifacts: exported.artifacts, + warnings: exported.warnings, + artifactCount: exported.artifacts.length, + }; +} +export function createOrUpdateXWorkmateTaskRecord(input, options) { + 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 = { + 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, params, patch) { + 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, sessionKey, runId) { + try { + const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey }); + const resolved = bound?.resolve?.(runId) || bound?.get?.(runId); + return asRecord(resolved); + } + catch (error) { + api.logger?.warn?.(`xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} error=${String(error)}`); + return undefined; + } +} +function resolveSessionMapping(input, params, sessionKey) { + 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, sessionKey, runId) { + return input.records.get(taskRecordKey(sessionKey, runId)); +} +function findXWorkmateTaskByTaskId(input, taskId) { + return [...input.records.values()].find((record) => record.taskId === taskId); +} +function taskRecordKey(sessionKey, runId) { + return `${sessionKey}\u0000${runId}`; +} +function appSessionKeyFromOpenClawSessionKey(sessionKey) { + return sessionKey.startsWith("agent:main:") ? sessionKey.slice("agent:main:".length) : sessionKey; +} +function agentMainSessionKeyFor(sessionKey) { + return sessionKey.startsWith("agent:") ? sessionKey : `agent:main:${sessionKey}`; +} +function terminalPatch(params) { + const status = taskStatusFrom(params.status, "succeeded"); + 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", + }; +} +function normalizeTaskStatus(status, hasArtifacts) { + const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running"); + if (normalized === "running" && hasArtifacts) { + return "succeeded"; + } + return normalized; +} +function appStatusFromTaskStatus(status) { + if (status === "succeeded") { + return "completed"; + } + if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") { + return "failed"; + } + return "running"; +} +function taskStatusFrom(value, fallback) { + const status = optionalString(value); + if (status === "queued" || + status === "running" || + status === "succeeded" || + status === "failed" || + status === "timed_out" || + status === "cancelled" || + status === "lost") { + return status; + } + return fallback; +} +function deliveryStatusFrom(value, fallback) { + 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) { + const stateApi = (api.session?.state ?? {}); + const apiRecord = api; + const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension; + return typeof candidate === "function" + ? candidate + : undefined; +} +function requiredString(value, message) { + const text = optionalString(value); + if (!text) { + throw new Error(message); + } + return text; +} +function optionalString(value) { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + return ""; + } + const text = String(value).trim(); + return text === "" ? "" : text; +} +function stringList(value) { + if (!Array.isArray(value)) { + return []; + } + const seen = new Set(); + const result = []; + 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) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : Date.now(); +} +function asRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value; +} +function compactObject(value) { + return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== "")); +} +function safeTaskIdSegment(value) { + return value.replace(/[^A-Za-z0-9._:-]+/g, "_"); +} diff --git a/index.test.ts b/index.test.ts index a0f1862..1e26c1a 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import http from "node:http"; import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; @@ -22,12 +21,12 @@ describe("plugin registration", () => { }; expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts"); - expect(manifest.contracts?.tools).toContain("openclaw_multi_session_agents"); + expect(manifest.contracts?.tools).not.toContain("openclaw_multi_session_agents"); expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts"); - expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_agents"); + expect(manifest.contracts?.sessionScopedTools).not.toContain("openclaw_multi_session_agents"); expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy(); - expect(manifest.configSchema?.properties?.bridgeUrl).toBeTruthy(); - expect(manifest.configSchema?.properties?.bridgeToken).toBeTruthy(); + expect(manifest.configSchema?.properties?.bridgeUrl).toBeUndefined(); + expect(manifest.configSchema?.properties?.bridgeToken).toBeUndefined(); }); it("registers the xworkmate gateway methods and optional tools", () => { @@ -43,29 +42,24 @@ describe("plugin registration", () => { tools.push({ tool, options }); }, registerHook: () => undefined, - } as unknown as OpenClawPluginApi; plugin.register(api); expect(methods.map((entry) => entry.method)).toEqual([ + "xworkmate.tasks.get", "xworkmate.artifacts.prepare", "xworkmate.artifacts.export", "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.list", "xworkmate.artifacts.read", - "xworkmate.agents.run", ]); expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true); - expect(tools).toHaveLength(2); + expect(tools).toHaveLength(1); expect(tools[0]?.options).toMatchObject({ names: ["openclaw_multi_session_artifacts"], optional: true, }); - expect(tools[1]?.options).toMatchObject({ - names: ["openclaw_multi_session_agents"], - optional: true, - }); }); it("executes registered gateway methods against the current task scope", async () => { @@ -131,6 +125,124 @@ describe("plugin registration", () => { expect(unprepared.payload?.warnings).toEqual(["artifact scope is not prepared for this task run"]); }); + 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 sessionExtensions: Array> = []; + const sessionExtensionPatches: Array> = []; + const detachedRuntimes: Array> = []; + const api = { + config: {}, + pluginConfig: { workspaceDir: root }, + runtime: { + tasks: { + runs: { + bindSession: ({ sessionKey }: { sessionKey: string }) => ({ + resolve: (token: string) => + sessionKey === "agent:main:draft:1780636411666238-3" && token === "turn-1" + ? { + taskId: "native-task", + runtime: "acp", + requesterSessionKey: sessionKey, + ownerKey: "draft-1780636411666238-3", + scopeKind: "session", + runId: token, + task: "native", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "state_changes", + createdAt: 1, + } + : undefined, + }), + }, + }, + }, + session: { + state: { + registerSessionExtension: (extension: Record) => { + sessionExtensions.push(extension); + }, + patchSessionExtension: (patch: Record) => { + sessionExtensionPatches.push(patch); + return { ok: true }; + }, + }, + }, + registerDetachedTaskRuntime: (runtime: Record) => { + detachedRuntimes.push(runtime); + }, + registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => { + methods.set(method, handler); + }, + registerTool: () => undefined, + 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", + sessionEntrySlotKey: "xworkmate", + }); + const projected = (sessionExtensions[0]?.project as (ctx: Record) => unknown)({ + sessionKey: "agent:main:draft:1780636411666238-3", + state: {}, + }); + expect(projected).toMatchObject({ + appSessionKey: "draft:1780636411666238-3", + openClawSessionKey: "agent:main:draft:1780636411666238-3", + }); + expect(detachedRuntimes).toHaveLength(1); + + await hooks.get("session.start")?.({ + sessionKey: "draft-1780636411666238-3", + openClawSessionKey: "agent:main:draft:1780636411666238-3", + threadId: "draft-1780636411666238-3", + runId: "turn-1", + expectedArtifactDirs: ["artifacts/", "reports/", "exports/"], + }); + await fs.promises.mkdir(path.join(root, "reports"), { recursive: true }); + 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/"], + }, + }); + + const snapshot = await callGatewayMethod(methods, "xworkmate.tasks.get", { + sessionKey: "draft-1780636411666238-3", + runId: "turn-1", + expectedArtifactDirs: ["reports"], + sinceUnixMs: Date.now() - 1_000, + }); + + 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", + artifactCount: 1, + }); + expect(snapshot.payload?.task).toMatchObject({ taskId: "native-task", status: "running" }); + expect(snapshot.payload?.artifacts).toMatchObject([{ relativePath: "reports/final.md" }]); + }); + it("does not invent default session or run ids for the optional agent tool", async () => { const tools: Array<{ tool: unknown; options: unknown }> = []; const api = { @@ -141,8 +253,6 @@ describe("plugin registration", () => { registerTool: (tool: unknown, options: unknown) => { tools.push({ tool, options }); }, - registerHook: () => undefined, - } as unknown as OpenClawPluginApi; plugin.register(api); @@ -162,7 +272,7 @@ describe("plugin registration", () => { ); }); - it("does not expose session scope controls on the bridge agents tool", async () => { + it("does not expose the removed bridge agents tool", async () => { const tools: Array<{ tool: unknown; options: { names?: string[] } }> = []; const api = { config: {}, @@ -176,147 +286,7 @@ describe("plugin registration", () => { plugin.register(api); - const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents")); - const factory = entry?.tool as (ctx: Record) => { - parameters: { properties?: Record }; - execute: (id: string, params: Record) => Promise; - }; - const tool = factory({}); - - expect(tool.parameters.properties?.sessionKey).toBeUndefined(); - expect(tool.parameters.properties?.runId).toBeUndefined(); - expect(tool.parameters.properties?.workspaceDir).toBeUndefined(); - await expect(tool.execute("call-1", { taskPrompt: "run", steps: [] })).rejects.toThrow("sessionKey required"); - await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { taskPrompt: "run", steps: [] })).rejects.toThrow( - "runId required", - ); - }); - - it("fails closed when bridge token is missing", async () => { - const tools: Array<{ tool: unknown; options: { names?: string[] } }> = []; - const api = { - config: {}, - pluginConfig: { workspaceDir: await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-agent-token-")), bridgeUrl: "http://127.0.0.1:1" }, - registerGatewayMethod: () => undefined, - registerHook: () => undefined, - registerTool: (tool: unknown, options: { names?: string[] }) => { - tools.push({ tool, options }); - }, - } as unknown as OpenClawPluginApi; - - plugin.register(api); - - const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents")); - const factory = entry?.tool as (ctx: Record) => { - execute: (id: string, params: Record) => Promise; - }; - const tool = factory({ sessionKey: "thread-main", runId: "turn-1" }); - - await expect( - tool.execute("call-1", { - taskPrompt: "run", - steps: [{ providerId: "codex", prompt: "hello" }], - }), - ).rejects.toThrow("bridgeToken required"); - }); - - it("runs bridge-backed multi-agent work inside the current task artifact scope", async () => { - const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-bridge-agents-")); - const bridgeRequests: Array> = []; - const bridgeServer = http.createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/acp/rpc") { - res.statusCode = 404; - res.end(); - return; - } - expect(req.headers.authorization).toBe("Bearer bridge-token"); - let body = ""; - req.on("data", (chunk: Buffer) => { - body += chunk.toString("utf8"); - }); - req.on("end", () => { - const decoded = JSON.parse(body) as Record; - bridgeRequests.push(decoded); - res.setHeader("Content-Type", "application/json"); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - id: decoded.id, - result: { - success: true, - status: "completed", - mode: "multi-agent", - orchestrationMode: "sequence", - summary: "bridge agents done", - steps: [{ providerId: "codex", status: "completed", output: "done" }], - }, - }), - ); - }); - }); - await new Promise((resolve) => bridgeServer.listen(0, "127.0.0.1", resolve)); - try { - const address = bridgeServer.address(); - if (!address || typeof address === "string") { - throw new Error("missing bridge server address"); - } - const tools: Array<{ tool: unknown; options: { names?: string[] } }> = []; - const api = { - config: {}, - pluginConfig: { - workspaceDir: root, - bridgeUrl: `http://127.0.0.1:${address.port}`, - bridgeToken: "bridge-token", - }, - registerGatewayMethod: () => undefined, - registerHook: () => undefined, - registerTool: (tool: unknown, options: { names?: string[] }) => { - tools.push({ tool, options }); - }, - } as unknown as OpenClawPluginApi; - - plugin.register(api); - - const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents")); - const factory = entry?.tool as (ctx: Record) => { - execute: (id: string, params: Record) => Promise<{ content: Array<{ text: string }>; details: { artifacts: Array<{ relativePath: string }> } }>; - }; - const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root }); - const result = await tool.execute("call-1", { - taskPrompt: "coordinate", - mode: "sequence", - steps: [{ providerId: "codex", prompt: "hello" }], - sessionKey: "evil", - runId: "evil", - workspaceDir: "/", - }); - - expect(result.content[0]?.text).toContain("bridge agents done"); - expect(result.details.artifacts).toEqual( - expect.arrayContaining([ - expect.objectContaining({ relativePath: "multi-agent-result.json" }), - expect.objectContaining({ relativePath: "multi-agent-result.md" }), - ]), - ); - expect(await fs.promises.readFile(path.join(root, "tasks", "thread-main", "turn-1", "multi-agent-result.md"), "utf8")).toContain( - "bridge agents done", - ); - await expect(fs.promises.stat(path.join(root, "tasks", "evil", "evil", "multi-agent-result.md"))).rejects.toThrow(); - expect(bridgeRequests).toHaveLength(1); - const params = bridgeRequests[0]?.params as Record; - expect(params.sessionId).toBe("openclaw:thread-main"); - expect(params.threadId).toBe("thread-main"); - expect(params.workingDirectory).toBe(await fs.promises.realpath(path.join(root, "tasks", "thread-main", "turn-1"))); - expect(params.multiAgent).toBe(true); - expect(params.routing).toMatchObject({ - orchestrationMode: "sequence", - steps: [{ providerId: "codex", prompt: "hello" }], - }); - } finally { - await new Promise((resolve, reject) => { - bridgeServer.close((error) => (error ? reject(error) : resolve())); - }); - } + expect(tools.map((item) => item.options.names).flat()).toEqual(["openclaw_multi_session_artifacts"]); }); it("uses host context scope for the optional agent tool", async () => { @@ -342,8 +312,6 @@ describe("plugin registration", () => { registerTool: (tool: unknown, options: unknown) => { tools.push({ tool, options }); }, - registerHook: () => undefined, - } as unknown as OpenClawPluginApi; plugin.register(api); diff --git a/index.ts b/index.ts index b0c8426..38aca31 100644 --- a/index.ts +++ b/index.ts @@ -11,7 +11,14 @@ import { readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js"; -import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; +import { + createOrUpdateXWorkmateTaskRecord, + createXWorkmateTaskStore, + getXWorkmateTaskSnapshot, + recordXWorkmateSessionMapping, + registerXWorkmateDetachedTaskRuntime, + registerXWorkmateSessionExtension, +} from "./src/taskState.js"; type XWorkmateToolContext = { config?: unknown; @@ -85,21 +92,55 @@ const plugin = { export default plugin; function register(api: OpenClawPluginApi) { - api.registerHook("session.start", async (event: any) => { - try { - const params = scopedGatewayParams(event?.context ?? event); - if (params.sessionKey && params.runId) { - await prepareXWorkmateArtifacts({ - params, - config: api.config, - pluginConfig: api.pluginConfig, - }); + const taskStore = createXWorkmateTaskStore(); + registerXWorkmateSessionExtension(api); + registerXWorkmateDetachedTaskRuntime(api, taskStore); + + api.registerHook( + "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 prepared = await prepareXWorkmateArtifacts({ + params, + config: api.config, + pluginConfig: api.pluginConfig, + }); + await recordXWorkmateSessionMapping({ + api, + taskStore, + params, + artifactScope: prepared.artifactScope, + }); + } + } catch (error) { + api.logger?.warn?.(`xworkmate session.start preparation failed: ${String(error)}`); } - } catch (e) { - // Ignored: best-effort preparation + }, + { name: "openclaw-multi-session-plugins.session-start" }, + ); + + api.registerGatewayMethod("xworkmate.tasks.get", async (opts: GatewayRequestHandlerOptions) => { + try { + const payload = await getXWorkmateTaskSnapshot({ + api, + taskStore, + params: scopedGatewayParams(opts.params), + }); + 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.prepare", async (opts: GatewayRequestHandlerOptions) => { try { const payload = await prepareXWorkmateArtifacts({ @@ -175,29 +216,10 @@ function register(api: OpenClawPluginApi) { }); } }); - api.registerGatewayMethod("xworkmate.agents.run", async (opts: GatewayRequestHandlerOptions) => { - try { - const payload = await runXWorkmateBridgeAgents({ - 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.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), { names: ["openclaw_multi_session_artifacts"], optional: true, }); - api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), { - names: ["openclaw_multi_session_agents"], - optional: true, - }); } function createXWorkmateArtifactsTool( @@ -300,102 +322,3 @@ function createXWorkmateArtifactsTool( }, } as unknown as AnyAgentTool; } - -function createXWorkmateAgentsTool( - api: OpenClawPluginApi, - ctx: XWorkmateToolContext, -): AnyAgentTool { - return { - name: "openclaw_multi_session_agents", - label: "XWorkmate multi-agent bridge", - description: - "Ask XWorkmate Bridge to coordinate multiple configured agents, then save the result into the current task artifact scope.", - parameters: { - type: "object", - additionalProperties: false, - properties: { - taskPrompt: { - type: "string", - description: "Overall multi-agent task prompt.", - }, - mode: { - type: "string", - enum: ["sequence", "parallel", "race", "conversation"], - description: "Multi-agent orchestration mode.", - }, - steps: { - type: "array", - description: "Agent steps. Each item needs providerId and prompt.", - items: { - type: "object", - additionalProperties: false, - properties: { - providerId: { type: "string" }, - prompt: { type: "string" }, - outputAs: { type: "string" }, - timeoutMs: { type: "number" }, - }, - required: ["providerId", "prompt"], - }, - }, - participants: { - type: "array", - description: "Conversation participants by providerId.", - items: { type: "string" }, - }, - maxTurns: { - type: "number", - description: "Maximum turns for conversation mode.", - }, - stopConditions: { - type: "array", - description: "Text markers that stop conversation mode.", - items: { type: "string" }, - }, - timeoutMs: { - type: "number", - description: "Overall bridge request timeout.", - }, - }, - required: ["taskPrompt"], - }, - async execute(_id: string, params: Record) { - const runScope = resolveRunScope(ctx); - const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey; - const runId = ctx.sessionScope?.runId || ctx.runId || ""; - if (!sessionKey) { - throw new Error("sessionKey required"); - } - if (!runId) { - throw new Error("runId required"); - } - const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir; - const { - sessionKey: _ignoredSessionKey, - runId: _ignoredRunId, - workspaceDir: _ignoredWorkspaceDir, - ...operationParams - } = params; - const payload = await runXWorkmateBridgeAgents({ - params: { - ...operationParams, - sessionKey, - runId, - ...(workspaceDir ? { workspaceDir } : {}), - ...(runScope?.artifactScope ? { artifactScope: runScope.artifactScope } : {}), - }, - config: ctx.config ?? api.config, - pluginConfig: api.pluginConfig, - }); - const summary = typeof payload.bridgeResult.summary === "string" - ? payload.bridgeResult.summary - : typeof payload.bridgeResult.output === "string" - ? payload.bridgeResult.output - : "Multi-agent run completed."; - return { - content: [{ type: "text", text: [summary, "", formatArtifactManifestMarkdown(payload)].join("\n") }], - details: { artifacts: payload.artifacts, bridgeResult: payload.bridgeResult }, - }; - }, - } as unknown as AnyAgentTool; -} diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 291c804..a9aeec5 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -6,8 +6,8 @@ "onStartup": true }, "contracts": { - "tools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"], - "sessionScopedTools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"] + "tools": ["openclaw_multi_session_artifacts"], + "sessionScopedTools": ["openclaw_multi_session_artifacts"] }, "configSchema": { "type": "object", @@ -28,18 +28,6 @@ "artifactRefSigningSecret": { "type": "string", "description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret." - }, - "bridgeUrl": { - "type": "string", - "description": "XWorkmate Bridge base URL or /acp/rpc URL used by openclaw_multi_session_agents." - }, - "bridgeToken": { - "type": "string", - "description": "Bearer token used when openclaw_multi_session_agents calls XWorkmate Bridge." - }, - "bridgeTimeoutMs": { - "type": "number", - "description": "Default timeout for XWorkmate Bridge multi-agent calls." } } }, @@ -60,19 +48,6 @@ "label": "Artifact Ref Signing Secret", "help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.", "sensitive": true - }, - "bridgeUrl": { - "label": "Bridge URL", - "help": "XWorkmate Bridge base URL or /acp/rpc URL for multi-agent orchestration." - }, - "bridgeToken": { - "label": "Bridge Token", - "help": "Bearer token for XWorkmate Bridge multi-agent orchestration.", - "sensitive": true - }, - "bridgeTimeoutMs": { - "label": "Bridge Timeout", - "help": "Timeout in milliseconds for multi-agent bridge calls." } } } diff --git a/src/bridgeAgents.ts b/src/bridgeAgents.ts deleted file mode 100644 index d2b3e31..0000000 --- a/src/bridgeAgents.ts +++ /dev/null @@ -1,249 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { - exportXWorkmateArtifacts, - prepareXWorkmateArtifacts, - type XWorkmateArtifactExport, -} from "./exportArtifacts.js"; - -type BridgeAgentInput = { - params: Record; - config?: unknown; - pluginConfig?: Record; -}; - -type BridgeAgentRun = XWorkmateArtifactExport & { - bridgeResult: Record; -}; - -export async function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise { - const params = input.params ?? {}; - const pluginConfig = input.pluginConfig ?? {}; - const sessionKey = requiredString(params.sessionKey, "sessionKey required"); - const runId = requiredString(params.runId, "runId required"); - const taskPrompt = requiredString(params.taskPrompt, "taskPrompt required"); - const bridgeUrl = bridgeRpcUrl(pluginConfig); - const bridgeToken = bridgeAuthToken(pluginConfig); - if (!bridgeToken) { - throw new Error("bridgeToken required"); - } - - const prepared = await prepareXWorkmateArtifacts({ - params: { sessionKey, runId, workspaceDir: params.workspaceDir }, - config: input.config, - pluginConfig, - }); - const orchestrationMode = optionalString(params.mode) || optionalString(params.orchestrationMode) || "sequence"; - const participants = safeStringList(params.participants); - const steps = safeSteps(params.steps, participants.length > 0); - if (steps.length === 0 && participants.length === 0) { - throw new Error("steps or participants required"); - } - const routing: Record = { - orchestrationMode, - steps, - }; - if (participants.length > 0) { - routing.participants = participants; - } - const maxTurns = positiveInteger(params.maxTurns, 0); - if (maxTurns > 0) { - routing.maxTurns = maxTurns; - } - const stopConditions = safeStringList(params.stopConditions); - if (stopConditions.length > 0) { - routing.stopConditions = stopConditions; - } - - const bridgeResult = await callBridgeRPC({ - bridgeUrl, - bridgeToken, - timeoutMs: positiveInteger(params.timeoutMs, positiveInteger(pluginConfig.bridgeTimeoutMs, 600_000)), - body: { - jsonrpc: "2.0", - id: `openclaw-${Date.now()}`, - method: "session.start", - params: { - sessionId: `openclaw:${sessionKey}`, - threadId: sessionKey, - taskPrompt, - workingDirectory: prepared.artifactDirectory, - multiAgent: true, - mode: "multi-agent", - routing, - }, - }, - }); - - await fs.mkdir(prepared.artifactDirectory, { recursive: true }); - await fs.writeFile( - path.join(prepared.artifactDirectory, "multi-agent-result.json"), - `${JSON.stringify(bridgeResult, null, 2)}\n`, - ); - await fs.writeFile( - path.join(prepared.artifactDirectory, "multi-agent-result.md"), - formatBridgeResultMarkdown(bridgeResult), - ); - - const exported = await exportXWorkmateArtifacts({ - params: { - sessionKey, - runId, - workspaceDir: params.workspaceDir, - artifactScope: prepared.artifactScope, - includeContent: false, - }, - config: input.config, - pluginConfig, - }); - return { ...exported, bridgeResult }; -} - -async function callBridgeRPC(input: { - bridgeUrl: string; - bridgeToken: string; - timeoutMs: number; - body: Record; -}): Promise> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), input.timeoutMs); - try { - const response = await fetch(input.bridgeUrl, { - method: "POST", - headers: { - Authorization: bearer(input.bridgeToken), - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(input.body), - signal: controller.signal, - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`bridge request failed (${response.status}): ${text.trim()}`); - } - const decoded = JSON.parse(text) as Record; - const error = asRecord(decoded.error); - if (error) { - throw new Error(optionalString(error.message) || "bridge rpc error"); - } - const result = asRecord(decoded.result); - if (!result) { - throw new Error("bridge response missing result"); - } - return result; - } finally { - clearTimeout(timer); - } -} - -function bridgeRpcUrl(pluginConfig: Record): string { - const configured = optionalString(pluginConfig.bridgeUrl) || optionalString(process.env.XWORKMATE_BRIDGE_URL); - if (!configured) { - throw new Error("bridgeUrl required"); - } - const trimmed = configured.replace(/\/+$/, ""); - if (trimmed.endsWith("/acp/rpc")) { - return trimmed; - } - return `${trimmed}/acp/rpc`; -} - -function bridgeAuthToken(pluginConfig: Record): string { - return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN); -} - -function safeSteps(raw: unknown, allowEmpty: boolean): Array> { - if (!Array.isArray(raw)) { - if (allowEmpty) { - return []; - } - throw new Error("steps required"); - } - return raw.map((item, index) => { - const mapped = asRecord(item); - if (!mapped) { - throw new Error(`steps[${index}] must be an object`); - } - const providerId = optionalString(mapped.providerId) || optionalString(mapped.provider) || optionalString(mapped.agent); - const prompt = optionalString(mapped.prompt) || optionalString(mapped.taskPrompt); - if (!providerId) { - throw new Error(`steps[${index}].providerId required`); - } - if (!prompt) { - throw new Error(`steps[${index}].prompt required`); - } - return { - providerId, - prompt, - ...(optionalString(mapped.outputAs) ? { outputAs: optionalString(mapped.outputAs) } : {}), - ...(positiveInteger(mapped.timeoutMs, 0) > 0 ? { timeoutMs: positiveInteger(mapped.timeoutMs, 0) } : {}), - }; - }); -} - -function safeStringList(raw: unknown): string[] { - if (!Array.isArray(raw)) { - return []; - } - return raw.map((value) => optionalString(value)).filter((value) => value.length > 0); -} - -function formatBridgeResultMarkdown(result: Record): string { - const lines = ["# Multi-Agent Result", ""]; - lines.push(`- Status: ${optionalString(result.status) || "unknown"}`); - lines.push(`- Mode: ${optionalString(result.orchestrationMode) || optionalString(result.mode) || "multi-agent"}`); - const summary = optionalString(result.summary) || optionalString(result.output) || optionalString(result.message); - if (summary) { - lines.push("", "## Summary", "", summary); - } - const steps = Array.isArray(result.steps) ? result.steps : []; - if (steps.length > 0) { - lines.push("", "## Steps", ""); - for (const item of steps) { - const step = asRecord(item) ?? {}; - lines.push( - `- ${optionalString(step.providerId) || "unknown"}: ${optionalString(step.status) || "unknown"}${ - optionalString(step.error) ? ` (${optionalString(step.error)})` : "" - }`, - ); - } - } - lines.push(""); - return `${lines.join("\n")}\n`; -} - -function bearer(token: string): string { - return token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; -} - -function requiredString(value: unknown, message: string): string { - const text = optionalString(value); - if (!text) { - throw new Error(message); - } - return text; -} - -function optionalString(value: unknown): string { - if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { - return ""; - } - const text = String(value).trim(); - return text === "" ? "" : text; -} - -function positiveInteger(value: unknown, fallback: number): number { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - return Math.floor(parsed); -} - -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index ae7ee1f..788bf58 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -325,6 +325,62 @@ describe("exportXWorkmateArtifacts", () => { await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow(); }); + it("exports explicitly expected artifact dirs when the task scope is empty", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "draft-article", runId: "openclaw-run-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.mkdir(path.join(root, "assets", "images"), { recursive: true }); + await fs.mkdir(path.join(root, "reports"), { recursive: true }); + await fs.writeFile(path.join(root, "assets", "images", "cover.png"), "png"); + await fs.writeFile(path.join(root, "reports", "final.md"), "final"); + await fs.writeFile(path.join(root, "scratch.txt"), "scratch"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "draft-article", + runId: "openclaw-run-1", + artifactScope: prepared.artifactScope, + expectedArtifactDirs: ["assets/images", "reports"], + sinceUnixMs: Date.now() - 1_000, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([ + "assets/images/cover.png", + "reports/final.md", + ]); + expect(result.artifacts.every((entry) => entry.artifactScope === prepared.artifactScope)).toBe(true); + expect(result.artifacts.every((entry) => entry.scopeKind === "task")).toBe(true); + }); + + it("keeps scoped artifacts authoritative over expected artifact dirs", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "draft-article", runId: "openclaw-run-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true }); + await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "scoped.md"), "scoped"); + await fs.mkdir(path.join(root, "reports"), { recursive: true }); + await fs.writeFile(path.join(root, "reports", "root.md"), "root"); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "draft-article", + runId: "openclaw-run-1", + artifactScope: prepared.artifactScope, + expectedArtifactDirs: ["reports"], + sinceUnixMs: Date.now() - 1_000, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/scoped.md"]); + }); + it("does not adopt old workspace root files into a later task scope", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); await prepareXWorkmateArtifacts({ diff --git a/src/taskState.ts b/src/taskState.ts new file mode 100644 index 0000000..169e7f0 --- /dev/null +++ b/src/taskState.ts @@ -0,0 +1,460 @@ +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"; +}; + +type XWorkmateSessionMapping = { + appSessionKey: string; + openClawSessionKey: string; + appThreadId?: string; + sessionId?: string; + runId: string; + artifactScope?: string; + expectedArtifactDirs?: string[]; +}; + +export type XWorkmateTaskStore = { + records: Map; + sessionMappingsByAppKey: Map; + sessionMappingsByOpenClawKey: Map; +}; + +const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate"; +const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins"; + +export function createXWorkmateTaskStore(): XWorkmateTaskStore { + return { + records: new Map(), + sessionMappingsByAppKey: new Map(), + sessionMappingsByOpenClawKey: new Map(), + }; +} + +export function registerXWorkmateSessionExtension(api: OpenClawPluginApi) { + 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.", + 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), + }; + }, + }); +} + +export async function recordXWorkmateSessionMapping(input: { + api: OpenClawPluginApi; + 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, + }); +} + +export function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore) { + const registerRuntime = (api as any).registerDetachedTaskRuntime; + if (typeof registerRuntime !== "function") { + return; + } + 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 }; + } + record.status = "cancelled"; + record.endedAt = Date.now(); + record.lastEventAt = record.endedAt; + return { found: true, cancelled: true, reason: optionalString(params.reason), task: record }; + }, + }); +} + +export async function getXWorkmateTaskSnapshot(input: { + api: OpenClawPluginApi; + 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 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"; + } + return { + success: true, + status: appStatusFromTaskStatus(taskStatus), + taskStatus, + mode: "gateway-chat", + sessionKey, + openClawSessionKey, + appSessionKey, + runId, + task, + artifactScope: exported.artifactScope, + remoteWorkingDirectory: exported.remoteWorkingDirectory, + remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind, + scopeKind: exported.scopeKind, + artifacts: exported.artifacts, + warnings: exported.warnings, + artifactCount: exported.artifacts.length, + }; +} + +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 { + try { + const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey }); + const resolved = bound?.resolve?.(runId) || bound?.get?.(runId); + return asRecord(resolved); + } catch (error) { + api.logger?.warn?.( + `xworkmate task native registry lookup failed: sessionKey=${sessionKey} runId=${runId} 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"); + 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", + }; +} + +function normalizeTaskStatus(status: string, hasArtifacts: boolean): XWorkmateTaskRecord["status"] { + const normalized = taskStatusFrom(status, hasArtifacts ? "succeeded" : "running"); + if (normalized === "running" && hasArtifacts) { + return "succeeded"; + } + return normalized; +} + +function appStatusFromTaskStatus(status: XWorkmateTaskRecord["status"]): string { + if (status === "succeeded") { + return "completed"; + } + if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") { + return "failed"; + } + 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; + } + 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; +} + +function requiredString(value: unknown, message: string): string { + const text = optionalString(value); + if (!text) { + throw new Error(message); + } + return text; +} + +function optionalString(value: unknown): string { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + return ""; + } + const text = String(value).trim(); + 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; + } + return value as Record; +} + +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, "_"); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index ea97252..131364a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,5 +7,5 @@ "outDir": "dist", "rootDir": "." }, - "include": ["index.ts", "src/exportArtifacts.ts", "src/bridgeAgents.ts"] + "include": ["index.ts", "src/exportArtifacts.ts"] }