Files
openclaw/src/node-host/node-worker-supervisor-commands.ts
T
Peter Steinberger d913796112 fix(runners): preserve node workspaces across restarts (#123889)
* fix(runners): retain node workspaces from gateway state

* docs(plan): record authoritative node retention
2026-08-14 16:59:19 -07:00

186 lines
6.3 KiB
TypeScript

import { WORKER_PUBLIC_INGRESS_PATH } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import {
NODE_WORKER_CAPACITY_EXHAUSTED_ERROR_CODE,
NODE_WORKER_SUPERVISOR_CANCEL_COMMAND,
NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND,
NODE_WORKER_SUPERVISOR_STATUS_COMMAND,
NODE_WORKER_WORKSPACE_EXEC_COMMAND,
NODE_WORKER_WORKSPACE_RETAIN_COMMAND,
} from "../infra/node-commands.js";
import {
parseNodeWorkerWorkspaceExecInput,
type NodeWorkerWorkspaceExecResult,
} from "../worker/node-workspace-protocol.js";
import {
parseNodeWorkerWorkspaceRetainInput,
type NodeWorkerWorkspaceRetainResult,
} from "../worker/node-workspace-retain-protocol.js";
import {
NODE_WORKSPACE_TRANSFER_ERROR_CODE,
NodeWorkerWorkspaceTransferError,
} from "../worker/node-workspace-transfer-protocol.js";
import {
parseWorkerConnectionEndpoint,
type WorkerConnectionEndpoint,
} from "../worker/worker-connection-endpoint.js";
import { NodeWorkerCapacityExhaustedError } from "./node-worker-capacity.js";
import {
parseNodeWorkerCancelInput,
parseNodeWorkerLaunchInput,
parseNodeWorkerLookupInput,
projectNodeWorkerSupervisorReceipt,
type NodeWorkerSupervisorControl,
type NodeWorkerSupervisorReceipt,
} from "./node-worker-supervisor-contract.js";
import type { NodeWorkerWorkspaceRuntime } from "./node-worker-workspace.js";
type NodeWorkerSupervisorCommandResult =
| { handled: false }
| {
handled: true;
ok: true;
payload:
| NodeWorkerSupervisorReceipt
| NodeWorkerWorkspaceExecResult
| NodeWorkerWorkspaceRetainResult
| null;
}
| {
handled: true;
ok: false;
code:
| "INVALID_REQUEST"
| "UNAVAILABLE"
| typeof NODE_WORKER_CAPACITY_EXHAUSTED_ERROR_CODE
| typeof NODE_WORKSPACE_TRANSFER_ERROR_CODE;
message: string;
};
function resolveWorkerConnectionEndpoint(params: {
gatewayUrl?: string;
gatewayTlsFingerprint?: string;
}): WorkerConnectionEndpoint {
if (!params.gatewayUrl) {
throw new Error("node worker gateway connection unavailable");
}
const gateway = new URL(params.gatewayUrl);
if (gateway.protocol !== "ws:" && gateway.protocol !== "wss:") {
throw new Error("node worker gateway connection must use WebSocket transport");
}
const endpointUrl = new URL(gateway.toString());
const basePath = gateway.pathname.replace(/\/$/u, "");
endpointUrl.pathname = `${basePath}${WORKER_PUBLIC_INGRESS_PATH}`;
endpointUrl.search = "";
endpointUrl.hash = "";
if (endpointUrl.host !== gateway.host) {
throw new Error("node worker endpoint must stay on the connected gateway host");
}
const endpoint = parseWorkerConnectionEndpoint({
kind: "websocket",
url: endpointUrl.toString(),
...(gateway.protocol === "wss:" && params.gatewayTlsFingerprint
? { tlsFingerprint: params.gatewayTlsFingerprint }
: {}),
});
if (!endpoint) {
throw new Error("node worker gateway connection could not form a worker endpoint");
}
return endpoint;
}
/** Dispatches the non-advertised worker control contract before public node commands. */
export async function invokeNodeWorkerSupervisorCommand(params: {
command: string;
paramsJSON?: string | null;
supervisor?: NodeWorkerSupervisorControl;
workspace?: NodeWorkerWorkspaceRuntime;
gatewayUrl?: string;
gatewayTlsFingerprint?: string;
signal?: AbortSignal;
}): Promise<NodeWorkerSupervisorCommandResult> {
const recognized =
params.command === NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND ||
params.command === NODE_WORKER_SUPERVISOR_STATUS_COMMAND ||
params.command === NODE_WORKER_SUPERVISOR_CANCEL_COMMAND ||
params.command === NODE_WORKER_WORKSPACE_EXEC_COMMAND ||
params.command === NODE_WORKER_WORKSPACE_RETAIN_COMMAND;
if (!recognized) {
return { handled: false };
}
if (
(params.command === NODE_WORKER_WORKSPACE_EXEC_COMMAND && !params.workspace) ||
(params.command !== NODE_WORKER_WORKSPACE_EXEC_COMMAND && !params.supervisor)
) {
return {
handled: true,
ok: false,
code: "UNAVAILABLE",
message: "node worker runtime unavailable",
};
}
try {
if (params.command === NODE_WORKER_WORKSPACE_EXEC_COMMAND) {
return {
handled: true,
ok: true,
payload: await params.workspace!.exec(
parseNodeWorkerWorkspaceExecInput(params.paramsJSON),
params.signal,
params.gatewayUrl
? {
url: params.gatewayUrl,
...(params.gatewayTlsFingerprint
? { tlsFingerprint: params.gatewayTlsFingerprint }
: {}),
}
: undefined,
),
};
}
if (params.command === NODE_WORKER_WORKSPACE_RETAIN_COMMAND) {
return {
handled: true,
ok: true,
payload: await params.supervisor!.retainWorkspaces(
parseNodeWorkerWorkspaceRetainInput(params.paramsJSON),
params.signal,
),
};
}
const receipt =
params.command === NODE_WORKER_SUPERVISOR_LAUNCH_COMMAND
? await params.supervisor!.launch(
parseNodeWorkerLaunchInput(params.paramsJSON),
resolveWorkerConnectionEndpoint(params),
params.signal,
)
: params.command === NODE_WORKER_SUPERVISOR_STATUS_COMMAND
? await params.supervisor!.status(parseNodeWorkerLookupInput(params.paramsJSON).launchId)
: await params.supervisor!.cancel(parseNodeWorkerCancelInput(params.paramsJSON));
return {
handled: true,
ok: true,
payload: receipt ? projectNodeWorkerSupervisorReceipt(receipt) : null,
};
} catch (error) {
const invalid = error instanceof Error && error.message.startsWith("INVALID_REQUEST:");
const capacityFailure = error instanceof NodeWorkerCapacityExhaustedError;
const transferFailure = error instanceof NodeWorkerWorkspaceTransferError;
return {
handled: true,
ok: false,
code: invalid
? "INVALID_REQUEST"
: capacityFailure
? NODE_WORKER_CAPACITY_EXHAUSTED_ERROR_CODE
: transferFailure
? NODE_WORKSPACE_TRANSFER_ERROR_CODE
: "UNAVAILABLE",
message:
invalid || capacityFailure || transferFailure
? error.message
: "node worker supervisor command failed",
};
}
}