mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 17:31:35 +00:00
Centralized managed worktrees under <state-dir>/worktrees/<repo-fingerprint>/<name> with branch-per-task (openclaw/<name>), .worktreeinclude provisioning, an optional .openclaw/worktree-setup.sh repo hook, and a SQLite registry in the shared state DB. Removal always snapshots the tree (untracked included, gitignored excluded) to refs/openclaw/snapshots/<id>; restore rebuilds the branch at the original commit with the snapshot content as uncommitted state. Lossless run-end cleanup, 7-day idle GC for run-owned worktrees (manual exempt), orphan reconciliation, 30-day snapshot retention. Surfaces: worktrees.* gateway RPC (operator.admin mutations), openclaw worktrees CLI, Control UI page, plugin-SDK facade + Workboard kind:"worktree" materialization. E2E-verified on Testbox: full create->work->remove->restore->gc lifecycle.
705 lines
19 KiB
TypeScript
705 lines
19 KiB
TypeScript
// Workboard plugin module implements gateway behavior.
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import type { OpenClawPluginApi } from "../api.js";
|
|
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
|
|
import { WorkboardStore } from "./store.js";
|
|
import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js";
|
|
|
|
const READ_SCOPE = "operator.read" as const;
|
|
const WRITE_SCOPE = "operator.write" as const;
|
|
|
|
type GatewayMethodContext = Parameters<
|
|
Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]
|
|
>[0];
|
|
type GatewayRespond = GatewayMethodContext["respond"];
|
|
|
|
function respondError(respond: GatewayRespond, error: unknown) {
|
|
respond(false, undefined, {
|
|
code: "workboard_error",
|
|
message: formatErrorMessage(error),
|
|
});
|
|
}
|
|
|
|
function readId(params: Record<string, unknown>): string {
|
|
const value = params.id;
|
|
if (typeof value === "string" && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
throw new Error("id is required.");
|
|
}
|
|
|
|
function readPatch(params: Record<string, unknown>): Record<string, unknown> {
|
|
const patch = params.patch;
|
|
if (patch && typeof patch === "object" && !Array.isArray(patch)) {
|
|
return patch as Record<string, unknown>;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function assertNoCursorAdvance(params: Record<string, unknown>) {
|
|
if (params.advance === true) {
|
|
throw new Error("notification cursor advancement requires workboard.notifications.advance.");
|
|
}
|
|
}
|
|
|
|
function redactClaimToken(card: WorkboardCard): WorkboardCard {
|
|
const claim = card.metadata?.claim;
|
|
if (!claim) {
|
|
return card;
|
|
}
|
|
return {
|
|
...card,
|
|
metadata: {
|
|
...card.metadata,
|
|
claim: { ...claim, token: "[redacted]" },
|
|
},
|
|
};
|
|
}
|
|
|
|
function redactDiagnosticsRows(result: Awaited<ReturnType<WorkboardStore["diagnostics"]>>) {
|
|
return {
|
|
...result,
|
|
diagnostics: result.diagnostics.map((row) => ({
|
|
...row,
|
|
card: redactClaimToken(row.card),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function registerWorkboardGatewayMethods(params: {
|
|
api: OpenClawPluginApi;
|
|
store?: WorkboardStore;
|
|
}) {
|
|
const { api } = params;
|
|
const store = params.store ?? WorkboardStore.openSqlite();
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.list",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
cards: (await store.list({ boardId: requestParams.boardId })).map(redactClaimToken),
|
|
statuses: WORKBOARD_STATUSES,
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.create",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, { card: redactClaimToken(await store.create(requestParams)) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.update",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(
|
|
await store.update(readId(requestParams), readPatch(requestParams)),
|
|
),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.move",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(
|
|
await store.move(readId(requestParams), requestParams.status, requestParams.position),
|
|
),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.delete",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.delete(readId(requestParams)));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.comment",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addComment(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.link",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addLink(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.linkDependency",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const parentId = requestParams.parentId;
|
|
const childId = requestParams.childId;
|
|
if (typeof parentId !== "string" || typeof childId !== "string") {
|
|
throw new Error("parentId and childId are required.");
|
|
}
|
|
respond(true, {
|
|
card: redactClaimToken(await store.linkCards(parentId, childId)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.proof",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addProof(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.artifact",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addArtifact(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.claim",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const claimed = await store.claim(readId(requestParams), requestParams);
|
|
respond(true, { ...claimed, card: redactClaimToken(claimed.card) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.heartbeat",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.heartbeat(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.release",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.releaseClaim(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.promote",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.promote(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.reassign",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.reassign(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.reclaim",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.reclaim(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.complete",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.complete(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.block",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.block(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.unblock",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.unblock(readId(requestParams))),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.bulk",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const result = await store.bulkUpdate(requestParams);
|
|
respond(true, { cards: result.cards.map(redactClaimToken) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.diagnostics",
|
|
async ({ respond }) => {
|
|
try {
|
|
respond(true, redactDiagnosticsRows(await store.diagnostics()));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.diagnostics.refresh",
|
|
async ({ respond }) => {
|
|
try {
|
|
respond(true, redactDiagnosticsRows(await store.refreshDiagnostics()));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.dispatch",
|
|
async ({ params: requestParams, respond, client }) => {
|
|
try {
|
|
const boardId =
|
|
requestParams && typeof requestParams === "object" && "boardId" in requestParams
|
|
? requestParams.boardId
|
|
: undefined;
|
|
const result = await dispatchAndStartWorkboardCards({
|
|
store,
|
|
subagent: api.runtime.subagent,
|
|
worktrees: api.runtime.worktrees,
|
|
options: {
|
|
boardId: typeof boardId === "string" ? boardId : undefined,
|
|
allowManagedWorktrees:
|
|
Array.isArray(client?.connect?.scopes) &&
|
|
client.connect.scopes.includes("operator.admin"),
|
|
},
|
|
});
|
|
respond(true, {
|
|
...result,
|
|
promoted: result.promoted.map(redactClaimToken),
|
|
reclaimed: result.reclaimed.map(redactClaimToken),
|
|
blocked: result.blocked.map(redactClaimToken),
|
|
orchestrated: result.orchestrated.map(redactClaimToken),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.boards.list",
|
|
async ({ respond }) => {
|
|
try {
|
|
respond(true, await store.listBoards());
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.boards.upsert",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, { board: await store.upsertBoard(requestParams) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.boards.archive",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
board: await store.archiveBoard(requestParams.id, requestParams.archived),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.boards.delete",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.deleteBoard(requestParams.id));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.stats",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.stats({ boardId: requestParams.boardId }));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.runs",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const result = await store.runs(readId(requestParams));
|
|
respond(true, { ...result, card: redactClaimToken(result.card) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.specify",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.specify(readId(requestParams), requestParams, null)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.decompose",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const result = await store.decompose(readId(requestParams), requestParams, null);
|
|
respond(true, {
|
|
parent: redactClaimToken(result.parent),
|
|
children: result.children.map(redactClaimToken),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.notifications.subscribe",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, { subscription: await store.subscribeNotifications(requestParams) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.notifications.list",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.listNotificationSubscriptions(requestParams));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.notifications.delete",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.deleteNotificationSubscription(readId(requestParams)));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.notifications.events",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
assertNoCursorAdvance(requestParams);
|
|
respond(true, await store.notificationEvents(requestParams));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.notifications.advance",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, await store.advanceNotificationEvents(requestParams));
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.attachments.list",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const result = await store.listAttachments(readId(requestParams));
|
|
respond(true, { ...result, card: redactClaimToken(result.card) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.attachments.get",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const attachment = await store.getAttachment(readId(requestParams));
|
|
if (!attachment) {
|
|
throw new Error(`attachment not found: ${readId(requestParams)}`);
|
|
}
|
|
respond(true, attachment);
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.attachments.add",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addAttachment(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.attachments.delete",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
const attachmentId = requestParams.attachmentId;
|
|
if (typeof attachmentId !== "string" || !attachmentId.trim()) {
|
|
throw new Error("attachmentId is required.");
|
|
}
|
|
respond(true, {
|
|
card: redactClaimToken(
|
|
await store.deleteAttachment(readId(requestParams), attachmentId.trim()),
|
|
),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.workerLog",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(await store.addWorkerLog(readId(requestParams), requestParams)),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.protocolViolation",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(
|
|
await store.recordProtocolViolation(readId(requestParams), requestParams),
|
|
),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.archive",
|
|
async ({ params: requestParams, respond }) => {
|
|
try {
|
|
respond(true, {
|
|
card: redactClaimToken(
|
|
await store.archive(readId(requestParams), requestParams.archived),
|
|
),
|
|
});
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: WRITE_SCOPE },
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"workboard.cards.export",
|
|
async ({ respond }) => {
|
|
try {
|
|
const exported = await store.exportCards();
|
|
respond(true, { ...exported, cards: exported.cards.map(redactClaimToken) });
|
|
} catch (error) {
|
|
respondError(respond, error);
|
|
}
|
|
},
|
|
{ scope: READ_SCOPE },
|
|
);
|
|
}
|