Files
openclaw/extensions/workboard/src/store-workflow.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

624 lines
22 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import type {
WorkboardArtifact,
WorkboardCard,
WorkboardClaim,
WorkboardNotification,
WorkboardRunAttempt,
} from "@openclaw/workboard-contract";
import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import {
appendEvent,
assertCanMutateClaimedCard,
capText,
cardChildIds,
cardParentIds,
cardRunId,
cardSessionKey,
closeRunningAttempts,
retryBudgetExhausted,
} from "./store-card-helpers.js";
import {
addWorkboardDurationMs,
DEFAULT_CLAIM_TTL_MS,
MAX_CARD_ARTIFACTS,
MAX_CARD_COMMENTS,
MAX_CARD_NOTIFICATIONS,
MAX_CARD_PROOF,
secondsToDurationMs,
} from "./store-constants.js";
import type {
WorkboardBlockInput,
WorkboardClaimInput,
WorkboardClaimOptions,
WorkboardCompleteInput,
WorkboardDecomposeChildInput,
WorkboardDecomposeInput,
WorkboardHeartbeatInput,
WorkboardMutationScope,
WorkboardProofInput,
WorkboardReassignInput,
WorkboardReclaimInput,
WorkboardSpecifyInput,
} from "./store-inputs.js";
import {
clearDiagnostics,
deriveChildIdempotencyKey,
normalizeArtifact,
normalizeAutomation,
normalizeBoundedString,
normalizeOptionalString,
normalizeProofInput,
normalizeStatus,
normalizeStringList,
removeUndefinedMetadataFields,
} from "./store-normalizers.js";
import { WorkboardPromoteStore } from "./store-promote.js";
function assertClaimIdentity(claim: WorkboardClaim, input: WorkboardHeartbeatInput): void {
const token = normalizeOptionalString(input.token);
const ownerId = normalizeOptionalString(input.ownerId);
if (token && !safeEqualSecret(token, claim.token)) {
throw new Error("claim token does not match.");
}
if (!token && ownerId && ownerId !== claim.ownerId) {
throw new Error("claim owner does not match.");
}
}
export class WorkboardWorkflowStore extends WorkboardPromoteStore {
async claim(
id: string,
input: WorkboardClaimInput,
options: WorkboardClaimOptions = {},
): Promise<{ card: WorkboardCard; token: string }> {
const ownerId = normalizeBoundedString(input.ownerId, undefined, 120, "claim owner");
if (!ownerId) {
throw new Error("claim ownerId is required.");
}
const ttlSeconds =
typeof input.ttlSeconds === "number" && Number.isFinite(input.ttlSeconds)
? Math.max(1, Math.trunc(input.ttlSeconds))
: undefined;
const token =
normalizeBoundedString(input.token, undefined, 160, "claim token") ?? randomUUID();
return await this.enqueueMutation(async () => {
const now = Date.now();
const expiresAt = addWorkboardDurationMs(
now,
ttlSeconds ? secondsToDurationMs(ttlSeconds) : DEFAULT_CLAIM_TTL_MS,
);
const guarded = await this.promoteDependencyReady(id, now);
const expectedAuthority = options.expectedAuthority;
if (
expectedAuthority &&
(guarded.agentId !== expectedAuthority.agentId ||
!isDeepStrictEqual(
guarded.metadata?.automation?.workspace,
expectedAuthority.workspace,
) ||
!isDeepStrictEqual(
guarded.metadata?.automation?.workspaceAccess,
expectedAuthority.workspaceAccess,
))
) {
throw new Error("card workspace authority changed before claim.");
}
const existingClaim = guarded.metadata?.claim;
const activeClaim =
existingClaim && isFutureDateTimestampMs(existingClaim.expiresAt, { nowMs: now })
? existingClaim
: undefined;
if (cardParentIds(guarded).length > 0 && guarded.status !== "ready" && !activeClaim) {
throw new Error("card dependencies are not done.");
}
if (guarded.status === "scheduled") {
throw new Error("card is scheduled for later.");
}
if (retryBudgetExhausted(guarded)) {
throw new Error("card exhausted its retry budget.");
}
if (activeClaim) {
throw new Error(`card already claimed by ${activeClaim.ownerId}.`);
}
const claimable =
options.adoptWorkspaceAccess && !guarded.metadata?.automation?.workspaceAccess
? await this.updateCard(id, { workspaceAccess: options.adoptWorkspaceAccess })
: guarded;
const metadata = clearDiagnostics(claimable.metadata, ["stranded_ready"]);
const card = await this.updateCard(id, {
metadata: {
...metadata,
claim: { ownerId, token, claimedAt: now, lastHeartbeatAt: now, expiresAt },
},
});
const next = await this.updateCard(card.id, {
status:
card.status === "backlog" || card.status === "todo" || card.status === "ready"
? "running"
: card.status,
agentId: card.agentId ?? ownerId,
});
return { card: next, token };
});
}
async heartbeat(id: string, input: WorkboardHeartbeatInput): Promise<WorkboardCard> {
const note = normalizeBoundedString(input.note, undefined, 400, "heartbeat note");
const card = await this.updateMetadata(id, (existing) => {
const claim = existing.metadata?.claim;
if (!claim) {
throw new Error("card is not claimed.");
}
const now = Math.max(Date.now(), claim.lastHeartbeatAt + 1);
assertClaimIdentity(claim, input);
const nextClaim = {
...claim,
lastHeartbeatAt: now,
expiresAt: claim.expiresAt
? addWorkboardDurationMs(
now,
Math.max(
1,
claim.expiresAt > claim.claimedAt
? claim.expiresAt - claim.lastHeartbeatAt
: DEFAULT_CLAIM_TTL_MS,
),
)
: undefined,
};
const metadata = clearDiagnostics(existing.metadata, ["running_without_heartbeat"]);
return {
...metadata,
claim: removeUndefinedMetadataFields({ claim: nextClaim }).claim,
comments: note
? [...(metadata.comments ?? []), { id: randomUUID(), body: note, createdAt: now }].slice(
-MAX_CARD_COMMENTS,
)
: metadata.comments,
};
});
return card;
}
async releaseClaim(
id: string,
input: WorkboardHeartbeatInput & { status?: unknown } = {},
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
const status =
input.status === undefined
? existing.status
: normalizeStatus(input.status, existing.status);
const claim = existing.metadata?.claim;
if (claim) {
assertClaimIdentity(claim, input);
}
return await this.updateCard(
id,
{
status,
metadata: { ...existing.metadata, claim: undefined },
},
{ enforceStatusHolds: input.status !== undefined },
);
});
}
async complete(
id: string,
input: WorkboardCompleteInput = {},
scope: WorkboardMutationScope | null | undefined = input,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => await this.completeDirect(id, input, scope));
}
private async completeDirect(
id: string,
input: WorkboardCompleteInput = {},
scope: WorkboardMutationScope | null | undefined = input,
): Promise<WorkboardCard> {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const now = Date.now();
const createdCardIds = normalizeStringList(input.createdCardIds, "created card ids", 120);
const childIds = cardChildIds(existing);
for (const createdCardId of createdCardIds) {
const createdCard = await this.get(createdCardId);
if (!createdCard) {
throw new Error(`created card not found: ${createdCardId}`);
}
const linkedFromParent =
childIds.includes(createdCardId) && cardParentIds(createdCard).includes(existing.id);
if (!linkedFromParent) {
throw new Error(`created card is not linked to this card: ${createdCardId}`);
}
}
const summary = normalizeBoundedString(input.summary, undefined, 2000, "summary");
const proofInput =
input.proof && typeof input.proof === "object" && !Array.isArray(input.proof)
? (input.proof as WorkboardProofInput)
: undefined;
const proof = proofInput ? normalizeProofInput(proofInput, now) : undefined;
const artifacts = Array.isArray(input.artifacts)
? input.artifacts
.map((artifact) => normalizeArtifact({ ...artifact, createdAt: now }))
.filter((artifact): artifact is WorkboardArtifact => artifact !== null)
.slice(-MAX_CARD_ARTIFACTS)
: [];
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
const notification: WorkboardNotification = {
id: randomUUID(),
kind: "completed",
createdAt: now,
sequence: this.nextNotificationSequence(now),
message: capText(summary, 240) ?? "Workboard card completed.",
...(cardSessionKey(existing) ? { sessionKey: cardSessionKey(existing) } : {}),
...(cardRunId(existing) ? { runId: cardRunId(existing) } : {}),
};
const execution =
existing.execution?.status === "running"
? { ...existing.execution, status: "done" as const, updatedAt: now }
: existing.execution;
return await this.updateCard(
id,
{
status: "done",
...(execution ? { execution } : {}),
metadata: {
...metadata,
claim: undefined,
attempts: closeRunningAttempts(metadata.attempts, now, "succeeded"),
failureCount: 0,
automation: normalizeAutomation(
{
...metadata.automation,
summary,
createdCardIds,
},
metadata.automation,
),
comments: summary
? [
...(metadata.comments ?? []),
{ id: randomUUID(), body: summary, createdAt: now },
].slice(-MAX_CARD_COMMENTS)
: metadata.comments,
proof: proof ? [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF) : metadata.proof,
artifacts: artifacts.length
? [...(metadata.artifacts ?? []), ...artifacts].slice(-MAX_CARD_ARTIFACTS)
: metadata.artifacts,
notifications: [...(metadata.notifications ?? []), notification].slice(
-MAX_CARD_NOTIFICATIONS,
),
},
},
{ enforceStatusHolds: true },
);
}
async block(
id: string,
input: WorkboardBlockInput = {},
scope: WorkboardMutationScope | null | undefined = input,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const now = Date.now();
const reason =
normalizeBoundedString(input.reason, undefined, 2000, "block reason") ??
"Workboard card blocked.";
const metadata = existing.metadata ?? {};
const notification: WorkboardNotification = {
id: randomUUID(),
kind: "failed",
createdAt: now,
sequence: this.nextNotificationSequence(now),
message: capText(reason, 240) ?? "Workboard card blocked.",
...(cardSessionKey(existing) ? { sessionKey: cardSessionKey(existing) } : {}),
...(cardRunId(existing) ? { runId: cardRunId(existing) } : {}),
};
const execution =
existing.execution?.status === "running"
? { ...existing.execution, status: "blocked" as const, updatedAt: now }
: existing.execution;
return await this.updateCard(id, {
status: "blocked",
...(execution ? { execution } : {}),
metadata: {
...metadata,
claim: undefined,
attempts: closeRunningAttempts(metadata.attempts, now, "blocked", reason),
failureCount: (metadata.failureCount ?? 0) + 1,
comments: [
...(metadata.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: now },
].slice(-MAX_CARD_COMMENTS),
notifications: [...(metadata.notifications ?? []), notification].slice(
-MAX_CARD_NOTIFICATIONS,
),
},
});
});
}
async unblock(id: string, scope?: WorkboardMutationScope): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["blocked_too_long"]);
return await this.updateCard(id, { status: "todo", metadata: { ...metadata, stale: null } });
});
}
async reassign(
id: string,
input: WorkboardReassignInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const agentId =
input.agentId === undefined ? existing.agentId : normalizeOptionalString(input.agentId);
const status =
input.status === undefined
? existing.status
: normalizeStatus(input.status, existing.status);
const reason = normalizeBoundedString(input.reason, undefined, 1000, "reassign reason");
const shouldResetFailures = input.resetFailures !== false;
const baseMetadata = shouldResetFailures
? clearDiagnostics(existing.metadata, ["blocked_too_long", "repeated_failures"])
: existing.metadata;
const metadata = {
...baseMetadata,
...(shouldResetFailures ? { failureCount: 0 } : {}),
comments: reason
? [
...(baseMetadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: Date.now() },
].slice(-MAX_CARD_COMMENTS)
: baseMetadata?.comments,
};
return await this.updateCard(id, { agentId, status, metadata }, { enforceStatusHolds: true });
});
}
async reclaim(
id: string,
input: WorkboardReclaimInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
const now = Date.now();
const reason =
normalizeBoundedString(input.reason, undefined, 1000, "reclaim reason") ??
"Workboard claim reclaimed.";
const targetStatus =
input.status === undefined
? existing.status === "running"
? "ready"
: existing.status
: normalizeStatus(input.status, existing.status);
const reclaimed = await this.updateCard(
id,
{
status: targetStatus,
execution: existing.execution?.status === "running" ? null : existing.execution,
metadata: {
...existing.metadata,
claim: undefined,
attempts: closeRunningAttempts(existing.metadata?.attempts, now, "stopped", reason),
comments: [
...(existing.metadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: now },
].slice(-MAX_CARD_COMMENTS),
stale: null,
},
},
{ enforceStatusHolds: true },
);
return await this.promoteDependencyReady(reclaimed.id, now);
});
}
async runs(id: string): Promise<{ card: WorkboardCard; attempts: WorkboardRunAttempt[] }> {
const card = await this.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
return { card, attempts: card.metadata?.attempts ?? [] };
}
async specify(
id: string,
input: WorkboardSpecifyInput = {},
scope?: WorkboardMutationScope | null,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(existing, scope === null ? undefined : scope);
if (
existing.status !== "triage" &&
existing.status !== "backlog" &&
existing.status !== "todo"
) {
throw new Error("only triage, backlog, or todo cards can be specified.");
}
const requestedStatus = normalizeStatus(input.status, "todo");
if (requestedStatus !== "todo") {
throw new Error("specified cards must move to todo.");
}
const now = Date.now();
const summary = normalizeBoundedString(input.summary, undefined, 2000, "spec summary");
const metadata = {
...existing.metadata,
comments: summary
? [
...(existing.metadata?.comments ?? []),
{ id: randomUUID(), body: summary, createdAt: now },
].slice(-MAX_CARD_COMMENTS)
: existing.metadata?.comments,
automation: normalizeAutomation(
{
...existing.metadata?.automation,
summary: summary ?? existing.metadata?.automation?.summary,
},
existing.metadata?.automation,
),
};
const { summary: _summary, status: _status, ...cardPatch } = input;
const updated = await this.updateCard(
id,
{
...cardPatch,
status: "todo",
metadata,
},
{ enforceStatusHolds: true },
);
const specified = {
...updated,
events: appendEvent(updated, { kind: "specified" }, now),
};
await this.store.register(specified.id, { version: 1, card: specified });
return specified;
});
}
async decompose(
id: string,
input: WorkboardDecomposeInput = {},
scope?: WorkboardMutationScope | null,
): Promise<{ parent: WorkboardCard; children: WorkboardCard[] }> {
return await this.enqueueMutation(async () => {
const parent = await this.get(id);
if (!parent) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(parent, scope === null ? undefined : scope);
const childrenInput = Array.isArray(input.children) ? input.children : [];
if (childrenInput.length === 0) {
throw new Error("children are required.");
}
if (childrenInput.length > 20) {
throw new Error("at most 20 children can be created at once.");
}
const parentAutomation = parent.metadata?.automation;
const existingCardIds = new Set((await this.list()).map((card) => card.id));
const children: WorkboardCard[] = [];
const reusedChildSnapshots = new Map<string, WorkboardCard>();
try {
for (const rawChild of childrenInput) {
if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) {
throw new Error("children must be objects.");
}
const child = rawChild as WorkboardDecomposeChildInput;
const created = await this.createDirect(
{
...child,
parents: [parent.id],
boardId: child.boardId ?? parentAutomation?.boardId,
tenant: child.tenant ?? parentAutomation?.tenant,
createdByCardId: parent.id,
idempotencyKey:
child.idempotencyKey ??
deriveChildIdempotencyKey(parentAutomation?.idempotencyKey, children.length + 1),
},
scope === null ? undefined : scope,
);
const reusedUnlinkedChild =
existingCardIds.has(created.id) && !cardParentIds(created).includes(parent.id);
if (reusedUnlinkedChild) {
reusedChildSnapshots.set(created.id, created);
}
children.push(
cardParentIds(created).includes(parent.id)
? created
: await this.linkCardsDirect(parent.id, created.id, Date.now(), {
allowStatusOnlyActiveChild: true,
scope: scope === null ? undefined : scope,
}),
);
}
const summary = normalizeBoundedString(input.summary, undefined, 2000, "decompose summary");
const completeParent = input.completeParent !== false;
const updatedParent = completeParent
? await this.completeDirect(
parent.id,
{ summary, createdCardIds: children.map((child) => child.id) },
scope,
)
: await (async () => {
const latestParent = (await this.get(parent.id)) ?? parent;
return await this.updateCard(
parent.id,
{
status:
latestParent.status === "triage" || latestParent.status === "backlog"
? "todo"
: latestParent.status,
metadata: {
...latestParent.metadata,
automation: normalizeAutomation(
{
...latestParent.metadata?.automation,
summary,
createdCardIds: children.map((child) => child.id),
},
latestParent.metadata?.automation,
),
},
},
{ enforceStatusHolds: true },
);
})();
const decomposedParent = {
...updatedParent,
events: appendEvent(updatedParent, { kind: "decomposed" }),
};
await this.store.register(decomposedParent.id, { version: 1, card: decomposedParent });
return { parent: decomposedParent, children };
} catch (error) {
for (const child of children.toReversed()) {
if (!existingCardIds.has(child.id)) {
await this.deleteDirect(child.id);
}
}
for (const child of reusedChildSnapshots.values()) {
await this.store.register(child.id, { version: 1, card: child });
}
await this.store.register(parent.id, { version: 1, card: parent });
throw error;
}
});
}
}