Files
openclaw/extensions/workboard/src/dispatcher.ts
Dallin Romney 561cf56c53 refactor(workboard): share control ui contract (#102271)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-14 02:33:22 -07:00

501 lines
16 KiB
TypeScript

// Workboard plugin module implements dispatcher behavior.
import path from "node:path";
import type {
WorkboardCard,
WorkboardExecution,
WorkboardWorkspace,
} from "@openclaw/workboard-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { canonicalPathFromExistingAncestor } from "openclaw/plugin-sdk/security-runtime";
import {
assertRestrictedWorkboardTarget,
managedWorktreeName,
resolveDispatchWorkspaceAccess,
type ResolveAgentWorkspaceRuntime,
} from "./dispatcher-workspace.js";
import { WorkboardStore, type WorkboardDispatchResult } from "./store.js";
import {
assertCanonicalWorkboardRootAccess,
assertWorkboardWorkspaceSourceAccess,
WORKBOARD_REQUIRED_WORKER_TOOLS,
type WorkboardWorkspaceAccess,
} from "./workspace-access.js";
const DEFAULT_DISPATCH_MAX_STARTS = 3;
const DEFAULT_DISPATCH_OWNER = "workboard-dispatcher";
const DEFAULT_DISPATCH_MODEL = "default";
export type WorkboardSubagentRuntime = Pick<PluginRuntime["subagent"], "run">;
export type WorkboardWorktreeRuntime = PluginRuntime["worktrees"];
type WorkboardDispatchStartOptions = {
maxStarts?: number;
model?: string;
provider?: string;
ownerId?: string;
boardId?: string;
now?: number;
materializeWorktree?: boolean;
resolveAgentWorkspace?: (agentId?: string) => string;
resolveAgentWorkspaceRuntime?: ResolveAgentWorkspaceRuntime;
workspaceAccess?: WorkboardWorkspaceAccess;
};
type WorkboardStartedRun = {
cardId: string;
title: string;
sessionKey: string;
runId: string;
};
type WorkboardStartFailure = {
cardId: string;
title: string;
error: string;
};
type WorkboardDispatchAndStartResult = WorkboardDispatchResult & {
started: WorkboardStartedRun[];
startFailures: WorkboardStartFailure[];
};
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.trunc(value))
: fallback;
}
function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
const sanitized = (value ?? fallback)
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return (sanitized || fallback).slice(0, 96);
}
function cardIsArchived(card: WorkboardCard): boolean {
return Boolean(card.metadata?.archivedAt);
}
function buildSessionKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(cardBoardId(card), "default");
const cardId = sanitizeSessionSegment(card.id, "card");
const suffix = `subagent:workboard-${boardId}-${cardId}`;
return card.agentId ? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}` : suffix;
}
function buildExecution(params: {
card: WorkboardCard;
sessionKey: string;
runId: string;
model: string;
now: number;
}): WorkboardExecution {
return {
id: params.card.execution?.id ?? `${params.card.id}:codex`,
kind: "agent-session",
engine: "codex",
mode: "autonomous",
status: "running",
model: params.model,
sessionKey: params.sessionKey,
runId: params.runId,
startedAt: params.now,
updatedAt: params.now,
};
}
async function materializeWorkspace(params: {
card: WorkboardCard;
worktrees?: WorkboardWorktreeRuntime;
materializeWorktree: boolean;
workspaceAccess: WorkboardWorkspaceAccess;
}): Promise<{ workspace?: WorkboardWorkspace; cwd?: string }> {
const workspace = params.card.metadata?.automation?.workspace;
if (!workspace || workspace.kind === "scratch") {
return {};
}
const sourcePath = workspace.sourcePath ?? workspace.path;
const sourceBranch = workspace.sourcePath ? workspace.sourceBranch : workspace.branch;
if (!sourcePath || !path.isAbsolute(sourcePath)) {
throw new Error("worktree workspace path must be an absolute git checkout path");
}
// Persisted cards can outlive the caller that created them. Keep the exact
// canonical path that passes this dispatcher's current boundary check.
const canonicalSourcePath = await assertWorkboardWorkspaceSourceAccess(
workspace,
params.workspaceAccess,
);
if (!canonicalSourcePath) {
throw new Error("worktree workspace path is required");
}
if (workspace.kind === "dir" || !params.workspaceAccess.unrestricted) {
await assertCanonicalWorkboardRootAccess(canonicalSourcePath, params.workspaceAccess);
return workspace.kind === "worktree"
? { cwd: canonicalSourcePath, workspace: { kind: "dir", path: canonicalSourcePath } }
: { cwd: canonicalSourcePath };
}
if (!params.materializeWorktree) {
throw new Error("managed worktree materialization was not explicitly authorized");
}
if (!params.worktrees) {
throw new Error("managed worktree runtime is unavailable");
}
const worktree = await params.worktrees.create({
repoRoot: canonicalSourcePath,
name: managedWorktreeName(params.card.id),
...(sourceBranch ? { baseRef: sourceBranch } : {}),
ownerKind: "workboard",
ownerId: params.card.id,
});
let cwd: string;
try {
cwd = await canonicalPathFromExistingAncestor(worktree.path);
} catch (error) {
const removed = await params.worktrees
.removeIfLossless({
path: worktree.path,
ownerKind: "workboard",
ownerId: params.card.id,
})
.catch(() => false);
if (!removed) {
throw new Error(`${formatErrorMessage(error)}; managed worktree cleanup failed`, {
cause: error,
});
}
throw error;
}
return {
cwd,
workspace: {
kind: "worktree",
path: worktree.path,
branch: worktree.branch,
sourcePath,
...(sourceBranch ? { sourceBranch } : {}),
},
};
}
function buildWorkerPrompt(params: {
card: WorkboardCard;
context: string;
ownerId: string;
token: string;
}): string {
return [
`Work on this OpenClaw Workboard card: ${params.card.title}`,
"",
"## Worker protocol",
`Card id: ${params.card.id}`,
`Claim ownerId: ${params.ownerId}`,
`Claim token: ${params.token}`,
"",
"Heartbeat with workboard_heartbeat using the card id and token while working.",
"When done, call workboard_complete with the card id, token, summary, and proof.",
"If blocked, call workboard_block with the card id, token, and reason.",
"",
params.context,
].join("\n");
}
function sortReadyCards(a: WorkboardCard, b: WorkboardCard): number {
const priorityRank: Record<WorkboardCard["priority"], number> = {
urgent: 0,
high: 1,
normal: 2,
low: 3,
};
return (
priorityRank[a.priority] - priorityRank[b.priority] ||
a.position - b.position ||
a.createdAt - b.createdAt
);
}
function selectStartableCards(
cards: WorkboardCard[],
limit: number,
candidates: WorkboardCard[] = cards,
): WorkboardCard[] {
if (limit <= 0) {
return [];
}
const runningByOwner = new Map<string, number>();
for (const card of cards) {
const consumesOwnerSlot =
card.status === "running" ||
Boolean(card.metadata?.claim) ||
card.execution?.status === "running";
if (!consumesOwnerSlot || cardIsArchived(card)) {
continue;
}
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
runningByOwner.set(owner, (runningByOwner.get(owner) ?? 0) + 1);
}
const selected: WorkboardCard[] = [];
for (const card of candidates
.filter((entry) => entry.status === "ready" && !entry.metadata?.claim && !cardIsArchived(entry))
.toSorted(sortReadyCards)) {
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
if ((runningByOwner.get(owner) ?? 0) > 0) {
continue;
}
selected.push(card);
runningByOwner.set(owner, 1);
if (selected.length >= limit) {
break;
}
}
return selected;
}
export async function dispatchAndStartWorkboardCards(params: {
store: WorkboardStore;
subagent: WorkboardSubagentRuntime;
worktrees?: WorkboardWorktreeRuntime;
options?: WorkboardDispatchStartOptions;
}): Promise<WorkboardDispatchAndStartResult> {
const now = params.options?.now ?? Date.now();
const boardId = params.options?.boardId;
const dispatch = await params.store.dispatch({ now, boardId });
const maxStarts = normalizePositiveInteger(
params.options?.maxStarts,
DEFAULT_DISPATCH_MAX_STARTS,
);
const started: WorkboardStartedRun[] = [];
const startFailures: WorkboardStartFailure[] = [];
const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL;
const cards = await params.store.list();
const candidates = await params.store.list({ boardId });
for (const card of selectStartableCards(cards, maxStarts, candidates)) {
const ownerId = params.options?.ownerId?.trim() || card.agentId || DEFAULT_DISPATCH_OWNER;
const sessionKey = buildSessionKey(card);
let claimValue = "";
let materializedWorkspace: WorkboardWorkspace | undefined;
let implicitWorkspaceCwd: string | undefined;
let runStarted = false;
const requestedWorkspace = card.metadata?.automation?.workspace;
let workspaceAccess: WorkboardWorkspaceAccess;
let targetWorkspace: string | undefined;
let persistWorkspaceAccess: boolean;
try {
({ workspaceAccess, targetWorkspace, persistWorkspaceAccess } =
await resolveDispatchWorkspaceAccess({
card,
currentAccess: params.options?.workspaceAccess,
resolveAgentWorkspace: params.options?.resolveAgentWorkspace,
}));
} catch (error) {
startFailures.push({
cardId: card.id,
title: card.title,
error: formatErrorMessage(error),
});
continue;
}
if (!requestedWorkspace || requestedWorkspace.kind === "scratch") {
if (!workspaceAccess.unrestricted) {
if (!targetWorkspace) {
startFailures.push({
cardId: card.id,
title: card.title,
error: "target agent workspace is unavailable for restricted dispatch",
});
continue;
}
try {
implicitWorkspaceCwd = targetWorkspace;
await assertCanonicalWorkboardRootAccess(implicitWorkspaceCwd, workspaceAccess);
await assertRestrictedWorkboardTarget({
root: implicitWorkspaceCwd,
agentId: card.agentId,
sessionKey,
modelProvider: params.options?.provider,
modelId: params.options?.model,
resolveAgentWorkspaceRuntime: params.options?.resolveAgentWorkspaceRuntime,
worktrees: params.worktrees,
});
} catch (error) {
startFailures.push({
cardId: card.id,
title: card.title,
error: formatErrorMessage(error),
});
continue;
}
}
} else {
try {
const canonicalSourcePath = await assertWorkboardWorkspaceSourceAccess(
requestedWorkspace,
workspaceAccess,
);
if (
canonicalSourcePath &&
requestedWorkspace.kind === "dir" &&
workspaceAccess.unrestricted
) {
await assertCanonicalWorkboardRootAccess(canonicalSourcePath, workspaceAccess);
}
if (canonicalSourcePath && !workspaceAccess.unrestricted) {
await assertCanonicalWorkboardRootAccess(canonicalSourcePath, workspaceAccess);
await assertRestrictedWorkboardTarget({
root: canonicalSourcePath,
agentId: card.agentId,
sessionKey,
modelProvider: params.options?.provider,
modelId: params.options?.model,
resolveAgentWorkspaceRuntime: params.options?.resolveAgentWorkspaceRuntime,
worktrees: params.worktrees,
});
}
} catch (error) {
startFailures.push({
cardId: card.id,
title: card.title,
error: formatErrorMessage(error),
});
continue;
}
}
try {
const claimed = await params.store.claim(
card.id,
{ ownerId, ttlSeconds: card.metadata?.automation?.maxRuntimeSeconds },
{
expectedAuthority: {
agentId: card.agentId,
workspace: card.metadata?.automation?.workspace,
workspaceAccess: card.metadata?.automation?.workspaceAccess,
},
adoptWorkspaceAccess: persistWorkspaceAccess ? workspaceAccess : undefined,
},
);
claimValue = claimed.token;
const context = await params.store.buildWorkerContext(card.id);
const materialized = await materializeWorkspace({
card: claimed.card,
worktrees: params.worktrees,
materializeWorktree: params.options?.materializeWorktree === true,
workspaceAccess,
});
const runCwd = materialized.cwd ?? implicitWorkspaceCwd;
if (runCwd && !workspaceAccess.unrestricted) {
await assertRestrictedWorkboardTarget({
root: runCwd,
// Claim may populate agentId; keep the sessionKey target identity.
agentId: card.agentId,
sessionKey,
modelProvider: params.options?.provider,
modelId: params.options?.model,
resolveAgentWorkspaceRuntime: params.options?.resolveAgentWorkspaceRuntime,
worktrees: params.worktrees,
});
}
materializedWorkspace = materialized.workspace;
if (materializedWorkspace) {
await params.store.update(card.id, { workspace: materializedWorkspace, workspaceAccess });
}
const run = await params.subagent.run({
sessionKey,
message: buildWorkerPrompt({
card: claimed.card,
context,
ownerId,
token: claimValue,
}),
toolsAlsoAllow: [...WORKBOARD_REQUIRED_WORKER_TOOLS],
...(params.options?.provider ? { provider: params.options.provider } : {}),
...(params.options?.model ? { model: params.options.model } : {}),
lane: `workboard:${cardBoardId(card)}:${card.id}`,
idempotencyKey: `workboard:${card.id}:${claimed.card.updatedAt}`,
lightContext: true,
deliver: false,
...(runCwd ? { cwd: runCwd } : {}),
});
runStarted = true;
const updated = await params.store.update(card.id, {
sessionKey,
runId: run.runId,
execution: buildExecution({
card: claimed.card,
sessionKey,
runId: run.runId,
model,
now,
}),
...(materializedWorkspace ? { workspace: materializedWorkspace } : {}),
});
await params.store.addWorkerLog(
updated.id,
{
level: "info",
message: `Dispatcher started subagent run ${run.runId}.`,
sessionKey,
runId: run.runId,
},
{ ownerId, token: claimValue },
);
started.push({
cardId: updated.id,
title: updated.title,
sessionKey,
runId: run.runId,
});
} catch (error) {
if (
!runStarted &&
materializedWorkspace?.kind === "worktree" &&
materializedWorkspace.path &&
params.worktrees
) {
await params.worktrees
.removeIfLossless({
path: materializedWorkspace.path,
ownerKind: "workboard",
ownerId: card.id,
})
.catch(() => undefined);
const sourceWorkspace = card.metadata?.automation?.workspace;
if (sourceWorkspace) {
await params.store.update(card.id, { workspace: sourceWorkspace }).catch(() => undefined);
}
}
const message = formatErrorMessage(error);
startFailures.push({ cardId: card.id, title: card.title, error: message });
if (!claimValue) {
continue;
}
try {
await params.store.block(
card.id,
{
ownerId,
token: claimValue,
reason: `Dispatcher could not start worker: ${message}`,
},
{ ownerId, token: claimValue },
);
} catch {
// Leave the original start failure visible; dispatch will diagnose stale claims later.
}
}
}
return {
...dispatch,
started,
startFailures,
count: dispatch.count + started.length + startFailures.length,
};
}