482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
import { exportXWorkmateArtifacts } from "./exportArtifacts.js";
|
|
import { normalizeExpectedArtifactDirs } from "./expectedArtifactDirs.js";
|
|
|
|
const XWORKMATE_PLUGIN_ID = "openclaw-multi-session-plugins";
|
|
export const XWORKMATE_SESSION_EXTENSION_NAMESPACE = "xworkmate.sessionMapping";
|
|
|
|
export type XWorkmateTaskMetadataV1 = {
|
|
schemaVersion: 1;
|
|
appThreadKey: string;
|
|
openclawSessionKey?: string;
|
|
expectedArtifactDirs: string[];
|
|
requestId?: string;
|
|
externalTaskId?: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type XWorkmateSessionMappingSource =
|
|
| "session_start"
|
|
| "bridge_prepare";
|
|
|
|
export type XWorkmateSessionMappingV1 = {
|
|
schemaVersion: 1;
|
|
appThreadKey: string;
|
|
openclawSessionKey: string;
|
|
expectedArtifactDirs: string[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
source: XWorkmateSessionMappingSource;
|
|
};
|
|
|
|
export type XWorkmateTaskLookupErrorCode =
|
|
| "mapping_not_found"
|
|
| "task_not_found"
|
|
| "no_native_task_record"
|
|
| "conflict"
|
|
| "invalid_lookup";
|
|
|
|
export type XWorkmateTaskLookupError = {
|
|
ok: false;
|
|
code: XWorkmateTaskLookupErrorCode;
|
|
message: string;
|
|
mapping?: XWorkmateSessionMappingV1;
|
|
expectedArtifactDirs?: string[];
|
|
};
|
|
|
|
type SessionEntry = Record<string, unknown> & {
|
|
pluginExtensions?: Record<string, Record<string, unknown>>;
|
|
};
|
|
|
|
type PatchSessionEntry = (params: {
|
|
sessionKey: string;
|
|
fallbackEntry?: SessionEntry;
|
|
preserveActivity?: boolean;
|
|
update: (entry: SessionEntry) => Partial<SessionEntry> | null;
|
|
}) => Promise<SessionEntry | null> | SessionEntry | null;
|
|
|
|
type GetSessionEntry = (params: { sessionKey: string }) => SessionEntry | undefined;
|
|
|
|
type BoundTaskRunsRuntime = {
|
|
get?: (taskId: string) => unknown;
|
|
list?: () => unknown[];
|
|
findLatest?: () => unknown;
|
|
resolve?: (token: string) => unknown;
|
|
};
|
|
|
|
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: "Durable XWorkmate app/OpenClaw session key mapping.",
|
|
sessionEntrySlotKey: "xworkmate",
|
|
project: (ctx: { sessionKey: string; state?: unknown }): any => {
|
|
const state = asRecord(ctx.state);
|
|
return state ?? {};
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function recordXWorkmateSessionMapping(input: {
|
|
api: OpenClawPluginApi;
|
|
params: Record<string, unknown>;
|
|
artifactScope?: string;
|
|
source?: XWorkmateSessionMappingSource;
|
|
}): Promise<XWorkmateSessionMappingV1> {
|
|
const metadata = normalizeXWorkmateTaskMetadataV1(input.params);
|
|
const openclawSessionKey = requiredString(
|
|
input.params.openclawSessionKey ?? metadata.openclawSessionKey,
|
|
"openclawSessionKey required",
|
|
);
|
|
return upsertXWorkmateSessionMapping(input.api, {
|
|
metadata: {
|
|
...metadata,
|
|
openclawSessionKey,
|
|
},
|
|
openclawSessionKey,
|
|
source: input.source ?? "bridge_prepare",
|
|
});
|
|
}
|
|
|
|
function normalizeXWorkmateTaskMetadataV1(input: Record<string, unknown>): XWorkmateTaskMetadataV1 {
|
|
const envelope = asRecord(input.xworkmate) ?? asRecord(input.xworkmateMetadata) ?? input;
|
|
const schemaVersion = Number(envelope.schemaVersion ?? 1);
|
|
if (schemaVersion !== 1) {
|
|
throw new Error("schemaVersion must be 1");
|
|
}
|
|
const appThreadKey = requiredString(envelope.appThreadKey, "appThreadKey required");
|
|
const createdAt = optionalString(envelope.createdAt) || new Date().toISOString();
|
|
return compactObject({
|
|
schemaVersion: 1 as const,
|
|
appThreadKey,
|
|
openclawSessionKey: optionalString(envelope.openclawSessionKey),
|
|
expectedArtifactDirs: normalizeExpectedArtifactDirs(envelope.expectedArtifactDirs),
|
|
requestId: optionalString(envelope.requestId),
|
|
externalTaskId: optionalString(envelope.externalTaskId ?? envelope.taskId),
|
|
createdAt,
|
|
}) as XWorkmateTaskMetadataV1;
|
|
}
|
|
|
|
async function upsertXWorkmateSessionMapping(
|
|
api: OpenClawPluginApi,
|
|
input: {
|
|
metadata: XWorkmateTaskMetadataV1;
|
|
openclawSessionKey: string;
|
|
source: XWorkmateSessionMappingSource;
|
|
},
|
|
): Promise<XWorkmateSessionMappingV1> {
|
|
const patchSessionEntry = resolvePatchSessionEntry(api);
|
|
if (!patchSessionEntry) {
|
|
throw new Error("OpenClaw runtime session patch API is unavailable");
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
let mapping: XWorkmateSessionMappingV1 | undefined;
|
|
await patchSessionEntry({
|
|
sessionKey: input.openclawSessionKey,
|
|
fallbackEntry: {
|
|
sessionId: input.openclawSessionKey,
|
|
updatedAt: Date.now(),
|
|
},
|
|
preserveActivity: true,
|
|
update: (entry) => {
|
|
const existing = readMappingFromEntry(entry);
|
|
if (existing) {
|
|
assertMappingCompatible(existing, input.metadata.appThreadKey, input.openclawSessionKey);
|
|
mapping = {
|
|
...existing,
|
|
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
|
updatedAt: now,
|
|
source: existing.source,
|
|
};
|
|
} else {
|
|
mapping = compactObject({
|
|
schemaVersion: 1 as const,
|
|
appThreadKey: input.metadata.appThreadKey,
|
|
openclawSessionKey: input.openclawSessionKey,
|
|
expectedArtifactDirs: input.metadata.expectedArtifactDirs,
|
|
createdAt: input.metadata.createdAt || now,
|
|
updatedAt: now,
|
|
source: input.source,
|
|
}) as XWorkmateSessionMappingV1;
|
|
}
|
|
return {
|
|
pluginExtensions: writeMappingToPluginExtensions(entry.pluginExtensions, mapping),
|
|
};
|
|
},
|
|
});
|
|
|
|
if (!mapping) {
|
|
throw new Error("failed to write xworkmate session mapping");
|
|
}
|
|
return mapping;
|
|
}
|
|
|
|
async function readXWorkmateSessionMapping(
|
|
api: OpenClawPluginApi,
|
|
lookup: {
|
|
appThreadKey?: string;
|
|
openclawSessionKey?: string;
|
|
},
|
|
): Promise<XWorkmateSessionMappingV1 | undefined> {
|
|
const getSessionEntry = resolveGetSessionEntry(api);
|
|
if (!getSessionEntry) {
|
|
return undefined;
|
|
}
|
|
const openclawSessionKey = optionalString(lookup.openclawSessionKey);
|
|
if (openclawSessionKey) {
|
|
return readMappingFromEntry(getSessionEntry({ sessionKey: openclawSessionKey }));
|
|
}
|
|
const appThreadKey = optionalString(lookup.appThreadKey);
|
|
if (!appThreadKey) {
|
|
return undefined;
|
|
}
|
|
const listSessionEntries = resolveListSessionEntries(api);
|
|
for (const item of listSessionEntries?.() ?? []) {
|
|
const mapping = readMappingFromEntry(item.entry);
|
|
if (mapping?.appThreadKey === appThreadKey) {
|
|
return mapping;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function getXWorkmateTaskSnapshot(input: {
|
|
api: OpenClawPluginApi;
|
|
params: Record<string, unknown>;
|
|
}): Promise<Record<string, unknown>> {
|
|
const params = input.params ?? {};
|
|
const appThreadKey = optionalString(params.appThreadKey);
|
|
const explicitOpenclawSessionKey = optionalString(params.openclawSessionKey);
|
|
const mapping = await readXWorkmateSessionMapping(input.api, {
|
|
appThreadKey,
|
|
openclawSessionKey: explicitOpenclawSessionKey,
|
|
});
|
|
if (!mapping && appThreadKey && !explicitOpenclawSessionKey) {
|
|
return lookupError("mapping_not_found", `No OpenClaw session mapping found for ${appThreadKey}`);
|
|
}
|
|
const openclawSessionKey = mapping?.openclawSessionKey || explicitOpenclawSessionKey;
|
|
if (!openclawSessionKey) {
|
|
return lookupError("invalid_lookup", "openclawSessionKey or appThreadKey required");
|
|
}
|
|
|
|
const runId = optionalString(params.runId);
|
|
const taskId = optionalString(params.taskId);
|
|
const task = resolveNativeTask(input.api, {
|
|
openclawSessionKey,
|
|
runId,
|
|
taskId,
|
|
});
|
|
const includeArtifacts = params.includeArtifacts !== false;
|
|
if (!task) {
|
|
const exported = includeArtifacts && runId
|
|
? await exportArtifactsForTaskLookup(input, params, openclawSessionKey, runId, mapping)
|
|
: undefined;
|
|
if (exported?.artifacts.length) {
|
|
return {
|
|
success: false,
|
|
status: "unknown",
|
|
taskStatus: "unknown",
|
|
evidence: "artifacts_present",
|
|
mode: "gateway-chat",
|
|
mapping,
|
|
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
|
openclawSessionKey,
|
|
runId,
|
|
taskId: taskId || runId,
|
|
task: {
|
|
taskId: taskId || runId,
|
|
runId,
|
|
status: "unknown",
|
|
source: "artifact_fallback",
|
|
},
|
|
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
|
artifactScope: exported.artifactScope,
|
|
remoteWorkingDirectory: exported.remoteWorkingDirectory,
|
|
remoteWorkspaceRefKind: exported.remoteWorkspaceRefKind,
|
|
scopeKind: exported.scopeKind,
|
|
artifacts: exported.artifacts,
|
|
constraintSatisfied: exported.constraintSatisfied,
|
|
missingRequiredExtensions: exported.missingRequiredExtensions,
|
|
warnings: [
|
|
...exported.warnings,
|
|
`Native OpenClaw task record was unavailable for ${openclawSessionKey}; artifacts are present but task status is unknown.`,
|
|
],
|
|
artifactCount: exported.artifacts.length,
|
|
};
|
|
}
|
|
const code: XWorkmateTaskLookupErrorCode = runId || taskId ? "no_native_task_record" : "task_not_found";
|
|
return lookupError(code, `No native OpenClaw task record found for ${openclawSessionKey}`, mapping);
|
|
}
|
|
|
|
const taskStatus = optionalString((task as any).status) || "running";
|
|
const exported = includeArtifacts
|
|
? await exportArtifactsForTaskLookup(
|
|
input,
|
|
params,
|
|
openclawSessionKey,
|
|
runId || optionalString((task as any).runId) || optionalString((task as any).taskId),
|
|
mapping,
|
|
)
|
|
: undefined;
|
|
|
|
return {
|
|
success: true,
|
|
status: appStatusFromTaskStatus(taskStatus),
|
|
taskStatus,
|
|
mode: "gateway-chat",
|
|
mapping,
|
|
appThreadKey: mapping?.appThreadKey ?? appThreadKey,
|
|
openclawSessionKey,
|
|
runId: runId || optionalString((task as any).runId),
|
|
taskId: taskId || optionalString((task as any).taskId),
|
|
task,
|
|
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? [],
|
|
artifactScope: exported?.artifactScope,
|
|
remoteWorkingDirectory: exported?.remoteWorkingDirectory,
|
|
remoteWorkspaceRefKind: exported?.remoteWorkspaceRefKind,
|
|
scopeKind: exported?.scopeKind,
|
|
artifacts: exported?.artifacts ?? [],
|
|
constraintSatisfied: exported?.constraintSatisfied,
|
|
missingRequiredExtensions: exported?.missingRequiredExtensions,
|
|
warnings: exported?.warnings ?? [],
|
|
artifactCount: exported?.artifacts.length ?? 0,
|
|
};
|
|
}
|
|
|
|
async function exportArtifactsForTaskLookup(
|
|
input: { api: OpenClawPluginApi; params: Record<string, unknown> },
|
|
params: Record<string, unknown>,
|
|
openclawSessionKey: string,
|
|
runId: string,
|
|
mapping?: XWorkmateSessionMappingV1,
|
|
) {
|
|
return exportXWorkmateArtifacts({
|
|
params: {
|
|
...params,
|
|
openclawSessionKey,
|
|
runId,
|
|
expectedArtifactDirs: mapping?.expectedArtifactDirs ?? normalizeExpectedArtifactDirs(params.expectedArtifactDirs),
|
|
includeContent: params.includeContent ?? false,
|
|
},
|
|
config: input.api.config,
|
|
pluginConfig: input.api.pluginConfig,
|
|
});
|
|
}
|
|
|
|
function resolveNativeTask(
|
|
api: OpenClawPluginApi,
|
|
input: { openclawSessionKey: string; runId?: string; taskId?: string },
|
|
): Record<string, unknown> | undefined {
|
|
try {
|
|
const bound = api.runtime?.tasks?.runs?.bindSession?.({ sessionKey: input.openclawSessionKey }) as
|
|
| BoundTaskRunsRuntime
|
|
| undefined;
|
|
if (!bound) {
|
|
return undefined;
|
|
}
|
|
const lookup = input.taskId || input.runId || "";
|
|
const resolved = lookup ? bound.resolve?.(lookup) || bound.get?.(lookup) : bound.findLatest?.();
|
|
return asRecord(resolved);
|
|
} catch (error) {
|
|
api.logger?.warn?.(
|
|
`xworkmate native task lookup failed: sessionKey=${input.openclawSessionKey} error=${String(error)}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function lookupError(
|
|
code: XWorkmateTaskLookupErrorCode,
|
|
message: string,
|
|
mapping?: XWorkmateSessionMappingV1,
|
|
): XWorkmateTaskLookupError {
|
|
return {
|
|
ok: false,
|
|
code,
|
|
message,
|
|
...(mapping ? { mapping, expectedArtifactDirs: mapping.expectedArtifactDirs } : {}),
|
|
};
|
|
}
|
|
|
|
function readMappingFromEntry(entry: SessionEntry | undefined | null): XWorkmateSessionMappingV1 | undefined {
|
|
const pluginState = asRecord(entry?.pluginExtensions?.[XWORKMATE_PLUGIN_ID]);
|
|
const raw = asRecord(pluginState?.[XWORKMATE_SESSION_EXTENSION_NAMESPACE]);
|
|
if (!raw || raw.schemaVersion !== 1) {
|
|
return undefined;
|
|
}
|
|
const appThreadKey = optionalString(raw.appThreadKey);
|
|
const openclawSessionKey = optionalString(raw.openclawSessionKey);
|
|
if (!appThreadKey || !openclawSessionKey) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
appThreadKey,
|
|
openclawSessionKey,
|
|
expectedArtifactDirs: normalizeExpectedArtifactDirs(raw.expectedArtifactDirs),
|
|
createdAt: optionalString(raw.createdAt) || new Date(0).toISOString(),
|
|
updatedAt: optionalString(raw.updatedAt) || optionalString(raw.createdAt) || new Date(0).toISOString(),
|
|
source: parseMappingSource(raw.source),
|
|
};
|
|
}
|
|
|
|
function writeMappingToPluginExtensions(
|
|
current: SessionEntry["pluginExtensions"],
|
|
mapping: XWorkmateSessionMappingV1 | undefined,
|
|
): SessionEntry["pluginExtensions"] {
|
|
if (!mapping) {
|
|
return current;
|
|
}
|
|
return {
|
|
...(current ?? {}),
|
|
[XWORKMATE_PLUGIN_ID]: {
|
|
...(current?.[XWORKMATE_PLUGIN_ID] ?? {}),
|
|
[XWORKMATE_SESSION_EXTENSION_NAMESPACE]: mapping,
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertMappingCompatible(
|
|
existing: XWorkmateSessionMappingV1,
|
|
appThreadKey: string,
|
|
openclawSessionKey: string,
|
|
) {
|
|
if (existing.appThreadKey !== appThreadKey || existing.openclawSessionKey !== openclawSessionKey) {
|
|
throw new Error("conflict: xworkmate session mapping already points to a different session");
|
|
}
|
|
}
|
|
|
|
function resolvePatchSessionEntry(api: OpenClawPluginApi): PatchSessionEntry | undefined {
|
|
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
|
const candidate = runtimeSession.patchSessionEntry;
|
|
return typeof candidate === "function" ? (candidate as PatchSessionEntry) : undefined;
|
|
}
|
|
|
|
function resolveGetSessionEntry(api: OpenClawPluginApi): GetSessionEntry | undefined {
|
|
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
|
const candidate = runtimeSession.getSessionEntry;
|
|
return typeof candidate === "function" ? (candidate as GetSessionEntry) : undefined;
|
|
}
|
|
|
|
function resolveListSessionEntries(
|
|
api: OpenClawPluginApi,
|
|
): (() => Array<{ sessionKey: string; entry: SessionEntry }>) | undefined {
|
|
const runtimeSession = (api.runtime?.agent?.session ?? {}) as Record<string, unknown>;
|
|
const candidate = runtimeSession.listSessionEntries;
|
|
return typeof candidate === "function"
|
|
? (candidate as () => Array<{ sessionKey: string; entry: SessionEntry }>)
|
|
: undefined;
|
|
}
|
|
|
|
function appStatusFromTaskStatus(status: string): string {
|
|
if (status === "succeeded") {
|
|
return "completed";
|
|
}
|
|
if (status === "failed" || status === "timed_out" || status === "cancelled" || status === "lost") {
|
|
return "failed";
|
|
}
|
|
return "running";
|
|
}
|
|
|
|
function parseMappingSource(value: unknown): XWorkmateSessionMappingSource {
|
|
const source = optionalString(value);
|
|
if (source === "session_start" || source === "bridge_prepare") {
|
|
return source;
|
|
}
|
|
return "bridge_prepare";
|
|
}
|
|
|
|
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 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>;
|
|
}
|