refactor: use OpenClaw native task state
This commit is contained in:
parent
c462ed6cce
commit
34be232931
39
README.md
39
README.md
@ -4,16 +4,17 @@ OpenClaw plugin for logical multi-session isolation and scoped XWorkmate artifac
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
|
XWorkmate talks to OpenClaw through `xworkmate-bridge` using the app-facing
|
||||||
`/gateway/openclaw` task contract. The bridge sends `chat.send`, waits for
|
`/acp` and `/acp/rpc` contract with OpenClaw routing metadata. The bridge sends
|
||||||
`agent.wait`, then asks this plugin for a session/run-scoped artifact manifest.
|
`chat.send`, waits for `agent.wait`, then asks this plugin for a session/run-scoped artifact manifest.
|
||||||
The APP can then sync generated files into its local thread workspace without
|
The APP can then sync generated files into its local thread workspace without
|
||||||
changing the UI or adding provider-specific routes.
|
changing the UI or adding provider-specific routes.
|
||||||
|
|
||||||
This plugin is not a scheduler. OpenClaw core owns sub-agents, multi-agent
|
This plugin is not a scheduler or bridge client. OpenClaw core owns sub-agents,
|
||||||
routing, queues, cron, and cross-session execution. This package only adapts
|
multi-agent routing, queues, cron, task registry state, and cross-session
|
||||||
those existing OpenClaw multi-task/session identities into isolated artifact
|
execution. This package only adapts those existing OpenClaw task/session
|
||||||
directories and signed artifact reads.
|
identities into isolated artifact directories, session key mapping, and signed
|
||||||
|
artifact reads.
|
||||||
|
|
||||||
It registers four Gateway methods:
|
It registers four Gateway methods:
|
||||||
|
|
||||||
@ -141,12 +142,15 @@ Export response payload:
|
|||||||
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
|
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
|
||||||
When `artifactScope` is omitted, export/list defaults to the current task scope
|
When `artifactScope` is omitted, export/list defaults to the current task scope
|
||||||
derived from `sessionKey/runId`. `sinceUnixMs` is only a filter inside that task
|
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
|
scope. The prepared task scope remains authoritative: when it contains files,
|
||||||
write final deliverables directly into the prepared `artifactDirectory`.
|
the plugin exports only that scope.
|
||||||
|
|
||||||
The plugin never scans workspace root, `owners/*/threads/*`, or any previous
|
If the prepared task scope is empty, trusted Gateway callers may pass
|
||||||
thread workspace as a fallback and does not borrow artifacts from earlier task
|
`expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then
|
||||||
scopes.
|
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
|
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
|
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
|
pipeline, not in `chat.send` params. If `chat.send` returns a different
|
||||||
OpenClaw `runId`, prepare/export with that actual `runId` instead of the
|
OpenClaw `runId`, prepare/export with that actual `runId` instead of the
|
||||||
bridge request id.
|
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.list` for a metadata-only manifest and Markdown table.
|
||||||
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
|
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
|
||||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned 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.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
|
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
|
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.
|
- 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.
|
- `.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.
|
- `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.
|
- Symlinks are skipped to avoid workspace escape.
|
||||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||||
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||||
|
|||||||
147
dist/index.js
vendored
147
dist/index.js
vendored
@ -1,6 +1,6 @@
|
|||||||
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
|
import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, formatArtifactManifestMarkdown, } from "./src/exportArtifacts.js";
|
||||||
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
|
import { createOrUpdateXWorkmateTaskRecord, createXWorkmateTaskStore, getXWorkmateTaskSnapshot, recordXWorkmateSessionMapping, registerXWorkmateDetachedTaskRuntime, registerXWorkmateSessionExtension, } from "./src/taskState.js";
|
||||||
function scopedGatewayParams(params) {
|
function scopedGatewayParams(params) {
|
||||||
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope;
|
||||||
const runScope = resolveRunScope({ sessionScope });
|
const runScope = resolveRunScope({ sessionScope });
|
||||||
@ -37,19 +37,49 @@ const plugin = {
|
|||||||
};
|
};
|
||||||
export default plugin;
|
export default plugin;
|
||||||
function register(api) {
|
function register(api) {
|
||||||
|
const taskStore = createXWorkmateTaskStore();
|
||||||
|
registerXWorkmateSessionExtension(api);
|
||||||
|
registerXWorkmateDetachedTaskRuntime(api, taskStore);
|
||||||
api.registerHook("session.start", async (event) => {
|
api.registerHook("session.start", async (event) => {
|
||||||
try {
|
try {
|
||||||
const params = scopedGatewayParams(event?.context ?? event);
|
const params = scopedGatewayParams(event?.context ?? event);
|
||||||
if (params.sessionKey && params.runId) {
|
if (params.sessionKey && params.runId) {
|
||||||
await prepareXWorkmateArtifacts({
|
createOrUpdateXWorkmateTaskRecord(taskStore, {
|
||||||
|
params,
|
||||||
|
status: "running",
|
||||||
|
progressSummary: "OpenClaw task is running",
|
||||||
|
});
|
||||||
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
params,
|
params,
|
||||||
config: api.config,
|
config: api.config,
|
||||||
pluginConfig: api.pluginConfig,
|
pluginConfig: api.pluginConfig,
|
||||||
});
|
});
|
||||||
|
await recordXWorkmateSessionMapping({
|
||||||
|
api,
|
||||||
|
taskStore,
|
||||||
|
params,
|
||||||
|
artifactScope: prepared.artifactScope,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (error) {
|
||||||
// Ignored: best-effort preparation
|
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) => {
|
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), {
|
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||||
names: ["openclaw_multi_session_artifacts"],
|
names: ["openclaw_multi_session_artifacts"],
|
||||||
optional: true,
|
optional: true,
|
||||||
});
|
});
|
||||||
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
|
|
||||||
names: ["openclaw_multi_session_agents"],
|
|
||||||
optional: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function createXWorkmateArtifactsTool(api, ctx) {
|
function createXWorkmateArtifactsTool(api, ctx) {
|
||||||
return {
|
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 },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
11
dist/src/bridgeAgents.d.ts
vendored
11
dist/src/bridgeAgents.d.ts
vendored
@ -1,11 +0,0 @@
|
|||||||
import { type XWorkmateArtifactExport } from "./exportArtifacts.js";
|
|
||||||
type BridgeAgentInput = {
|
|
||||||
params: Record<string, unknown>;
|
|
||||||
config?: unknown;
|
|
||||||
pluginConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
type BridgeAgentRun = XWorkmateArtifactExport & {
|
|
||||||
bridgeResult: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
export declare function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun>;
|
|
||||||
export {};
|
|
||||||
205
dist/src/bridgeAgents.js
vendored
205
dist/src/bridgeAgents.js
vendored
@ -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 === "<nil>" ? "" : 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;
|
|
||||||
}
|
|
||||||
57
dist/src/taskState.d.ts
vendored
Normal file
57
dist/src/taskState.d.ts
vendored
Normal file
@ -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<string, XWorkmateTaskRecord>;
|
||||||
|
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
|
||||||
|
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
|
||||||
|
};
|
||||||
|
export declare function createXWorkmateTaskStore(): XWorkmateTaskStore;
|
||||||
|
export declare function registerXWorkmateSessionExtension(api: OpenClawPluginApi): void;
|
||||||
|
export declare function recordXWorkmateSessionMapping(input: {
|
||||||
|
api: OpenClawPluginApi;
|
||||||
|
taskStore: XWorkmateTaskStore;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
artifactScope?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
export declare function registerXWorkmateDetachedTaskRuntime(api: OpenClawPluginApi, taskStore: XWorkmateTaskStore): void;
|
||||||
|
export declare function getXWorkmateTaskSnapshot(input: {
|
||||||
|
api: OpenClawPluginApi;
|
||||||
|
taskStore: XWorkmateTaskStore;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}): Promise<Record<string, unknown>>;
|
||||||
|
export declare function createOrUpdateXWorkmateTaskRecord(input: XWorkmateTaskStore, options: {
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
status: XWorkmateTaskRecord["status"];
|
||||||
|
progressSummary?: string;
|
||||||
|
}): XWorkmateTaskRecord;
|
||||||
|
export {};
|
||||||
351
dist/src/taskState.js
vendored
Normal file
351
dist/src/taskState.js
vendored
Normal file
@ -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 === "<nil>" ? "" : 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, "_");
|
||||||
|
}
|
||||||
284
index.test.ts
284
index.test.ts
@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
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_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_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?.artifactRefSigningSecret).toBeTruthy();
|
||||||
expect(manifest.configSchema?.properties?.bridgeUrl).toBeTruthy();
|
expect(manifest.configSchema?.properties?.bridgeUrl).toBeUndefined();
|
||||||
expect(manifest.configSchema?.properties?.bridgeToken).toBeTruthy();
|
expect(manifest.configSchema?.properties?.bridgeToken).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers the xworkmate gateway methods and optional tools", () => {
|
it("registers the xworkmate gateway methods and optional tools", () => {
|
||||||
@ -43,29 +42,24 @@ describe("plugin registration", () => {
|
|||||||
tools.push({ tool, options });
|
tools.push({ tool, options });
|
||||||
},
|
},
|
||||||
registerHook: () => undefined,
|
registerHook: () => undefined,
|
||||||
|
|
||||||
} as unknown as OpenClawPluginApi;
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
plugin.register(api);
|
plugin.register(api);
|
||||||
|
|
||||||
expect(methods.map((entry) => entry.method)).toEqual([
|
expect(methods.map((entry) => entry.method)).toEqual([
|
||||||
|
"xworkmate.tasks.get",
|
||||||
"xworkmate.artifacts.prepare",
|
"xworkmate.artifacts.prepare",
|
||||||
"xworkmate.artifacts.export",
|
"xworkmate.artifacts.export",
|
||||||
"xworkmate.artifacts.collect-and-snapshot",
|
"xworkmate.artifacts.collect-and-snapshot",
|
||||||
"xworkmate.artifacts.list",
|
"xworkmate.artifacts.list",
|
||||||
"xworkmate.artifacts.read",
|
"xworkmate.artifacts.read",
|
||||||
"xworkmate.agents.run",
|
|
||||||
]);
|
]);
|
||||||
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
||||||
expect(tools).toHaveLength(2);
|
expect(tools).toHaveLength(1);
|
||||||
expect(tools[0]?.options).toMatchObject({
|
expect(tools[0]?.options).toMatchObject({
|
||||||
names: ["openclaw_multi_session_artifacts"],
|
names: ["openclaw_multi_session_artifacts"],
|
||||||
optional: true,
|
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 () => {
|
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"]);
|
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<string, GatewayMethodHandler>();
|
||||||
|
const hooks = new Map<string, (event: unknown) => Promise<void>>();
|
||||||
|
const sessionExtensions: Array<Record<string, unknown>> = [];
|
||||||
|
const sessionExtensionPatches: Array<Record<string, unknown>> = [];
|
||||||
|
const detachedRuntimes: Array<Record<string, unknown>> = [];
|
||||||
|
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<string, unknown>) => {
|
||||||
|
sessionExtensions.push(extension);
|
||||||
|
},
|
||||||
|
patchSessionExtension: (patch: Record<string, unknown>) => {
|
||||||
|
sessionExtensionPatches.push(patch);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registerDetachedTaskRuntime: (runtime: Record<string, unknown>) => {
|
||||||
|
detachedRuntimes.push(runtime);
|
||||||
|
},
|
||||||
|
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||||
|
methods.set(method, handler);
|
||||||
|
},
|
||||||
|
registerTool: () => undefined,
|
||||||
|
registerHook: (event: string, handler: (payload: unknown) => Promise<void>) => {
|
||||||
|
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<string, unknown>) => 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 () => {
|
it("does not invent default session or run ids for the optional agent tool", async () => {
|
||||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||||
const api = {
|
const api = {
|
||||||
@ -141,8 +253,6 @@ describe("plugin registration", () => {
|
|||||||
registerTool: (tool: unknown, options: unknown) => {
|
registerTool: (tool: unknown, options: unknown) => {
|
||||||
tools.push({ tool, options });
|
tools.push({ tool, options });
|
||||||
},
|
},
|
||||||
registerHook: () => undefined,
|
|
||||||
|
|
||||||
} as unknown as OpenClawPluginApi;
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
plugin.register(api);
|
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 tools: Array<{ tool: unknown; options: { names?: string[] } }> = [];
|
||||||
const api = {
|
const api = {
|
||||||
config: {},
|
config: {},
|
||||||
@ -176,147 +286,7 @@ describe("plugin registration", () => {
|
|||||||
|
|
||||||
plugin.register(api);
|
plugin.register(api);
|
||||||
|
|
||||||
const entry = tools.find((item) => item.options.names?.includes("openclaw_multi_session_agents"));
|
expect(tools.map((item) => item.options.names).flat()).toEqual(["openclaw_multi_session_artifacts"]);
|
||||||
const factory = entry?.tool as (ctx: Record<string, unknown>) => {
|
|
||||||
parameters: { properties?: Record<string, unknown> };
|
|
||||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
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<string, unknown>) => {
|
|
||||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
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<Record<string, unknown>> = [];
|
|
||||||
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<string, unknown>;
|
|
||||||
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<void>((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<string, unknown>) => {
|
|
||||||
execute: (id: string, params: Record<string, unknown>) => 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<string, unknown>;
|
|
||||||
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<void>((resolve, reject) => {
|
|
||||||
bridgeServer.close((error) => (error ? reject(error) : resolve()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses host context scope for the optional agent tool", async () => {
|
it("uses host context scope for the optional agent tool", async () => {
|
||||||
@ -342,8 +312,6 @@ describe("plugin registration", () => {
|
|||||||
registerTool: (tool: unknown, options: unknown) => {
|
registerTool: (tool: unknown, options: unknown) => {
|
||||||
tools.push({ tool, options });
|
tools.push({ tool, options });
|
||||||
},
|
},
|
||||||
registerHook: () => undefined,
|
|
||||||
|
|
||||||
} as unknown as OpenClawPluginApi;
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
plugin.register(api);
|
plugin.register(api);
|
||||||
|
|||||||
185
index.ts
185
index.ts
@ -11,7 +11,14 @@ import {
|
|||||||
readXWorkmateArtifact,
|
readXWorkmateArtifact,
|
||||||
formatArtifactManifestMarkdown,
|
formatArtifactManifestMarkdown,
|
||||||
} from "./src/exportArtifacts.js";
|
} from "./src/exportArtifacts.js";
|
||||||
import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js";
|
import {
|
||||||
|
createOrUpdateXWorkmateTaskRecord,
|
||||||
|
createXWorkmateTaskStore,
|
||||||
|
getXWorkmateTaskSnapshot,
|
||||||
|
recordXWorkmateSessionMapping,
|
||||||
|
registerXWorkmateDetachedTaskRuntime,
|
||||||
|
registerXWorkmateSessionExtension,
|
||||||
|
} from "./src/taskState.js";
|
||||||
|
|
||||||
type XWorkmateToolContext = {
|
type XWorkmateToolContext = {
|
||||||
config?: unknown;
|
config?: unknown;
|
||||||
@ -85,21 +92,55 @@ const plugin = {
|
|||||||
export default plugin;
|
export default plugin;
|
||||||
|
|
||||||
function register(api: OpenClawPluginApi) {
|
function register(api: OpenClawPluginApi) {
|
||||||
api.registerHook("session.start", async (event: any) => {
|
const taskStore = createXWorkmateTaskStore();
|
||||||
try {
|
registerXWorkmateSessionExtension(api);
|
||||||
const params = scopedGatewayParams(event?.context ?? event);
|
registerXWorkmateDetachedTaskRuntime(api, taskStore);
|
||||||
if (params.sessionKey && params.runId) {
|
|
||||||
await prepareXWorkmateArtifacts({
|
api.registerHook(
|
||||||
params,
|
"session.start",
|
||||||
config: api.config,
|
async (event: any) => {
|
||||||
pluginConfig: api.pluginConfig,
|
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) => {
|
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => {
|
||||||
try {
|
try {
|
||||||
const payload = await prepareXWorkmateArtifacts({
|
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), {
|
api.registerTool((ctx) => createXWorkmateArtifactsTool(api, ctx), {
|
||||||
names: ["openclaw_multi_session_artifacts"],
|
names: ["openclaw_multi_session_artifacts"],
|
||||||
optional: true,
|
optional: true,
|
||||||
});
|
});
|
||||||
api.registerTool((ctx) => createXWorkmateAgentsTool(api, ctx), {
|
|
||||||
names: ["openclaw_multi_session_agents"],
|
|
||||||
optional: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createXWorkmateArtifactsTool(
|
function createXWorkmateArtifactsTool(
|
||||||
@ -300,102 +322,3 @@ function createXWorkmateArtifactsTool(
|
|||||||
},
|
},
|
||||||
} as unknown as AnyAgentTool;
|
} 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<string, unknown>) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
"onStartup": true
|
"onStartup": true
|
||||||
},
|
},
|
||||||
"contracts": {
|
"contracts": {
|
||||||
"tools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"],
|
"tools": ["openclaw_multi_session_artifacts"],
|
||||||
"sessionScopedTools": ["openclaw_multi_session_artifacts", "openclaw_multi_session_agents"]
|
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -28,18 +28,6 @@
|
|||||||
"artifactRefSigningSecret": {
|
"artifactRefSigningSecret": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
|
"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",
|
"label": "Artifact Ref Signing Secret",
|
||||||
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
|
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
|
||||||
"sensitive": true
|
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<string, unknown>;
|
|
||||||
config?: unknown;
|
|
||||||
pluginConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BridgeAgentRun = XWorkmateArtifactExport & {
|
|
||||||
bridgeResult: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function runXWorkmateBridgeAgents(input: BridgeAgentInput): Promise<BridgeAgentRun> {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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<string, unknown>;
|
|
||||||
}): Promise<Record<string, unknown>> {
|
|
||||||
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<string, unknown>;
|
|
||||||
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, unknown>): 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, unknown>): string {
|
|
||||||
return optionalString(pluginConfig.bridgeToken) || optionalString(process.env.XWORKMATE_BRIDGE_TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeSteps(raw: unknown, allowEmpty: boolean): Array<Record<string, unknown>> {
|
|
||||||
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, unknown>): 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 === "<nil>" ? "" : 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<string, unknown> | undefined {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@ -325,6 +325,62 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow();
|
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 () => {
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
await prepareXWorkmateArtifacts({
|
await prepareXWorkmateArtifacts({
|
||||||
|
|||||||
460
src/taskState.ts
Normal file
460
src/taskState.ts
Normal file
@ -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<string, XWorkmateTaskRecord>;
|
||||||
|
sessionMappingsByAppKey: Map<string, XWorkmateSessionMapping>;
|
||||||
|
sessionMappingsByOpenClawKey: Map<string, XWorkmateSessionMapping>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>) =>
|
||||||
|
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "queued" }),
|
||||||
|
createRunningTaskRun: (params: Record<string, unknown>) =>
|
||||||
|
createOrUpdateXWorkmateTaskRecord(taskStore, { params, status: "running" }),
|
||||||
|
startTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||||
|
updateXWorkmateTaskRecordsByRunId(taskStore, params, { status: "running", startedAt: Date.now() }),
|
||||||
|
recordTaskRunProgressByRunId: (params: Record<string, unknown>) =>
|
||||||
|
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
|
||||||
|
lastEventAt: Date.now(),
|
||||||
|
progressSummary: optionalString(params.progressSummary) || optionalString(params.eventSummary),
|
||||||
|
}),
|
||||||
|
finalizeTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||||
|
updateXWorkmateTaskRecordsByRunId(taskStore, params, terminalPatch(params)),
|
||||||
|
completeTaskRunByRunId: (params: Record<string, unknown>) =>
|
||||||
|
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<string, unknown>) =>
|
||||||
|
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<string, unknown>) =>
|
||||||
|
updateXWorkmateTaskRecordsByRunId(taskStore, params, {
|
||||||
|
deliveryStatus: deliveryStatusFrom(params.deliveryStatus, "delivered"),
|
||||||
|
error: optionalString(params.error),
|
||||||
|
}),
|
||||||
|
cancelDetachedTaskRunById: async (params: Record<string, unknown>) => {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>,
|
||||||
|
patch: Partial<XWorkmateTaskRecord>,
|
||||||
|
): 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<string, unknown> | 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<string, unknown>,
|
||||||
|
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<string, unknown>): Partial<XWorkmateTaskRecord> {
|
||||||
|
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<string, unknown>) => Promise<unknown> | unknown)
|
||||||
|
| undefined {
|
||||||
|
const stateApi = (api.session?.state ?? {}) as Record<string, unknown>;
|
||||||
|
const apiRecord = api as unknown as Record<string, unknown>;
|
||||||
|
const candidate = stateApi.patchSessionExtension || apiRecord.patchSessionExtension;
|
||||||
|
return typeof candidate === "function"
|
||||||
|
? (candidate as (params: Record<string, unknown>) => Promise<unknown> | 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 === "<nil>" ? "" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactObject<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined && entry[1] !== "")) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTaskIdSegment(value: string): string {
|
||||||
|
return value.replace(/[^A-Za-z0-9._:-]+/g, "_");
|
||||||
|
}
|
||||||
@ -7,5 +7,5 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "src/exportArtifacts.ts", "src/bridgeAgents.ts"]
|
"include": ["index.ts", "src/exportArtifacts.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user