Files
openclaw/extensions/workboard/src/store.ts
2026-05-31 18:59:02 +01:00

4243 lines
143 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
isFutureDateTimestampMs,
MAX_DATE_TIMESTAMP_MS,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
import { createWorkboardSqliteStores } from "./sqlite-store.js";
import {
WORKBOARD_DIAGNOSTIC_KINDS,
WORKBOARD_DIAGNOSTIC_SEVERITIES,
WORKBOARD_EXECUTION_ENGINES,
WORKBOARD_EXECUTION_MODES,
WORKBOARD_EXECUTION_STATUSES,
WORKBOARD_ATTEMPT_STATUSES,
WORKBOARD_EVENT_KINDS,
WORKBOARD_LINK_TYPES,
WORKBOARD_NOTIFICATION_KINDS,
WORKBOARD_PRIORITIES,
WORKBOARD_PROOF_STATUSES,
WORKBOARD_STATUSES,
WORKBOARD_TEMPLATE_IDS,
type WorkboardCard,
type WorkboardArtifact,
type WorkboardAttachment,
type WorkboardAttemptStatus,
type WorkboardAutomation,
type WorkboardBoardMetadata,
type WorkboardClaim,
type WorkboardComment,
type WorkboardDiagnostic,
type WorkboardDiagnosticAction,
type WorkboardDiagnosticKind,
type WorkboardDiagnosticSeverity,
type WorkboardEvent,
type WorkboardEventKind,
type WorkboardExecution,
type WorkboardExecutionEngine,
type WorkboardExecutionMode,
type WorkboardExecutionStatus,
type WorkboardLink,
type WorkboardLinkType,
type WorkboardMetadata,
type WorkboardNotification,
type WorkboardNotificationKind,
type WorkboardNotificationSubscription,
type WorkboardOrchestrationSettings,
type WorkboardPriority,
type WorkboardProof,
type WorkboardProofStatus,
type WorkboardRunAttempt,
type WorkboardStatus,
type WorkboardTemplateId,
type WorkboardWorkerLog,
type WorkboardWorkerProtocol,
type WorkboardWorkspace,
} from "./types.js";
export type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
const POSITION_STEP = 1000;
const MAX_CARDS = 2000;
const MAX_CARD_EVENTS = 50;
const MAX_CARD_ATTEMPTS = 30;
const MAX_CARD_COMMENTS = 50;
const MAX_CARD_LINKS = 50;
const MAX_CARD_PROOF = 40;
const MAX_CARD_ARTIFACTS = 40;
const MAX_CARD_ATTACHMENTS = 20;
const MAX_ATTACHMENT_ENTRIES = MAX_CARDS * (MAX_CARD_ATTACHMENTS + 1);
const MAX_CARD_WORKER_LOGS = 40;
const MAX_ATTACHMENT_BYTES = 256 * 1024;
const MAX_CARD_DIAGNOSTICS = 12;
const MAX_CARD_NOTIFICATIONS = 20;
const MAX_CARD_METADATA_BYTES = 24 * 1024;
const DEFAULT_CLAIM_TTL_MS = 30 * 60 * 1000;
const READY_STRANDED_MS = 60 * 60 * 1000;
const RUNNING_HEARTBEAT_STALE_MS = 20 * 60 * 1000;
const BLOCKED_TOO_LONG_MS = 24 * 60 * 60 * 1000;
const CLAIM_RECLAIM_MS = 5 * 60 * 1000;
function secondsToDurationMs(seconds: number): number {
const ms = Math.trunc(seconds) * 1000;
return Number.isFinite(ms)
? Math.min(MAX_DATE_TIMESTAMP_MS, Math.max(1, ms))
: MAX_DATE_TIMESTAMP_MS;
}
function addWorkboardDurationMs(now: number, durationMs: number): number {
return resolveExpiresAtMsFromDurationMs(durationMs, { nowMs: now }) ?? MAX_DATE_TIMESTAMP_MS;
}
export type WorkboardCardInput = {
title?: unknown;
notes?: unknown;
status?: unknown;
priority?: unknown;
labels?: unknown;
agentId?: unknown;
sessionKey?: unknown;
runId?: unknown;
taskId?: unknown;
sourceUrl?: unknown;
execution?: unknown;
metadata?: unknown;
templateId?: unknown;
position?: unknown;
tenant?: unknown;
boardId?: unknown;
createdByCardId?: unknown;
idempotencyKey?: unknown;
skills?: unknown;
workspace?: unknown;
maxRuntimeSeconds?: unknown;
maxRetries?: unknown;
scheduledAt?: unknown;
startedAt?: unknown;
completedAt?: unknown;
parents?: unknown;
};
export type WorkboardCardPatch = Partial<WorkboardCardInput>;
export type WorkboardCommentInput = { body?: unknown };
export type WorkboardLinkInput = {
type?: unknown;
targetCardId?: unknown;
title?: unknown;
url?: unknown;
};
export type WorkboardLinkedCreateInput = WorkboardCardInput & {
parents?: unknown;
};
export type WorkboardProofInput = {
status?: unknown;
label?: unknown;
command?: unknown;
url?: unknown;
note?: unknown;
};
export type WorkboardArtifactInput = {
label?: unknown;
url?: unknown;
path?: unknown;
mimeType?: unknown;
};
export type WorkboardAttachmentInput = {
fileName?: unknown;
contentBase64?: unknown;
mimeType?: unknown;
note?: unknown;
};
export type WorkboardWorkerLogInput = {
level?: unknown;
message?: unknown;
sessionKey?: unknown;
runId?: unknown;
};
export type WorkboardProtocolViolationInput = {
detail?: unknown;
sessionKey?: unknown;
runId?: unknown;
};
export type WorkboardClaimInput = {
ownerId?: unknown;
token?: unknown;
ttlSeconds?: unknown;
};
export type WorkboardHeartbeatInput = {
token?: unknown;
ownerId?: unknown;
note?: unknown;
};
export type WorkboardBulkInput = {
ids?: unknown;
patch?: unknown;
archived?: unknown;
};
export type WorkboardCompleteInput = {
ownerId?: unknown;
token?: unknown;
summary?: unknown;
proof?: unknown;
artifacts?: unknown;
createdCardIds?: unknown;
};
export type WorkboardBlockInput = {
ownerId?: unknown;
token?: unknown;
reason?: unknown;
};
export type WorkboardDispatchResult = {
promoted: WorkboardCard[];
reclaimed: WorkboardCard[];
blocked: WorkboardCard[];
orchestrated: WorkboardCard[];
count: number;
};
export type WorkboardListOptions = {
boardId?: unknown;
};
export type WorkboardBoardSummary = {
id: string;
name?: string;
description?: string;
icon?: string;
color?: string;
defaultWorkspace?: WorkboardWorkspace;
orchestration?: WorkboardOrchestrationSettings;
total: number;
active: number;
archived: number;
byStatus: Partial<Record<WorkboardStatus, number>>;
updatedAt?: number;
archivedAt?: number;
};
export type WorkboardStatsResult = WorkboardBoardSummary & {
byAgent: Record<string, number>;
oldestReadyAgeMs?: number;
};
export type WorkboardPromoteInput = {
force?: unknown;
reason?: unknown;
};
export type WorkboardReassignInput = {
agentId?: unknown;
status?: unknown;
resetFailures?: unknown;
reason?: unknown;
};
export type WorkboardReclaimInput = {
status?: unknown;
reason?: unknown;
};
export type WorkboardBoardInput = {
id?: unknown;
name?: unknown;
description?: unknown;
icon?: unknown;
color?: unknown;
defaultWorkspace?: unknown;
orchestration?: unknown;
archived?: unknown;
};
export type WorkboardSpecifyInput = WorkboardCardPatch & {
summary?: unknown;
};
export type WorkboardDecomposeChildInput = WorkboardLinkedCreateInput & {
idempotencyKey?: unknown;
};
export type WorkboardDecomposeInput = {
summary?: unknown;
children?: unknown;
completeParent?: unknown;
};
export type WorkboardNotificationSubscribeInput = {
boardId?: unknown;
cardId?: unknown;
sessionKey?: unknown;
runId?: unknown;
target?: unknown;
eventKinds?: unknown;
};
export type WorkboardNotificationListOptions = {
boardId?: unknown;
cardId?: unknown;
};
export type WorkboardNotificationEventsInput = WorkboardNotificationListOptions & {
subscriptionId?: unknown;
limit?: unknown;
};
export type WorkboardMutationScope = {
ownerId?: unknown;
token?: unknown;
};
export type WorkboardDiagnosticsResult = {
diagnostics: Array<{
card: WorkboardCard;
diagnostics: WorkboardDiagnostic[];
}>;
count: number;
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeBoardId(value: unknown, fallback?: string): string | undefined {
const raw = normalizeBoundedString(value, fallback, 80, "board id");
if (!raw) {
return undefined;
}
const boardId = raw.toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(boardId)) {
throw new Error(
"board id must start with a letter or number and use letters, numbers, dots, dashes, or underscores.",
);
}
return boardId;
}
function normalizeBoardIdRequired(value: unknown): string {
return normalizeBoardId(value) ?? "default";
}
function normalizeBoardMetadata(
input: WorkboardBoardInput,
fallback: WorkboardBoardMetadata | undefined,
now = Date.now(),
): WorkboardBoardMetadata {
const id = normalizeBoardId(input.id, fallback?.id) ?? "default";
const name = normalizeBoundedString(input.name, fallback?.name, 120, "board name");
const description = normalizeBoundedString(
input.description,
fallback?.description,
1000,
"board description",
);
const icon = normalizeBoundedString(input.icon, fallback?.icon, 40, "board icon");
const color = normalizeBoundedString(input.color, fallback?.color, 40, "board color");
const defaultWorkspace = Object.hasOwn(input, "defaultWorkspace")
? normalizeWorkspace(input.defaultWorkspace, fallback?.defaultWorkspace)
: fallback?.defaultWorkspace;
const orchestration = Object.hasOwn(input, "orchestration")
? normalizeOrchestration(input.orchestration, fallback?.orchestration)
: fallback?.orchestration;
const archivedAt = Object.hasOwn(input, "archived")
? input.archived === false
? undefined
: now
: fallback?.archivedAt;
return {
id,
...(name ? { name } : {}),
...(description ? { description } : {}),
...(icon ? { icon } : {}),
...(color ? { color } : {}),
...(defaultWorkspace ? { defaultWorkspace } : {}),
...(orchestration ? { orchestration } : {}),
createdAt: fallback?.createdAt ?? now,
updatedAt: now,
...(archivedAt ? { archivedAt } : {}),
};
}
function normalizeOrchestration(
value: unknown,
fallback?: WorkboardOrchestrationSettings,
): WorkboardOrchestrationSettings | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const record = value as Record<string, unknown>;
const autoDecompose =
typeof record.autoDecompose === "boolean" ? record.autoDecompose : fallback?.autoDecompose;
const autoDecomposePerDispatch =
typeof record.autoDecomposePerDispatch === "number" &&
Number.isFinite(record.autoDecomposePerDispatch)
? Math.max(1, Math.min(20, Math.trunc(record.autoDecomposePerDispatch)))
: fallback?.autoDecomposePerDispatch;
const defaultAssignee = normalizeBoundedString(
record.defaultAssignee,
fallback?.defaultAssignee,
120,
"default assignee",
);
const orchestratorProfile = normalizeBoundedString(
record.orchestratorProfile,
fallback?.orchestratorProfile,
120,
"orchestrator profile",
);
const next: WorkboardOrchestrationSettings = {
...(autoDecompose !== undefined ? { autoDecompose } : {}),
...(autoDecomposePerDispatch ? { autoDecomposePerDispatch } : {}),
...(defaultAssignee ? { defaultAssignee } : {}),
...(orchestratorProfile ? { orchestratorProfile } : {}),
};
return Object.keys(next).length ? next : undefined;
}
function normalizeNotificationKinds(value: unknown): WorkboardNotificationKind[] | undefined {
if (value == null) {
return undefined;
}
const entries = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
const kinds: WorkboardNotificationKind[] = [];
for (const entry of entries) {
const kind = typeof entry === "string" ? entry.trim() : "";
if (!WORKBOARD_NOTIFICATION_KINDS.includes(kind as WorkboardNotificationKind)) {
throw new Error(
`notification kind must be one of: ${WORKBOARD_NOTIFICATION_KINDS.join(", ")}.`,
);
}
const notificationKind = kind as WorkboardNotificationKind;
if (!kinds.includes(notificationKind)) {
kinds.push(notificationKind);
}
}
return kinds.length ? kinds : undefined;
}
function normalizeNotificationSubscription(
input: WorkboardNotificationSubscribeInput,
fallback?: WorkboardNotificationSubscription,
now = Date.now(),
): WorkboardNotificationSubscription {
const boardId = normalizeBoardId(input.boardId, fallback?.boardId) ?? "default";
const cardId = normalizeBoundedString(input.cardId, fallback?.cardId, 120, "card id");
const sessionKey = normalizeBoundedString(
input.sessionKey,
fallback?.sessionKey,
240,
"session key",
);
const runId = normalizeBoundedString(input.runId, fallback?.runId, 160, "run id");
const target = normalizeBoundedString(input.target, fallback?.target, 240, "notification target");
if (!cardId && !sessionKey && !runId && !target) {
throw new Error("notification subscription needs cardId, sessionKey, runId, or target.");
}
const eventKinds = normalizeNotificationKinds(input.eventKinds);
return {
id: fallback?.id ?? randomUUID(),
boardId,
...(cardId ? { cardId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(target ? { target } : {}),
...(eventKinds ? { eventKinds } : {}),
...(fallback?.lastEventAt ? { lastEventAt: fallback.lastEventAt } : {}),
...(fallback?.lastEventId ? { lastEventId: fallback.lastEventId } : {}),
...(fallback?.lastEventSequence ? { lastEventSequence: fallback.lastEventSequence } : {}),
...(fallback?.deliveredEventIds?.length
? { deliveredEventIds: fallback.deliveredEventIds }
: {}),
createdAt: fallback?.createdAt ?? now,
updatedAt: now,
};
}
function normalizeTitle(value: unknown): string {
const title = normalizeOptionalString(value);
if (!title) {
throw new Error("title is required.");
}
if (title.length > 180) {
throw new Error("title must be 180 characters or fewer.");
}
return title;
}
function normalizeNotes(value: unknown): string | undefined {
const notes = normalizeOptionalString(value);
if (!notes) {
return undefined;
}
if (notes.length > 4000) {
throw new Error("notes must be 4000 characters or fewer.");
}
return notes;
}
function normalizeBoundedString(
value: unknown,
fallback: string | undefined,
maxLength: number,
fieldName: string,
): string | undefined {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return fallback;
}
if (normalized.length > maxLength) {
throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`);
}
return normalized;
}
function normalizeStatus(value: unknown, fallback: WorkboardStatus): WorkboardStatus {
if (typeof value !== "string" || !value.trim()) {
return fallback;
}
if ((WORKBOARD_STATUSES as readonly string[]).includes(value)) {
return value as WorkboardStatus;
}
throw new Error(`status must be one of: ${WORKBOARD_STATUSES.join(", ")}.`);
}
function normalizePriority(value: unknown, fallback: WorkboardPriority): WorkboardPriority {
if (typeof value !== "string" || !value.trim()) {
return fallback;
}
if ((WORKBOARD_PRIORITIES as readonly string[]).includes(value)) {
return value as WorkboardPriority;
}
throw new Error(`priority must be one of: ${WORKBOARD_PRIORITIES.join(", ")}.`);
}
function normalizeLabels(value: unknown, fallback: string[] = []): string[] {
if (value == null) {
return fallback;
}
const entries =
typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : undefined;
if (!entries) {
throw new Error("labels must be an array or comma-separated string.");
}
const labels: string[] = [];
for (const entry of entries) {
const label = normalizeOptionalString(entry);
if (!label || labels.includes(label)) {
continue;
}
if (label.length > 40) {
throw new Error("labels must be 40 characters or fewer.");
}
labels.push(label);
if (labels.length >= 12) {
break;
}
}
return labels;
}
function normalizeStringList(value: unknown, fieldName: string, maxLength = 80): string[] {
if (value == null) {
return [];
}
const entries =
typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : undefined;
if (!entries) {
throw new Error(`${fieldName} must be an array or comma-separated string.`);
}
const values: string[] = [];
for (const entry of entries) {
if (Array.isArray(value) && typeof entry !== "string") {
throw new Error(`${fieldName} entries must be strings.`);
}
const normalized = normalizeBoundedString(entry, undefined, maxLength, fieldName);
if (normalized && !values.includes(normalized)) {
values.push(normalized);
}
if (values.length > 20) {
throw new Error(`${fieldName} supports at most 20 entries.`);
}
}
return values;
}
function normalizePosition(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.trunc(value));
}
function normalizePositiveInteger(value: unknown, fieldName: string): number | undefined {
if (value == null || value === "") {
return undefined;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${fieldName} must be a number.`);
}
return Math.max(1, Math.trunc(value));
}
function isAbsoluteWorkspacePath(value: string): boolean {
return (
value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)
);
}
function normalizeWorkspace(
value: unknown,
fallback?: WorkboardWorkspace,
): WorkboardWorkspace | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const record = value as Record<string, unknown>;
const kind =
record.kind === "scratch" || record.kind === "dir" || record.kind === "worktree"
? record.kind
: fallback?.kind;
if (!kind) {
throw new Error("workspace kind must be scratch, dir, or worktree.");
}
const workspacePath = normalizeBoundedString(record.path, fallback?.path, 2000, "workspace path");
if (kind === "dir" && (!workspacePath || !isAbsoluteWorkspacePath(workspacePath))) {
throw new Error("dir workspace path must be absolute.");
}
const branch = normalizeBoundedString(record.branch, fallback?.branch, 160, "workspace branch");
return {
kind,
...(workspacePath ? { path: workspacePath } : {}),
...(branch ? { branch } : {}),
};
}
function normalizeAutomation(
value: unknown,
fallback: WorkboardAutomation = {},
): WorkboardAutomation | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return Object.keys(fallback).length ? fallback : undefined;
}
const record = value as Record<string, unknown>;
const tenant = normalizeBoundedString(record.tenant, fallback.tenant, 80, "tenant");
const boardId = Object.hasOwn(record, "boardId")
? normalizeBoardId(record.boardId, fallback.boardId)
: fallback.boardId;
const createdByCardId = normalizeBoundedString(
record.createdByCardId,
fallback.createdByCardId,
120,
"created by card id",
);
const idempotencyKey = normalizeBoundedString(
record.idempotencyKey,
fallback.idempotencyKey,
160,
"idempotency key",
);
const summary = normalizeBoundedString(record.summary, fallback.summary, 2000, "summary");
const skills = Object.hasOwn(record, "skills")
? normalizeStringList(record.skills, "skills")
: fallback.skills;
const createdCardIds = Object.hasOwn(record, "createdCardIds")
? normalizeStringList(record.createdCardIds, "created card ids", 120)
: fallback.createdCardIds;
const scheduledAt = Object.hasOwn(record, "scheduledAt")
? normalizeTimestamp(record.scheduledAt, 0) || undefined
: fallback.scheduledAt;
const maxRuntimeSeconds = Object.hasOwn(record, "maxRuntimeSeconds")
? normalizePositiveInteger(record.maxRuntimeSeconds, "max runtime seconds")
: fallback.maxRuntimeSeconds;
const maxRetries = Object.hasOwn(record, "maxRetries")
? normalizePositiveInteger(record.maxRetries, "max retries")
: fallback.maxRetries;
const dispatchCount = Object.hasOwn(record, "dispatchCount")
? normalizeTimestamp(record.dispatchCount, 0) || undefined
: fallback.dispatchCount;
const lastDispatchAt = Object.hasOwn(record, "lastDispatchAt")
? normalizeTimestamp(record.lastDispatchAt, 0) || undefined
: fallback.lastDispatchAt;
const workspace = Object.hasOwn(record, "workspace")
? normalizeWorkspace(record.workspace, fallback.workspace)
: fallback.workspace;
const next = removeUndefinedAutomationFields({
...(tenant ? { tenant } : {}),
...(boardId ? { boardId } : {}),
...(createdByCardId ? { createdByCardId } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
...(skills?.length ? { skills } : {}),
...(workspace ? { workspace } : {}),
...(maxRuntimeSeconds ? { maxRuntimeSeconds } : {}),
...(maxRetries ? { maxRetries } : {}),
...(scheduledAt ? { scheduledAt } : {}),
...(summary ? { summary } : {}),
...(createdCardIds?.length ? { createdCardIds } : {}),
...(dispatchCount ? { dispatchCount } : {}),
...(lastDispatchAt ? { lastDispatchAt } : {}),
});
return Object.keys(next).length ? next : undefined;
}
function deriveChildIdempotencyKey(
parentKey: string | undefined,
index: number,
): string | undefined {
if (!parentKey) {
return undefined;
}
const key = `${parentKey}:child:${index}`;
return key.length <= 160 ? key : undefined;
}
function normalizeExecutionEngine(
value: unknown,
fallback: WorkboardExecutionEngine,
): WorkboardExecutionEngine {
if (
typeof value === "string" &&
WORKBOARD_EXECUTION_ENGINES.includes(value as WorkboardExecutionEngine)
) {
return value as WorkboardExecutionEngine;
}
return fallback;
}
function normalizeExecutionMode(
value: unknown,
fallback: WorkboardExecutionMode,
): WorkboardExecutionMode {
if (
typeof value === "string" &&
WORKBOARD_EXECUTION_MODES.includes(value as WorkboardExecutionMode)
) {
return value as WorkboardExecutionMode;
}
return fallback;
}
function normalizeExecutionStatus(
value: unknown,
fallback: WorkboardExecutionStatus,
): WorkboardExecutionStatus {
if (
typeof value === "string" &&
WORKBOARD_EXECUTION_STATUSES.includes(value as WorkboardExecutionStatus)
) {
return value as WorkboardExecutionStatus;
}
return fallback;
}
function normalizeAttemptStatus(
value: unknown,
fallback: WorkboardAttemptStatus,
): WorkboardAttemptStatus {
if (
typeof value === "string" &&
WORKBOARD_ATTEMPT_STATUSES.includes(value as WorkboardAttemptStatus)
) {
return value as WorkboardAttemptStatus;
}
return fallback;
}
function normalizeLinkType(value: unknown, fallback: WorkboardLinkType): WorkboardLinkType {
if (typeof value === "string" && WORKBOARD_LINK_TYPES.includes(value as WorkboardLinkType)) {
return value as WorkboardLinkType;
}
return fallback;
}
function normalizeProofStatus(
value: unknown,
fallback: WorkboardProofStatus,
): WorkboardProofStatus {
if (
typeof value === "string" &&
WORKBOARD_PROOF_STATUSES.includes(value as WorkboardProofStatus)
) {
return value as WorkboardProofStatus;
}
return fallback;
}
function normalizeTemplateId(value: unknown): WorkboardTemplateId | undefined {
return typeof value === "string" && WORKBOARD_TEMPLATE_IDS.includes(value as WorkboardTemplateId)
? (value as WorkboardTemplateId)
: undefined;
}
function normalizeTimestamp(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, Math.trunc(value))
: fallback;
}
function normalizeEvent(value: unknown): WorkboardEvent | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const kind = WORKBOARD_EVENT_KINDS.includes(record.kind as WorkboardEventKind)
? (record.kind as WorkboardEventKind)
: null;
const at = normalizeTimestamp(record.at, 0);
if (!id || !kind || !at) {
return null;
}
const fromStatus =
typeof record.fromStatus === "string" &&
WORKBOARD_STATUSES.includes(record.fromStatus as WorkboardStatus)
? (record.fromStatus as WorkboardStatus)
: undefined;
const toStatus =
typeof record.toStatus === "string" &&
WORKBOARD_STATUSES.includes(record.toStatus as WorkboardStatus)
? (record.toStatus as WorkboardStatus)
: undefined;
const sessionKey = normalizeOptionalString(record.sessionKey);
const runId = normalizeOptionalString(record.runId);
return {
id,
kind,
at,
...(fromStatus ? { fromStatus } : {}),
...(toStatus ? { toStatus } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
}
function normalizeEvents(value: unknown): WorkboardEvent[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map(normalizeEvent)
.filter((event): event is WorkboardEvent => event !== null)
.slice(-MAX_CARD_EVENTS);
}
function normalizeAttempt(value: unknown): WorkboardRunAttempt | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const startedAt = normalizeTimestamp(record.startedAt, 0);
if (!id || !startedAt) {
return null;
}
const endedAt = normalizeTimestamp(record.endedAt, 0);
const sessionKey = normalizeOptionalString(record.sessionKey);
const runId = normalizeOptionalString(record.runId);
const error = normalizeBoundedString(record.error, undefined, 800, "attempt error");
const model = normalizeBoundedString(record.model, undefined, 160, "attempt model");
return {
id,
status: normalizeAttemptStatus(record.status, "running"),
startedAt,
...(endedAt ? { endedAt } : {}),
...(typeof record.engine === "string" &&
WORKBOARD_EXECUTION_ENGINES.includes(record.engine as WorkboardExecutionEngine)
? { engine: record.engine as WorkboardExecutionEngine }
: {}),
...(typeof record.mode === "string" &&
WORKBOARD_EXECUTION_MODES.includes(record.mode as WorkboardExecutionMode)
? { mode: record.mode as WorkboardExecutionMode }
: {}),
...(model ? { model } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(error ? { error } : {}),
};
}
function normalizeComment(value: unknown): WorkboardComment | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const body = normalizeBoundedString(record.body, undefined, 2000, "comment body");
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !body || !createdAt) {
return null;
}
const updatedAt = normalizeTimestamp(record.updatedAt, 0);
return { id, body, createdAt, ...(updatedAt ? { updatedAt } : {}) };
}
function normalizeLink(value: unknown): WorkboardLink | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !createdAt) {
return null;
}
const targetCardId = normalizeBoundedString(record.targetCardId, undefined, 120, "link target");
const title = normalizeBoundedString(record.title, undefined, 180, "link title");
const url = normalizeBoundedString(record.url, undefined, 2000, "link URL");
if (!targetCardId && !url) {
return null;
}
return {
id,
type: normalizeLinkType(record.type, "relates_to"),
createdAt,
...(targetCardId ? { targetCardId } : {}),
...(title ? { title } : {}),
...(url ? { url } : {}),
};
}
function isDependencyLink(link: WorkboardLink): boolean {
return link.type === "parent" || link.type === "child";
}
function normalizeProof(value: unknown): WorkboardProof | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !createdAt) {
return null;
}
const label = normalizeBoundedString(record.label, undefined, 160, "proof label");
const command = normalizeBoundedString(record.command, undefined, 1000, "proof command");
const url = normalizeBoundedString(record.url, undefined, 2000, "proof URL");
const note = normalizeBoundedString(record.note, undefined, 2000, "proof note");
return {
id,
status: normalizeProofStatus(record.status, "unknown"),
createdAt,
...(label ? { label } : {}),
...(command ? { command } : {}),
...(url ? { url } : {}),
...(note ? { note } : {}),
};
}
function normalizeArtifact(value: unknown): WorkboardArtifact | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id) ?? randomUUID();
const createdAt = normalizeTimestamp(record.createdAt, Date.now());
const label = normalizeBoundedString(record.label, undefined, 160, "artifact label");
const url = normalizeBoundedString(record.url, undefined, 2000, "artifact URL");
const artifactPath = normalizeBoundedString(record.path, undefined, 2000, "artifact path");
const mimeType = normalizeBoundedString(record.mimeType, undefined, 160, "artifact MIME type");
if (!url && !artifactPath) {
return null;
}
return {
id,
createdAt,
...(label ? { label } : {}),
...(url ? { url } : {}),
...(artifactPath ? { path: artifactPath } : {}),
...(mimeType ? { mimeType } : {}),
};
}
function normalizeAttachment(value: unknown): WorkboardAttachment | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const cardId = normalizeBoundedString(record.cardId, undefined, 120, "card id");
const fileName = normalizeBoundedString(record.fileName, undefined, 240, "attachment file name");
const createdAt = normalizeTimestamp(record.createdAt, 0);
const byteSize =
typeof record.byteSize === "number" && Number.isFinite(record.byteSize)
? Math.max(0, Math.trunc(record.byteSize))
: 0;
if (!id || !cardId || !fileName || !createdAt || byteSize <= 0) {
return null;
}
const mimeType = normalizeBoundedString(record.mimeType, undefined, 160, "attachment MIME type");
const note = normalizeBoundedString(record.note, undefined, 400, "attachment note");
return {
id,
cardId,
createdAt,
fileName,
byteSize,
...(mimeType ? { mimeType } : {}),
...(note ? { note } : {}),
};
}
function normalizeWorkerLog(value: unknown): WorkboardWorkerLog | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id);
const message = normalizeBoundedString(record.message, undefined, 800, "worker log message");
const createdAt = normalizeTimestamp(record.createdAt, 0);
if (!id || !message || !createdAt) {
return null;
}
const level =
record.level === "warning" || record.level === "error" || record.level === "info"
? record.level
: "info";
const sessionKey = normalizeBoundedString(record.sessionKey, undefined, 240, "session key");
const runId = normalizeBoundedString(record.runId, undefined, 160, "run id");
return {
id,
level,
message,
createdAt,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
}
function normalizeWorkerProtocol(
value: unknown,
fallback?: WorkboardWorkerProtocol,
): WorkboardWorkerProtocol | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const record = value as Record<string, unknown>;
const state =
record.state === "idle" ||
record.state === "running" ||
record.state === "completed" ||
record.state === "blocked" ||
record.state === "violated"
? record.state
: fallback?.state;
if (!state) {
return undefined;
}
const updatedAt = normalizeTimestamp(record.updatedAt, fallback?.updatedAt ?? Date.now());
const detail = normalizeBoundedString(record.detail, fallback?.detail, 800, "protocol detail");
return {
state,
updatedAt,
...(detail ? { detail } : {}),
};
}
function normalizeAttachmentInput(
cardId: string,
input: WorkboardAttachmentInput,
now: number,
): { attachment: WorkboardAttachment; contentBase64: string } {
const fileName = normalizeBoundedString(input.fileName, undefined, 240, "attachment file name");
if (!fileName) {
throw new Error("attachment fileName is required.");
}
const contentBase64 =
typeof input.contentBase64 === "string" && input.contentBase64
? input.contentBase64
: undefined;
if (!contentBase64) {
throw new Error("attachment contentBase64 is required.");
}
if (
!/^[A-Za-z0-9+/]*={0,2}$/.test(contentBase64) ||
contentBase64.length % 4 !== 0 ||
contentBase64.length > Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4
) {
throw new Error("attachment contentBase64 must be canonical base64.");
}
const decoded = Buffer.from(contentBase64, "base64");
if (decoded.toString("base64") !== contentBase64) {
throw new Error("attachment contentBase64 must be canonical base64.");
}
const byteSize = decoded.length;
if (byteSize <= 0 || byteSize > MAX_ATTACHMENT_BYTES) {
throw new Error(`attachment must be between 1 and ${MAX_ATTACHMENT_BYTES} bytes.`);
}
const mimeType = normalizeBoundedString(input.mimeType, undefined, 160, "attachment MIME type");
const note = normalizeBoundedString(input.note, undefined, 400, "attachment note");
const attachment: WorkboardAttachment = {
id: randomUUID(),
cardId,
createdAt: now,
fileName,
byteSize,
...(mimeType ? { mimeType } : {}),
...(note ? { note } : {}),
};
return { attachment, contentBase64 };
}
function normalizeClaim(value: unknown, fallback?: WorkboardClaim): WorkboardClaim | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const record = value as Record<string, unknown>;
const ownerId = normalizeBoundedString(record.ownerId, fallback?.ownerId, 120, "claim owner");
const token = normalizeBoundedString(record.token, fallback?.token, 160, "claim token");
const claimedAt = normalizeTimestamp(record.claimedAt, fallback?.claimedAt ?? Date.now());
const lastHeartbeatAt = normalizeTimestamp(
record.lastHeartbeatAt,
fallback?.lastHeartbeatAt ?? claimedAt,
);
const expiresAt = normalizeTimestamp(record.expiresAt, fallback?.expiresAt ?? 0);
if (!ownerId || !token || !claimedAt || !lastHeartbeatAt) {
return undefined;
}
return {
ownerId,
token,
claimedAt,
lastHeartbeatAt,
...(expiresAt ? { expiresAt } : {}),
};
}
function normalizeDiagnosticAction(value: unknown): WorkboardDiagnosticAction | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const kind =
record.kind === "claim" ||
record.kind === "unblock" ||
record.kind === "reassign" ||
record.kind === "add_proof" ||
record.kind === "open_session"
? record.kind
: undefined;
const label = normalizeBoundedString(record.label, undefined, 120, "diagnostic action label");
return kind && label ? { kind, label } : null;
}
function normalizeDiagnostic(value: unknown): WorkboardDiagnostic | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const kind = WORKBOARD_DIAGNOSTIC_KINDS.includes(record.kind as WorkboardDiagnosticKind)
? (record.kind as WorkboardDiagnosticKind)
: undefined;
const severity = WORKBOARD_DIAGNOSTIC_SEVERITIES.includes(
record.severity as WorkboardDiagnosticSeverity,
)
? (record.severity as WorkboardDiagnosticSeverity)
: "warning";
const title = normalizeBoundedString(record.title, undefined, 160, "diagnostic title");
const detail = normalizeBoundedString(record.detail, undefined, 800, "diagnostic detail");
const firstSeenAt = normalizeTimestamp(record.firstSeenAt, Date.now());
const lastSeenAt = normalizeTimestamp(record.lastSeenAt, firstSeenAt);
if (!kind || !title || !detail) {
return null;
}
return {
kind,
severity,
title,
detail,
firstSeenAt,
lastSeenAt,
count:
typeof record.count === "number" && Number.isFinite(record.count)
? Math.max(1, Math.trunc(record.count))
: 1,
actions: Array.isArray(record.actions)
? record.actions
.map(normalizeDiagnosticAction)
.filter((action): action is WorkboardDiagnosticAction => action !== null)
.slice(0, 4)
: [],
};
}
function normalizeNotification(value: unknown): WorkboardNotification | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeOptionalString(record.id) ?? randomUUID();
const kind = WORKBOARD_NOTIFICATION_KINDS.includes(record.kind as WorkboardNotificationKind)
? (record.kind as WorkboardNotificationKind)
: undefined;
const createdAt = normalizeTimestamp(record.createdAt, Date.now());
const sequence = normalizeTimestamp(record.sequence, 0) || undefined;
const message = normalizeBoundedString(record.message, undefined, 240, "notification message");
if (!kind || !message) {
return null;
}
const sessionKey = normalizeBoundedString(record.sessionKey, undefined, 240, "session key");
const runId = normalizeBoundedString(record.runId, undefined, 120, "run id");
return {
id,
kind,
createdAt,
...(sequence ? { sequence } : {}),
message,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
}
function normalizeProofInput(input: WorkboardProofInput, now: number): WorkboardProof {
const label = normalizeBoundedString(input.label, undefined, 160, "proof label");
const command = normalizeBoundedString(input.command, undefined, 1000, "proof command");
const url = normalizeBoundedString(input.url, undefined, 2000, "proof URL");
const note = normalizeBoundedString(input.note, undefined, 2000, "proof note");
return {
id: randomUUID(),
status: normalizeProofStatus(input.status, "unknown"),
createdAt: now,
...(label ? { label } : {}),
...(command ? { command } : {}),
...(url ? { url } : {}),
...(note ? { note } : {}),
};
}
function normalizeMetadata(
value: unknown,
fallback: WorkboardMetadata = {},
options: { allowDependencyLinks?: boolean } = {},
): WorkboardMetadata {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return trimMetadataToBudget(fallback);
}
const record = value as Record<string, unknown>;
const stale =
record.stale && typeof record.stale === "object" && !Array.isArray(record.stale)
? (record.stale as Record<string, unknown>)
: null;
const hasArchivedAt = Object.hasOwn(record, "archivedAt");
const hasStale = Object.hasOwn(record, "stale");
const links = Array.isArray(record.links)
? record.links.map(normalizeLink).filter((link): link is WorkboardLink => link !== null)
: undefined;
const normalizedLinks =
links === undefined
? fallback.links
: options.allowDependencyLinks === false
? (() => {
const dependencyLinks = (fallback.links ?? []).filter(isDependencyLink);
const ordinaryCapacity = Math.max(0, MAX_CARD_LINKS - dependencyLinks.length);
return [
...dependencyLinks.slice(-MAX_CARD_LINKS),
...(ordinaryCapacity > 0
? links.filter((link) => !isDependencyLink(link)).slice(-ordinaryCapacity)
: []),
];
})()
: links.slice(-MAX_CARD_LINKS);
return trimMetadataToBudget({
attempts: Array.isArray(record.attempts)
? record.attempts
.map(normalizeAttempt)
.filter((attempt): attempt is WorkboardRunAttempt => attempt !== null)
.slice(-MAX_CARD_ATTEMPTS)
: fallback.attempts,
comments: Array.isArray(record.comments)
? record.comments
.map(normalizeComment)
.filter((comment): comment is WorkboardComment => comment !== null)
.slice(-MAX_CARD_COMMENTS)
: fallback.comments,
links: normalizedLinks,
proof: Array.isArray(record.proof)
? record.proof
.map(normalizeProof)
.filter((proof): proof is WorkboardProof => proof !== null)
.slice(-MAX_CARD_PROOF)
: fallback.proof,
artifacts: Array.isArray(record.artifacts)
? record.artifacts
.map(normalizeArtifact)
.filter((artifact): artifact is WorkboardArtifact => artifact !== null)
.slice(-MAX_CARD_ARTIFACTS)
: fallback.artifacts,
attachments: Array.isArray(record.attachments)
? record.attachments
.map(normalizeAttachment)
.filter((attachment): attachment is WorkboardAttachment => attachment !== null)
.slice(-MAX_CARD_ATTACHMENTS)
: fallback.attachments,
workerLogs: Array.isArray(record.workerLogs)
? record.workerLogs
.map(normalizeWorkerLog)
.filter((log): log is WorkboardWorkerLog => log !== null)
.slice(-MAX_CARD_WORKER_LOGS)
: fallback.workerLogs,
workerProtocol: Object.hasOwn(record, "workerProtocol")
? normalizeWorkerProtocol(record.workerProtocol, fallback.workerProtocol)
: fallback.workerProtocol,
automation: Object.hasOwn(record, "automation")
? normalizeAutomation(record.automation, fallback.automation)
: fallback.automation,
claim: Object.hasOwn(record, "claim")
? record.claim
? normalizeClaim(record.claim, fallback.claim)
: undefined
: fallback.claim,
diagnostics: Array.isArray(record.diagnostics)
? record.diagnostics
.map(normalizeDiagnostic)
.filter(
(diagnosticLocal): diagnosticLocal is WorkboardDiagnostic => diagnosticLocal !== null,
)
.slice(-MAX_CARD_DIAGNOSTICS)
: fallback.diagnostics,
notifications: Array.isArray(record.notifications)
? record.notifications
.map(normalizeNotification)
.filter((notification): notification is WorkboardNotification => notification !== null)
.slice(-MAX_CARD_NOTIFICATIONS)
: fallback.notifications,
templateId: normalizeTemplateId(record.templateId) ?? fallback.templateId,
archivedAt: hasArchivedAt
? normalizeTimestamp(record.archivedAt, 0) || undefined
: fallback.archivedAt,
stale: hasStale
? stale
? {
detectedAt: normalizeTimestamp(stale.detectedAt, Date.now()),
lastSessionUpdatedAt: normalizeTimestamp(stale.lastSessionUpdatedAt, 0) || undefined,
reason:
normalizeBoundedString(stale.reason, fallback.stale?.reason, 240, "stale reason") ??
"Session has not reported recent activity.",
}
: undefined
: fallback.stale,
failureCount:
typeof record.failureCount === "number" && Number.isFinite(record.failureCount)
? Math.max(0, Math.trunc(record.failureCount))
: fallback.failureCount,
});
}
function normalizeExecution(value: unknown): WorkboardExecution | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const now = Date.now();
const model = normalizeOptionalString(record.model);
const id = normalizeOptionalString(record.id) ?? randomUUID();
if (!model) {
return undefined;
}
const startedAt = normalizeTimestamp(record.startedAt, now);
const updatedAt = normalizeTimestamp(record.updatedAt, startedAt);
const sessionKey = normalizeOptionalString(record.sessionKey);
const runId = normalizeOptionalString(record.runId);
return {
id,
kind: "agent-session",
engine: normalizeExecutionEngine(record.engine, "codex"),
mode: normalizeExecutionMode(record.mode, "autonomous"),
status: normalizeExecutionStatus(record.status, "idle"),
model,
startedAt,
updatedAt,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
}
function syncExecutionSessionKey(
execution: WorkboardExecution | undefined,
sessionKey: string | undefined,
): WorkboardExecution | undefined {
if (!execution) {
return undefined;
}
return removeUndefinedExecutionFields({
...execution,
sessionKey,
updatedAt: Date.now(),
});
}
function removeUndefinedExecutionFields(execution: WorkboardExecution): WorkboardExecution {
const next = { ...execution };
if (next.sessionKey === undefined) {
delete next.sessionKey;
}
if (next.runId === undefined) {
delete next.runId;
}
return next;
}
function removeUndefinedAutomationFields(automation: WorkboardAutomation): WorkboardAutomation {
const next = { ...automation };
for (const key of [
"tenant",
"boardId",
"createdByCardId",
"idempotencyKey",
"skills",
"workspace",
"maxRuntimeSeconds",
"maxRetries",
"scheduledAt",
"summary",
"createdCardIds",
"dispatchCount",
"lastDispatchAt",
] as const) {
const value = next[key];
if (
value === undefined ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" && value !== null && Object.keys(value).length === 0)
) {
delete next[key];
}
}
return next;
}
function removeUndefinedMetadataFields(metadata: WorkboardMetadata): WorkboardMetadata {
const next = { ...metadata };
for (const key of [
"attempts",
"comments",
"links",
"proof",
"artifacts",
"attachments",
"workerLogs",
"workerProtocol",
"automation",
"claim",
"diagnostics",
"notifications",
"templateId",
"archivedAt",
"stale",
"failureCount",
] as const) {
const value = next[key];
if (
value === undefined ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "number" && value === 0 && key === "failureCount")
) {
delete next[key];
}
}
return next;
}
function clearDiagnostics(
metadata: WorkboardMetadata | undefined,
kinds: readonly WorkboardDiagnosticKind[],
): WorkboardMetadata {
if (!metadata?.diagnostics) {
return metadata ?? {};
}
return {
...metadata,
diagnostics: metadata.diagnostics.filter((entry) => !kinds.includes(entry.kind)),
};
}
function metadataIsEmpty(metadata: WorkboardMetadata | undefined): boolean {
return !metadata || Object.keys(metadata).length === 0;
}
function metadataByteSize(metadata: WorkboardMetadata): number {
return Buffer.byteLength(JSON.stringify(metadata), "utf8");
}
function dropFirst<T>(items: readonly T[] | undefined): T[] | undefined {
if (!items?.length) {
return undefined;
}
const next = items.slice(1);
return next.length ? next : undefined;
}
function dropFirstNonDependencyLink(
items: readonly WorkboardLink[] | undefined,
): WorkboardLink[] | undefined {
if (!items?.length) {
return undefined;
}
const index = items.findIndex((link) => !isDependencyLink(link));
if (index < 0) {
return items.slice();
}
const next = items.filter((_, itemIndex) => itemIndex !== index);
return next.length ? next : undefined;
}
function appendLinkPreservingDependencies(
links: readonly WorkboardLink[],
link: WorkboardLink,
): WorkboardLink[] {
const next = [...links, link];
if (next.length <= MAX_CARD_LINKS) {
return next;
}
const dropIndex = next.findIndex((entry) => !isDependencyLink(entry));
if (dropIndex < 0 || dropIndex === next.length - 1) {
throw new Error("card link limit reached.");
}
return next.filter((_, index) => index !== dropIndex);
}
function trimMetadataToBudget(metadata: WorkboardMetadata): WorkboardMetadata {
let next = removeUndefinedMetadataFields(metadata);
while (metadataByteSize(next) > MAX_CARD_METADATA_BYTES) {
const currentSize = metadataByteSize(next);
if (next.attempts?.length) {
next = removeUndefinedMetadataFields({ ...next, attempts: dropFirst(next.attempts) });
} else if (next.diagnostics?.length) {
next = removeUndefinedMetadataFields({ ...next, diagnostics: dropFirst(next.diagnostics) });
} else if (next.notifications?.length) {
next = removeUndefinedMetadataFields({
...next,
notifications: dropFirst(next.notifications),
});
} else if (next.proof?.length) {
next = removeUndefinedMetadataFields({ ...next, proof: dropFirst(next.proof) });
} else if (next.artifacts?.length) {
next = removeUndefinedMetadataFields({ ...next, artifacts: dropFirst(next.artifacts) });
} else if (next.attachments?.length) {
next = removeUndefinedMetadataFields({
...next,
attachments: dropFirst(next.attachments),
});
} else if (next.workerLogs?.length) {
next = removeUndefinedMetadataFields({ ...next, workerLogs: dropFirst(next.workerLogs) });
} else if (next.links?.length) {
const links = dropFirstNonDependencyLink(next.links);
if (links?.length === next.links.length) {
next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) });
} else {
next = removeUndefinedMetadataFields({ ...next, links });
}
} else if (next.comments?.length) {
next = removeUndefinedMetadataFields({ ...next, comments: dropFirst(next.comments) });
}
if (metadataByteSize(next) >= currentSize) {
break;
}
}
return next;
}
function compareCards(left: WorkboardCard, right: WorkboardCard): number {
if (left.status !== right.status) {
return WORKBOARD_STATUSES.indexOf(left.status) - WORKBOARD_STATUSES.indexOf(right.status);
}
if (left.position !== right.position) {
return left.position - right.position;
}
return left.createdAt - right.createdAt;
}
function cardSessionKey(card: WorkboardCard): string | undefined {
return card.sessionKey ?? card.execution?.sessionKey;
}
function cardRunId(card: WorkboardCard): string | undefined {
return card.runId ?? card.execution?.runId;
}
function executionAttemptStatus(execution: WorkboardExecution): WorkboardAttemptStatus {
if (execution.status === "running") {
return "running";
}
if (execution.status === "blocked") {
return "blocked";
}
if (execution.status === "done" || execution.status === "review") {
return "succeeded";
}
return "stopped";
}
function syncExecutionAttemptMetadata(
metadata: WorkboardMetadata,
execution: WorkboardExecution | undefined,
now: number,
): WorkboardMetadata {
if (!execution) {
return metadata;
}
const attemptStatus = executionAttemptStatus(execution);
const attempts = [...(metadata.attempts ?? [])];
const key = execution.runId ?? execution.sessionKey ?? execution.id;
const existingIndex = attempts.findIndex(
(attempt) =>
(execution.runId && attempt.runId === execution.runId) ||
(!execution.runId && attempt.id === key),
);
const existingAttempt = existingIndex >= 0 ? attempts[existingIndex] : undefined;
const nextAttempt: WorkboardRunAttempt = {
id: existingAttempt?.id ?? key,
status: attemptStatus,
startedAt: existingAttempt?.startedAt ?? execution.startedAt,
engine: execution.engine,
mode: execution.mode,
model: execution.model,
...(execution.sessionKey ? { sessionKey: execution.sessionKey } : {}),
...(execution.runId ? { runId: execution.runId } : {}),
...(attemptStatus !== "running" && { endedAt: execution.updatedAt || now }),
...(attemptStatus !== "succeeded" && existingAttempt?.error
? { error: existingAttempt.error }
: {}),
};
if (existingIndex >= 0) {
attempts[existingIndex] = nextAttempt;
} else {
attempts.push(nextAttempt);
}
const previousFailed =
existingAttempt?.status === "blocked" || existingAttempt?.status === "failed";
const attemptFailed = attemptStatus === "blocked" || attemptStatus === "failed";
const failureCount = attemptFailed
? previousFailed
? metadata.failureCount
: (metadata.failureCount ?? 0) + 1
: attemptStatus === "succeeded"
? 0
: metadata.failureCount;
return removeUndefinedMetadataFields({
...metadata,
attempts: attempts.slice(-MAX_CARD_ATTEMPTS),
failureCount,
});
}
function appendEvent(
card: WorkboardCard,
event: Omit<WorkboardEvent, "id" | "at">,
at = Date.now(),
): WorkboardEvent[] {
return [
...normalizeEvents(card.events),
{
id: randomUUID(),
at,
...event,
},
].slice(-MAX_CARD_EVENTS);
}
function latestMetadataIdChanged(
existing: readonly { id: string }[] | undefined,
next: readonly { id: string }[] | undefined,
): boolean {
const latestId = next?.at(-1)?.id;
return Boolean(latestId && latestId !== existing?.at(-1)?.id);
}
function updateEvent(
existing: WorkboardCard,
next: WorkboardCard,
): Omit<WorkboardEvent, "id" | "at"> {
if (
existing.metadata?.workerProtocol?.state !== next.metadata?.workerProtocol?.state &&
next.metadata?.workerProtocol?.state === "violated"
) {
return { kind: "protocol_violation" };
}
if (existing.status !== next.status || existing.position !== next.position) {
return {
kind: "moved",
fromStatus: existing.status,
toStatus: next.status,
};
}
if (cardSessionKey(existing) !== cardSessionKey(next)) {
return {
kind: "linked",
...(cardSessionKey(next) ? { sessionKey: cardSessionKey(next) } : {}),
};
}
if (existing.metadata?.claim?.token !== next.metadata?.claim?.token) {
return { kind: "claimed" };
}
if (existing.metadata?.claim?.lastHeartbeatAt !== next.metadata?.claim?.lastHeartbeatAt) {
return { kind: "heartbeat" };
}
if (
existing.execution?.status !== next.execution?.status ||
existing.execution?.engine !== next.execution?.engine ||
cardRunId(existing) !== cardRunId(next)
) {
const existingAttempts = existing.metadata?.attempts ?? [];
const nextAttempts = next.metadata?.attempts ?? [];
const latestAttempt = nextAttempts.at(-1);
if (nextAttempts.length > existingAttempts.length) {
return {
kind: "attempt_started",
...(latestAttempt?.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}),
...(latestAttempt?.runId ? { runId: latestAttempt.runId } : {}),
};
}
const previousAttempt = latestAttempt
? existingAttempts.find((attempt) => attempt.id === latestAttempt.id)
: undefined;
if (latestAttempt && previousAttempt?.status !== latestAttempt.status) {
return {
kind: "attempt_updated",
...(latestAttempt.sessionKey ? { sessionKey: latestAttempt.sessionKey } : {}),
...(latestAttempt.runId ? { runId: latestAttempt.runId } : {}),
};
}
return {
kind: "execution_updated",
...(cardSessionKey(next) ? { sessionKey: cardSessionKey(next) } : {}),
...(cardRunId(next) ? { runId: cardRunId(next) } : {}),
};
}
if (
(existing.metadata?.comments?.length ?? 0) !== (next.metadata?.comments?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.comments, next.metadata?.comments)
) {
return { kind: "comment_added" };
}
if (
(existing.metadata?.links?.length ?? 0) !== (next.metadata?.links?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.links, next.metadata?.links)
) {
return { kind: "link_added" };
}
if (
(existing.metadata?.proof?.length ?? 0) !== (next.metadata?.proof?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.proof, next.metadata?.proof)
) {
return { kind: "proof_added" };
}
if (
(existing.metadata?.artifacts?.length ?? 0) !== (next.metadata?.artifacts?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.artifacts, next.metadata?.artifacts)
) {
return { kind: "artifact_added" };
}
if (
(existing.metadata?.attachments?.length ?? 0) !== (next.metadata?.attachments?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.attachments, next.metadata?.attachments)
) {
return (next.metadata?.attachments?.length ?? 0) > (existing.metadata?.attachments?.length ?? 0)
? { kind: "attachment_added" }
: { kind: "edited" };
}
if (existing.metadata?.workerProtocol?.state !== next.metadata?.workerProtocol?.state) {
return { kind: "orchestration" };
}
if (
(existing.metadata?.workerLogs?.length ?? 0) !== (next.metadata?.workerLogs?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.workerLogs, next.metadata?.workerLogs)
) {
return { kind: "orchestration" };
}
if ((existing.metadata?.diagnostics?.length ?? 0) !== (next.metadata?.diagnostics?.length ?? 0)) {
return { kind: "diagnostic" };
}
if (
(existing.metadata?.notifications?.length ?? 0) !==
(next.metadata?.notifications?.length ?? 0) ||
latestMetadataIdChanged(existing.metadata?.notifications, next.metadata?.notifications)
) {
return { kind: "notification" };
}
if (
existing.metadata?.automation?.dispatchCount !== next.metadata?.automation?.dispatchCount ||
existing.metadata?.automation?.lastDispatchAt !== next.metadata?.automation?.lastDispatchAt
) {
return { kind: "dispatch" };
}
if (!existing.metadata?.archivedAt && next.metadata?.archivedAt) {
return { kind: "archived" };
}
if (existing.metadata?.archivedAt && !next.metadata?.archivedAt) {
return { kind: "unarchived" };
}
if (!existing.metadata?.stale && next.metadata?.stale) {
return { kind: "stale" };
}
return { kind: "edited" };
}
function removeUndefinedCardFields(card: WorkboardCard): WorkboardCard {
const next = { ...card };
for (const key of [
"notes",
"agentId",
"sessionKey",
"runId",
"taskId",
"sourceUrl",
"execution",
"startedAt",
"completedAt",
"metadata",
] as const) {
if (next[key] === undefined) {
delete next[key];
}
}
if (metadataIsEmpty(next.metadata)) {
delete next.metadata;
}
return next;
}
function assertCanMutateClaimedCard(
card: WorkboardCard,
scope: WorkboardMutationScope | undefined,
) {
if (!scope) {
return;
}
const claim = card.metadata?.claim;
if (!claim) {
return;
}
const ownerId = normalizeOptionalString(scope.ownerId);
const token = normalizeOptionalString(scope.token);
if (claim.ownerId === ownerId || (token && claim.token === token)) {
return;
}
throw new Error(`card is claimed by ${claim.ownerId}.`);
}
function retryBudgetExhausted(card: WorkboardCard): boolean {
const maxRetries = card.metadata?.automation?.maxRetries;
return Boolean(maxRetries && (card.metadata?.failureCount ?? 0) > maxRetries);
}
function diagnostic(
params: {
kind: WorkboardDiagnosticKind;
severity: WorkboardDiagnosticSeverity;
title: string;
detail: string;
actions: WorkboardDiagnosticAction[];
},
now: number,
): WorkboardDiagnostic {
return {
...params,
firstSeenAt: now,
lastSeenAt: now,
count: 1,
};
}
function mergeDiagnostics(
previous: readonly WorkboardDiagnostic[] | undefined,
next: WorkboardDiagnostic[],
): WorkboardDiagnostic[] {
const byKind = new Map(previous?.map((entry) => [entry.kind, entry]));
return next.map((entry) => {
const prior = byKind.get(entry.kind);
return prior
? {
...entry,
firstSeenAt: prior.firstSeenAt,
count: prior.count + 1,
}
: entry;
});
}
function computeCardDiagnostics(card: WorkboardCard, now: number): WorkboardDiagnostic[] {
const diagnostics: WorkboardDiagnostic[] = [];
const claim = card.metadata?.claim;
const lastHeartbeatAt = claim?.lastHeartbeatAt ?? card.execution?.updatedAt ?? card.updatedAt;
if (
(card.status === "todo" || card.status === "backlog" || card.status === "ready") &&
card.agentId &&
now - card.updatedAt > READY_STRANDED_MS
) {
diagnostics.push(
diagnostic(
{
kind: "stranded_ready",
severity: "warning",
title: "Assigned card is waiting",
detail: "The card has an assigned agent but has not been claimed recently.",
actions: [{ kind: "claim", label: "Claim card" }],
},
now,
),
);
}
if (card.status === "running" && now - lastHeartbeatAt > RUNNING_HEARTBEAT_STALE_MS) {
diagnostics.push(
diagnostic(
{
kind: "running_without_heartbeat",
severity: "error",
title: "Running card has no recent heartbeat",
detail: "The linked run or claim has not reported recent activity.",
actions: [
{ kind: "open_session", label: "Open session" },
{ kind: "reassign", label: "Reassign card" },
],
},
now,
),
);
}
if (card.status === "blocked" && now - card.updatedAt > BLOCKED_TOO_LONG_MS) {
diagnostics.push(
diagnostic(
{
kind: "blocked_too_long",
severity: "warning",
title: "Blocked card needs attention",
detail: "The card has been blocked for more than a day.",
actions: [{ kind: "unblock", label: "Move to todo" }],
},
now,
),
);
}
if ((card.metadata?.failureCount ?? 0) >= 2) {
diagnostics.push(
diagnostic(
{
kind: "repeated_failures",
severity: "error",
title: "Repeated run failures",
detail: "Multiple attempts failed or blocked on this card.",
actions: [{ kind: "reassign", label: "Reassign card" }],
},
now,
),
);
}
if (
card.status === "done" &&
!(
card.metadata?.proof?.length ||
card.metadata?.artifacts?.length ||
card.metadata?.attachments?.length
)
) {
diagnostics.push(
diagnostic(
{
kind: "missing_proof",
severity: "warning",
title: "Done card has no proof",
detail: "The card is marked done without proof or an attached artifact.",
actions: [{ kind: "add_proof", label: "Add proof" }],
},
now,
),
);
}
if (card.sessionKey && !card.execution && card.status === "running") {
diagnostics.push(
diagnostic(
{
kind: "orphaned_session",
severity: "warning",
title: "Running card has only a loose session link",
detail: "The card is running but has no execution record for lifecycle handoff.",
actions: [{ kind: "open_session", label: "Open session" }],
},
now,
),
);
}
return diagnostics;
}
function capText(value: string | undefined, max: number): string | undefined {
if (!value) {
return undefined;
}
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}`;
}
function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
function cardResultSummary(card: WorkboardCard): string | undefined {
return (
card.metadata?.automation?.summary ??
card.metadata?.comments?.findLast((comment) => comment.body.trim())?.body ??
card.metadata?.proof?.findLast((proof) => proof.note?.trim())?.note
);
}
function buildWorkerContext(card: WorkboardCard, cards: readonly WorkboardCard[] = []): string {
const lines = [
`# Workboard card ${card.id}`,
`Title: ${card.title}`,
`Status: ${card.status}`,
`Priority: ${card.priority}`,
`Board: ${cardBoardId(card)}`,
`Agent: ${card.agentId ?? "(default)"}`,
];
if (card.notes) {
lines.push("", "## Notes", capText(card.notes, 4000) ?? "");
}
const attempts = card.metadata?.attempts?.slice(-8) ?? [];
if (attempts.length) {
lines.push("", "## Recent attempts");
for (const attempt of attempts) {
lines.push(
`- ${attempt.status} ${attempt.model ?? ""} ${attempt.error ? `error=${capText(attempt.error, 240)}` : ""}`.trim(),
);
}
}
const comments = card.metadata?.comments?.slice(-12) ?? [];
if (comments.length) {
lines.push("", "## Recent comments");
for (const comment of comments) {
lines.push(`- ${capText(comment.body, 400)}`);
}
}
const proof = card.metadata?.proof?.slice(-8) ?? [];
if (proof.length) {
lines.push("", "## Proof");
for (const entry of proof) {
lines.push(
`- ${entry.status}: ${capText(entry.label ?? entry.command ?? entry.url ?? entry.note, 400)}`,
);
}
}
const artifacts = card.metadata?.artifacts?.slice(-8) ?? [];
if (artifacts.length) {
lines.push("", "## Artifacts");
for (const artifact of artifacts) {
lines.push(`- ${capText(artifact.label ?? artifact.url ?? artifact.path, 400)}`);
}
}
const attachments = card.metadata?.attachments?.slice(-8) ?? [];
if (attachments.length) {
lines.push("", "## Attachments");
for (const attachment of attachments) {
const detail = [
attachment.fileName,
`${attachment.byteSize} bytes`,
attachment.mimeType,
attachment.note,
]
.filter(Boolean)
.join(" · ");
lines.push(`- ${capText(detail, 500)}`);
}
}
if (card.metadata?.workerProtocol) {
const protocol = card.metadata.workerProtocol;
lines.push("", "## Worker protocol");
lines.push(`${protocol.state}: ${capText(protocol.detail, 500) ?? "no detail"}`);
}
const workerLogs = card.metadata?.workerLogs?.slice(-8) ?? [];
if (workerLogs.length) {
lines.push("", "## Worker logs");
for (const log of workerLogs) {
lines.push(`- ${log.level}: ${capText(log.message, 500)}`);
}
}
const links = card.metadata?.links?.slice(-8) ?? [];
if (links.length) {
lines.push("", "## Links");
for (const link of links) {
lines.push(`- ${link.type}: ${link.title ?? link.url ?? link.targetCardId ?? ""}`);
}
}
const cardsById = new Map(cards.map((entry) => [entry.id, entry]));
const parentResults = cardParentIds(card)
.map((parentId) => cardsById.get(parentId))
.filter((parent): parent is WorkboardCard => parent !== undefined && parent.status === "done")
.slice(-6);
if (parentResults.length) {
lines.push("", "## Parent results");
for (const parent of parentResults) {
lines.push(
`- ${parent.id} ${parent.title}: ${capText(cardResultSummary(parent), 500) ?? "done"}`,
);
}
}
const recentAgentWork =
card.agentId && cards.length
? cards
.filter(
(entry) =>
entry.id !== card.id &&
cardBoardId(entry) === cardBoardId(card) &&
entry.agentId === card.agentId &&
entry.status === "done",
)
.toSorted((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 5)
: [];
if (recentAgentWork.length) {
lines.push("", `## Recent done work by ${card.agentId}`);
for (const entry of recentAgentWork) {
lines.push(
`- ${entry.id} ${entry.title}: ${capText(cardResultSummary(entry), 300) ?? "done"}`,
);
}
}
const automation = card.metadata?.automation;
if (automation) {
lines.push("", "## Automation");
if (automation.tenant) {
lines.push(`Tenant: ${automation.tenant}`);
}
if (automation.boardId) {
lines.push(`Board: ${automation.boardId}`);
}
if (automation.skills?.length) {
lines.push(`Skills: ${automation.skills.join(", ")}`);
}
if (automation.workspace) {
lines.push(
`Workspace: ${automation.workspace.kind}${automation.workspace.path ? ` ${automation.workspace.path}` : ""}`,
);
}
if (automation.summary) {
lines.push(`Summary: ${capText(automation.summary, 400)}`);
}
}
const diagnostics = computeCardDiagnostics(card, Date.now());
if (diagnostics.length) {
lines.push("", "## Active diagnostics");
for (const entry of diagnostics) {
lines.push(`- ${entry.severity}: ${entry.title}`);
}
}
return lines.join("\n");
}
function cardParentIds(card: WorkboardCard): string[] {
return (card.metadata?.links ?? [])
.filter((link) => link.type === "parent" && link.targetCardId)
.map((link) => link.targetCardId!)
.filter((id, index, ids) => ids.indexOf(id) === index);
}
function cardChildIds(card: WorkboardCard): string[] {
return (card.metadata?.links ?? [])
.filter((link) => link.type === "child" && link.targetCardId)
.map((link) => link.targetCardId!)
.filter((id, index, ids) => ids.indexOf(id) === index);
}
function latestRunningAttempt(card: WorkboardCard): WorkboardRunAttempt | undefined {
return card.metadata?.attempts?.findLast((attempt) => attempt.status === "running");
}
function isDependencyPromotableStatus(status: WorkboardStatus): boolean {
return (
status === "backlog" ||
status === "triage" ||
status === "todo" ||
status === "scheduled" ||
status === "ready"
);
}
function isActiveDependencyTarget(
card: WorkboardCard,
options: { allowStatusOnly?: boolean } = {},
): boolean {
return (
Boolean(card.metadata?.claim) ||
card.execution?.status === "running" ||
Boolean(latestRunningAttempt(card)) ||
(!options.allowStatusOnly && (card.status === "running" || card.status === "review"))
);
}
function closeRunningAttempts(
attempts: WorkboardRunAttempt[] | undefined,
now: number,
status: WorkboardAttemptStatus,
reason?: string,
): WorkboardRunAttempt[] | undefined {
if (!attempts?.some((attempt) => attempt.status === "running")) {
return attempts;
}
return attempts.map((attempt) =>
attempt.status === "running"
? { ...attempt, status, endedAt: now, ...(reason ? { error: reason } : {}) }
: attempt,
);
}
function notificationSequence(event: WorkboardNotification): number | undefined {
return typeof event.sequence === "number" && Number.isFinite(event.sequence)
? Math.trunc(event.sequence)
: undefined;
}
function compareNotifications(a: WorkboardNotification, b: WorkboardNotification): number {
if (a.createdAt !== b.createdAt) {
return a.createdAt - b.createdAt;
}
const aSequence = notificationSequence(a);
const bSequence = notificationSequence(b);
if (aSequence !== undefined && bSequence !== undefined) {
return aSequence - bSequence || a.id.localeCompare(b.id);
}
if (aSequence !== undefined) {
return -1;
}
if (bSequence !== undefined) {
return 1;
}
return a.id.localeCompare(b.id);
}
export class WorkboardStore {
private mutationQueue: Promise<unknown> = Promise.resolve();
private lastNotificationSequence = 0;
private readonly boardStore: WorkboardKeyedStore<PersistedWorkboardBoard>;
private readonly subscriptionStore: WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>;
private readonly attachmentStore: WorkboardKeyedStore<PersistedWorkboardAttachment>;
constructor(
private readonly store: WorkboardKeyedStore,
stores: {
boards?: WorkboardKeyedStore<PersistedWorkboardBoard>;
subscriptions?: WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>;
attachments?: WorkboardKeyedStore<PersistedWorkboardAttachment>;
} = {},
) {
this.boardStore =
stores.boards ?? (store as unknown as WorkboardKeyedStore<PersistedWorkboardBoard>);
this.subscriptionStore =
stores.subscriptions ??
(store as unknown as WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>);
this.attachmentStore =
stores.attachments ?? (store as unknown as WorkboardKeyedStore<PersistedWorkboardAttachment>);
}
private async enqueueMutation<T>(run: () => Promise<T>): Promise<T> {
const result = this.mutationQueue.then(run, run);
this.mutationQueue = result.then(
() => undefined,
() => undefined,
);
return await result;
}
private async updateMetadata(
id: string,
mutate: (existing: WorkboardCard) => WorkboardMetadata,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
return await this.updateCard(id, { metadata: mutate(existing) });
});
}
private async deleteDetachedAttachments(
existing: WorkboardCard,
next: WorkboardCard,
): Promise<void> {
const nextIds = new Set(next.metadata?.attachments?.map((attachment) => attachment.id) ?? []);
for (const attachment of existing.metadata?.attachments ?? []) {
if (!nextIds.has(attachment.id)) {
await this.attachmentStore.delete(attachment.id);
}
}
}
private nextNotificationSequence(now: number): number {
const base = Math.max(0, Math.trunc(now)) * 1000;
this.lastNotificationSequence = Math.max(this.lastNotificationSequence + 1, base);
return this.lastNotificationSequence;
}
async list(options: WorkboardListOptions = {}): Promise<WorkboardCard[]> {
const boardId = normalizeBoardId(options.boardId);
const entries = await this.store.entries();
return entries
.map((entry) => entry.value)
.filter(
(entry): entry is PersistedWorkboardCard => entry?.version === 1 && Boolean(entry.card?.id),
)
.map((entry) => entry.card)
.filter((card) => !boardId || cardBoardId(card) === boardId)
.toSorted(compareCards);
}
async listBoards(): Promise<{ boards: WorkboardBoardSummary[] }> {
const boards = new Map<string, WorkboardBoardSummary>();
for (const entry of await this.boardStore.entries()) {
if (entry.value?.version !== 1 || !entry.value.board?.id) {
continue;
}
const board = entry.value.board;
boards.set(board.id, {
id: board.id,
...(board.name ? { name: board.name } : {}),
...(board.description ? { description: board.description } : {}),
...(board.icon ? { icon: board.icon } : {}),
...(board.color ? { color: board.color } : {}),
...(board.defaultWorkspace ? { defaultWorkspace: board.defaultWorkspace } : {}),
...(board.orchestration ? { orchestration: board.orchestration } : {}),
total: 0,
active: 0,
archived: 0,
byStatus: {},
updatedAt: board.updatedAt,
...(board.archivedAt ? { archivedAt: board.archivedAt } : {}),
});
}
if (!boards.has("default")) {
boards.set("default", {
id: "default",
total: 0,
active: 0,
archived: 0,
byStatus: {},
});
}
for (const card of await this.list()) {
const boardId = cardBoardId(card);
const summary =
boards.get(boardId) ??
({
id: boardId,
total: 0,
active: 0,
archived: 0,
byStatus: {},
} satisfies WorkboardBoardSummary);
summary.total += 1;
if (card.metadata?.archivedAt) {
summary.archived += 1;
} else {
summary.active += 1;
}
summary.byStatus[card.status] = (summary.byStatus[card.status] ?? 0) + 1;
summary.updatedAt = Math.max(summary.updatedAt ?? 0, card.updatedAt);
boards.set(boardId, summary);
}
return {
boards: [...boards.values()].toSorted((a, b) =>
a.id === "default" ? -1 : b.id === "default" ? 1 : a.id.localeCompare(b.id),
),
};
}
async upsertBoard(input: WorkboardBoardInput): Promise<WorkboardBoardMetadata> {
return await this.enqueueMutation(async () => {
const id = normalizeBoardIdRequired(input.id);
const existing = await this.boardStore.lookup(id);
const board = normalizeBoardMetadata({ ...input, id }, existing?.board);
await this.boardStore.register(id, { version: 1, board });
return board;
});
}
async archiveBoard(id: unknown, archived: unknown = true): Promise<WorkboardBoardMetadata> {
return await this.upsertBoard({ id, archived });
}
async deleteBoard(id: unknown): Promise<{ deleted: boolean }> {
return await this.enqueueMutation(async () => {
const boardId = normalizeBoardIdRequired(id);
if (boardId === "default") {
throw new Error("default board cannot be deleted.");
}
if ((await this.list({ boardId })).length > 0) {
throw new Error("board still has cards; archive it or move/delete the cards first.");
}
for (const entry of await this.subscriptionStore.entries()) {
if (entry.value?.version === 1 && entry.value.subscription?.boardId === boardId) {
await this.subscriptionStore.delete(entry.key);
}
}
return { deleted: await this.boardStore.delete(boardId) };
});
}
async stats(input: WorkboardListOptions = {}, now = Date.now()): Promise<WorkboardStatsResult> {
const cards = await this.list(input);
const boardId = normalizeBoardId(input.boardId) ?? "all";
const byStatus: Partial<Record<WorkboardStatus, number>> = {};
const byAgent = Object.create(null) as Record<string, number>;
let oldestReadyAt: number | undefined;
let updatedAt: number | undefined;
let archived = 0;
for (const card of cards) {
byStatus[card.status] = (byStatus[card.status] ?? 0) + 1;
byAgent[card.agentId ?? "(default)"] = (byAgent[card.agentId ?? "(default)"] ?? 0) + 1;
if (card.metadata?.archivedAt) {
archived += 1;
}
if (card.status === "ready") {
oldestReadyAt = Math.min(oldestReadyAt ?? card.updatedAt, card.updatedAt);
}
updatedAt = Math.max(updatedAt ?? 0, card.updatedAt);
}
return {
id: boardId,
total: cards.length,
active: cards.length - archived,
archived,
byStatus,
byAgent,
...(oldestReadyAt ? { oldestReadyAgeMs: Math.max(0, now - oldestReadyAt) } : {}),
...(updatedAt ? { updatedAt } : {}),
};
}
async get(id: string): Promise<WorkboardCard | undefined> {
const entry = await this.store.lookup(id.trim());
return entry?.version === 1 ? entry.card : undefined;
}
private async removeReferencesToCard(cardId: string): Promise<void> {
for (const card of await this.list()) {
const links = card.metadata?.links;
if (!links?.some((link) => link.targetCardId === cardId)) {
continue;
}
await this.updateCard(card.id, {
metadata: {
...card.metadata,
links: links.filter((link) => link.targetCardId !== cardId),
},
});
}
}
async create(
input: WorkboardLinkedCreateInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => await this.createDirect(input, scope));
}
private async createDirect(
input: WorkboardLinkedCreateInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const now = Date.now();
const requestedStatus = normalizeStatus(input.status, "todo");
const cards = await this.list();
const parents = normalizeStringList(input.parents, "parents", 120);
const automation = normalizeAutomation({
tenant: input.tenant,
boardId: input.boardId,
createdByCardId: input.createdByCardId,
idempotencyKey: input.idempotencyKey,
skills: input.skills,
workspace: input.workspace,
maxRuntimeSeconds: input.maxRuntimeSeconds,
maxRetries: input.maxRetries,
scheduledAt: input.scheduledAt,
});
const heldBySchedule =
Boolean(automation?.scheduledAt && automation.scheduledAt > now) &&
requestedStatus !== "blocked";
let status: WorkboardStatus = heldBySchedule ? "scheduled" : requestedStatus;
let heldByDependencies = false;
if (parents.length > 0 && (status === "running" || status === "review")) {
status = "todo";
heldByDependencies = true;
}
if (automation?.idempotencyKey) {
const existing = cards.find(
(card) =>
card.metadata?.automation?.idempotencyKey === automation.idempotencyKey &&
card.metadata?.automation?.tenant === automation.tenant &&
cardBoardId(card) === (automation.boardId ?? "default"),
);
if (existing) {
return existing;
}
}
const cardsById = new Map(cards.map((card) => [card.id, card]));
const parentCards = parents.map((parentId) => {
const parent = cardsById.get(parentId);
if (!parent) {
throw new Error(`card not found: ${parentId}`);
}
return parent;
});
const childAutomation = normalizeAutomation(
{
...automation,
createdByCardId:
automation?.createdByCardId ?? (parents.length === 1 ? parents[0] : undefined),
},
automation,
);
const normalizedPosition = normalizePosition(input.position, Number.NaN);
const position = Number.isFinite(normalizedPosition)
? normalizedPosition
: Math.max(
0,
...cards.filter((card) => card.status === status).map((card) => card.position),
) + POSITION_STEP;
const notes = normalizeNotes(input.notes);
const agentId = normalizeOptionalString(input.agentId);
const sessionKey = normalizeOptionalString(input.sessionKey);
const runId = normalizeOptionalString(input.runId);
const taskId = normalizeOptionalString(input.taskId);
const sourceUrl = normalizeOptionalString(input.sourceUrl);
const normalizedExecution = normalizeExecution(input.execution);
const execution =
normalizedExecution?.status === "running" && (heldBySchedule || heldByDependencies)
? undefined
: normalizedExecution;
const startedAt =
input.startedAt === undefined
? status === "running"
? now
: undefined
: normalizeTimestamp(input.startedAt, 0) || undefined;
const completedAt =
input.completedAt === undefined
? status === "done"
? now
: undefined
: normalizeTimestamp(input.completedAt, 0) || undefined;
const metadata = normalizeMetadata(
input.metadata,
{
templateId: normalizeTemplateId(input.templateId),
...(childAutomation ? { automation: childAutomation } : {}),
},
{ allowDependencyLinks: false },
);
const syncedMetadata = trimMetadataToBudget(
syncExecutionAttemptMetadata(metadata, execution, now),
);
let card: WorkboardCard = {
id: randomUUID(),
title: normalizeTitle(input.title),
status,
priority: normalizePriority(input.priority, "normal"),
labels: normalizeLabels(input.labels),
position,
createdAt: now,
updatedAt: now,
events: [
{
id: randomUUID(),
kind: "created",
at: now,
toStatus: status,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
},
],
...(notes ? { notes } : {}),
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
...(taskId ? { taskId } : {}),
...(sourceUrl ? { sourceUrl } : {}),
...(execution ? { execution } : {}),
...(startedAt ? { startedAt } : {}),
...(completedAt ? { completedAt } : {}),
...(!metadataIsEmpty(syncedMetadata) ? { metadata: syncedMetadata } : {}),
};
await this.store.register(card.id, { version: 1, card });
try {
for (const parent of parentCards) {
card = await this.linkCardsDirect(parent.id, card.id, now, {
allowStatusOnlyActiveChild: true,
scope,
});
}
} catch (error) {
await this.store.delete(card.id);
await this.removeReferencesToCard(card.id);
throw error;
}
return card;
}
async update(id: string, patch: WorkboardCardPatch): Promise<WorkboardCard> {
return await this.enqueueMutation(
async () =>
await this.updateCard(id, patch, {
allowMetadataDependencyLinks: false,
enforceStatusHolds: true,
}),
);
}
private async updateCard(
id: string,
patch: WorkboardCardPatch,
options: { allowMetadataDependencyLinks?: boolean; enforceStatusHolds?: boolean } = {},
): Promise<WorkboardCard> {
const existing = await this.get(id);
if (!existing) {
throw new Error(`card not found: ${id}`);
}
const status = normalizeStatus(patch.status, existing.status);
const now = Date.now();
const startedAt =
patch.startedAt === undefined
? status === "running"
? (existing.startedAt ?? now)
: existing.startedAt
: normalizeTimestamp(patch.startedAt, 0) || undefined;
const completedAt =
patch.completedAt === undefined
? status === "done"
? (existing.completedAt ?? now)
: undefined
: normalizeTimestamp(patch.completedAt, 0) || undefined;
const sessionKey =
patch.sessionKey === undefined
? existing.sessionKey
: normalizeOptionalString(patch.sessionKey);
const execution =
patch.execution === undefined
? patch.sessionKey === undefined
? existing.execution
: syncExecutionSessionKey(existing.execution, sessionKey)
: normalizeExecution(patch.execution);
let metadata = normalizeMetadata(patch.metadata, existing.metadata, {
allowDependencyLinks: options.allowMetadataDependencyLinks !== false,
});
const automationPatch: Record<string, unknown> = {};
for (const key of [
"tenant",
"boardId",
"createdByCardId",
"idempotencyKey",
"skills",
"workspace",
"maxRuntimeSeconds",
"maxRetries",
"scheduledAt",
] as const) {
if (Object.hasOwn(patch, key) && patch[key] !== undefined) {
automationPatch[key] = patch[key];
}
}
if (Object.keys(automationPatch).length > 0) {
metadata = trimMetadataToBudget({
...metadata,
automation: normalizeAutomation(automationPatch, metadata.automation),
});
}
const next = removeUndefinedCardFields({
...existing,
title: patch.title === undefined ? existing.title : normalizeTitle(patch.title),
notes: patch.notes === undefined ? existing.notes : normalizeNotes(patch.notes),
status,
priority:
patch.priority === undefined
? existing.priority
: normalizePriority(patch.priority, existing.priority),
labels: patch.labels === undefined ? existing.labels : normalizeLabels(patch.labels),
agentId:
patch.agentId === undefined ? existing.agentId : normalizeOptionalString(patch.agentId),
sessionKey,
runId: patch.runId === undefined ? existing.runId : normalizeOptionalString(patch.runId),
taskId: patch.taskId === undefined ? existing.taskId : normalizeOptionalString(patch.taskId),
sourceUrl:
patch.sourceUrl === undefined
? existing.sourceUrl
: normalizeOptionalString(patch.sourceUrl),
execution,
metadata:
patch.templateId === undefined
? metadata
: { ...metadata, templateId: normalizeTemplateId(patch.templateId) },
position:
patch.position === undefined
? existing.position
: normalizePosition(patch.position, existing.position),
updatedAt: now,
...(startedAt ? { startedAt } : {}),
...(completedAt ? { completedAt } : {}),
});
next.metadata = trimMetadataToBudget(
syncExecutionAttemptMetadata(next.metadata ?? {}, execution, now),
);
next.events = appendEvent(next, updateEvent(existing, next), now);
if (options.enforceStatusHolds && patch.status !== undefined) {
await this.assertActiveStatusAllowed(existing, next, now);
}
if (status !== "done") {
delete next.completedAt;
}
if (patch.startedAt !== undefined && !startedAt) {
delete next.startedAt;
}
if (patch.completedAt !== undefined && !completedAt) {
delete next.completedAt;
}
if (metadataIsEmpty(next.metadata)) {
delete next.metadata;
}
await this.store.register(next.id, { version: 1, card: next });
await this.deleteDetachedAttachments(existing, next);
return next;
}
private async assertActiveStatusAllowed(
existing: WorkboardCard,
next: WorkboardCard,
now: number,
): Promise<void> {
if (
next.status !== "ready" &&
next.status !== "running" &&
next.status !== "review" &&
next.status !== "done"
) {
return;
}
const parents = cardParentIds(next);
const cards =
parents.length > 0 ? new Map((await this.list()).map((card) => [card.id, card])) : undefined;
if (
parents.length > 0 &&
!parents.every((parentId) => cards?.get(parentId)?.status === "done")
) {
throw new Error("card dependencies are not done.");
}
if (next.status === "done") {
return;
}
const scheduledAt = next.metadata?.automation?.scheduledAt;
if ((scheduledAt && scheduledAt > now) || (existing.status === "scheduled" && !scheduledAt)) {
throw new Error("card is scheduled for later.");
}
}
async move(id: string, status: unknown, position: unknown): Promise<WorkboardCard> {
return await this.update(id, {
status,
position,
});
}
async delete(id: string): Promise<{ deleted: boolean }> {
return await this.enqueueMutation(async () => await this.deleteDirect(id));
}
private async deleteDirect(id: string): Promise<{ deleted: boolean }> {
const cardId = id.trim();
const deleted = await this.store.delete(cardId);
if (!deleted) {
return { deleted: false };
}
for (const entry of await this.subscriptionStore.entries()) {
if (entry.value?.version === 1 && entry.value.subscription?.cardId === cardId) {
await this.subscriptionStore.delete(entry.key);
}
}
for (const entry of await this.attachmentStore.entries()) {
if (entry.value?.version === 1 && entry.value.attachment?.cardId === cardId) {
await this.attachmentStore.delete(entry.key);
}
}
await this.removeReferencesToCard(cardId);
return { deleted: true };
}
async addComment(
id: string,
input: WorkboardCommentInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const now = Date.now();
const body = normalizeBoundedString(input.body, undefined, 2000, "comment body");
if (!body) {
throw new Error("comment body is required.");
}
const comment = { id: randomUUID(), body, createdAt: now };
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
return {
...existing.metadata,
comments: [...(existing.metadata?.comments ?? []), comment].slice(-MAX_CARD_COMMENTS),
};
});
}
async addLink(id: string, input: WorkboardLinkInput): Promise<WorkboardCard> {
const now = Date.now();
const targetCardId = normalizeBoundedString(input.targetCardId, undefined, 120, "link target");
const url = normalizeBoundedString(input.url, undefined, 2000, "link URL");
const title = normalizeBoundedString(input.title, undefined, 180, "link title");
if (!targetCardId && !url) {
throw new Error("link targetCardId or url is required.");
}
const type = normalizeLinkType(input.type, "relates_to");
if (type === "parent" || type === "child") {
throw new Error("parent and child dependency links must use linkDependency.");
}
const link: WorkboardLink = {
id: randomUUID(),
type,
createdAt: now,
...(targetCardId ? { targetCardId } : {}),
...(title ? { title } : {}),
...(url ? { url } : {}),
};
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
links: appendLinkPreservingDependencies(existing.metadata?.links ?? [], link),
}));
}
async linkCards(
parentId: string,
childId: string,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
return await this.enqueueMutation(
async () => await this.linkCardsDirect(parentId, childId, Date.now(), { scope }),
);
}
private async linkCardsDirect(
parentId: string,
childId: string,
now = Date.now(),
options: { allowStatusOnlyActiveChild?: boolean; scope?: WorkboardMutationScope } = {},
): Promise<WorkboardCard> {
if (parentId.trim() === childId.trim()) {
throw new Error("parent and child cards must differ.");
}
const parent = await this.get(parentId);
const child = await this.get(childId);
if (!parent) {
throw new Error(`card not found: ${parentId}`);
}
if (!child) {
throw new Error(`card not found: ${childId}`);
}
assertCanMutateClaimedCard(parent, options.scope);
assertCanMutateClaimedCard(child, options.scope);
if (child.status === "done" || child.status === "blocked") {
const cardsById = new Map((await this.list()).map((card) => [card.id, card]));
const parentIds = [...cardParentIds(child), parent.id].filter(
(id, index, ids) => ids.indexOf(id) === index,
);
if (parentIds.some((id) => cardsById.get(id)?.status !== "done")) {
throw new Error("terminal child cards cannot gain incomplete parent dependencies.");
}
}
if (isActiveDependencyTarget(child, { allowStatusOnly: options.allowStatusOnlyActiveChild })) {
throw new Error("active child cards cannot gain parent dependencies.");
}
if (await this.dependsOn(parent.id, child.id)) {
throw new Error("dependency link would create a cycle.");
}
const parentLinks = parent.metadata?.links ?? [];
const childLinks = child.metadata?.links ?? [];
const nextParentLinks = parentLinks.some(
(link) => link.type === "child" && link.targetCardId === child.id,
)
? parentLinks
: appendLinkPreservingDependencies(parentLinks, {
id: randomUUID(),
type: "child" as const,
targetCardId: child.id,
createdAt: now,
});
const nextChildLinks = childLinks.some(
(link) => link.type === "parent" && link.targetCardId === parent.id,
)
? childLinks
: appendLinkPreservingDependencies(childLinks, {
id: randomUUID(),
type: "parent" as const,
targetCardId: parent.id,
createdAt: now,
});
await this.updateCard(parent.id, {
metadata: { ...parent.metadata, links: nextParentLinks },
});
const nextChild = await this.updateCard(child.id, {
metadata: { ...child.metadata, links: nextChildLinks },
});
return await this.promoteDependencyReady(nextChild.id);
}
async linkParents(childId: string, parentIds: readonly string[]): Promise<WorkboardCard> {
let child = await this.get(childId);
if (!child) {
throw new Error(`card not found: ${childId}`);
}
for (const parentId of parentIds) {
child = await this.linkCards(parentId, child.id);
}
return child;
}
private async dependencyTargetStatus(card: WorkboardCard, now: number): Promise<WorkboardStatus> {
const scheduledAt = card.metadata?.automation?.scheduledAt;
const parents = cardParentIds(card);
if (card.status === "scheduled" && !scheduledAt) {
return "scheduled";
}
if (parents.length === 0) {
if (scheduledAt && scheduledAt > now && isDependencyPromotableStatus(card.status)) {
return "scheduled";
}
return card.status === "scheduled" ? "ready" : card.status;
}
const cards = new Map((await this.list()).map((entry) => [entry.id, entry]));
const parentsDone = parents.every((parentId) => cards.get(parentId)?.status === "done");
if (
!parentsDone &&
scheduledAt &&
scheduledAt > now &&
isDependencyPromotableStatus(card.status)
) {
return "scheduled";
}
if (!parentsDone && isDependencyPromotableStatus(card.status)) {
return "todo";
}
if (
parentsDone &&
scheduledAt &&
scheduledAt > now &&
isDependencyPromotableStatus(card.status)
) {
return "scheduled";
}
return parentsDone && isDependencyPromotableStatus(card.status) ? "ready" : card.status;
}
private async dependsOn(cardId: string, targetParentId: string): Promise<boolean> {
const cards = new Map((await this.list()).map((entry) => [entry.id, entry]));
const seen = new Set<string>();
const visit = (id: string): boolean => {
if (id === targetParentId) {
return true;
}
if (seen.has(id)) {
return false;
}
seen.add(id);
const card = cards.get(id);
return Boolean(card && cardParentIds(card).some(visit));
};
return visit(cardId);
}
private async recordDispatch(card: WorkboardCard, now: number): Promise<WorkboardCard> {
const metadata = trimMetadataToBudget(
normalizeMetadata(
{
...card.metadata,
automation: normalizeAutomation(
{
...card.metadata?.automation,
dispatchCount: (card.metadata?.automation?.dispatchCount ?? 0) + 1,
lastDispatchAt: now,
},
card.metadata?.automation,
),
},
card.metadata,
),
);
const next = removeUndefinedCardFields({
...card,
...(!metadataIsEmpty(metadata) ? { metadata } : { metadata: undefined }),
events: appendEvent(card, { kind: "dispatch" }, now),
});
await this.store.register(card.id, { version: 1, card: next });
return next;
}
private async recordOrchestrationCandidate(
card: WorkboardCard,
now: number,
): Promise<WorkboardCard> {
const metadata = trimMetadataToBudget({
...card.metadata,
workerLogs: [
...(card.metadata?.workerLogs ?? []),
{
id: randomUUID(),
level: "info" as const,
message: "Auto orchestration marked this triage card for specification or decomposition.",
createdAt: now,
},
].slice(-MAX_CARD_WORKER_LOGS),
workerProtocol: {
state: "idle" as const,
updatedAt: now,
detail: "Awaiting workboard_specify or workboard_decompose.",
},
});
const next = removeUndefinedCardFields({
...card,
...(!metadataIsEmpty(metadata) ? { metadata } : { metadata: undefined }),
events: appendEvent(card, { kind: "orchestration" }, now),
});
await this.store.register(card.id, { version: 1, card: next });
return next;
}
private async shouldAutoOrchestrate(card: WorkboardCard): Promise<boolean> {
if (
card.status !== "triage" ||
card.metadata?.archivedAt ||
card.metadata?.workerProtocol?.state === "idle"
) {
return false;
}
const board = await this.boardStore.lookup(cardBoardId(card));
return board?.version === 1 && board.board.orchestration?.autoDecompose === true;
}
private async promoteDependencyReady(id: string, now = Date.now()): Promise<WorkboardCard> {
const card = await this.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
const target = await this.dependencyTargetStatus(card, now);
if (target === card.status) {
return card;
}
return await this.updateCard(card.id, { status: target });
}
async promoteReady(now = Date.now()): Promise<{ cards: WorkboardCard[]; count: number }> {
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
for (const card of await this.list()) {
const next = await this.promoteDependencyReady(card.id, now);
if (next.status !== card.status) {
promoted.push(next);
}
}
return { cards: promoted, count: promoted.length };
});
}
async addProof(
id: string,
input: WorkboardProofInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const now = Date.now();
const proof = normalizeProofInput(input, now);
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
};
});
}
async addProofWithArtifact(
id: string,
proofInput: WorkboardProofInput,
artifactInput: WorkboardArtifactInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const now = Date.now();
const proof = normalizeProofInput(proofInput, now);
const artifact = normalizeArtifact({ ...artifactInput, createdAt: now });
if (!artifact) {
throw new Error("artifact url or path is required.");
}
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
proof: [...(metadata.proof ?? []), proof].slice(-MAX_CARD_PROOF),
artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS),
};
});
}
async addArtifact(
id: string,
input: WorkboardArtifactInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const artifact = normalizeArtifact({ ...input, createdAt: Date.now() });
if (!artifact) {
throw new Error("artifact url or path is required.");
}
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
const metadata = clearDiagnostics(existing.metadata, ["missing_proof"]);
return {
...metadata,
artifacts: [...(metadata.artifacts ?? []), artifact].slice(-MAX_CARD_ARTIFACTS),
};
});
}
async addAttachment(
id: string,
input: WorkboardAttachmentInput,
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 now = Date.now();
const { attachment, contentBase64 } = normalizeAttachmentInput(id, input, now);
await this.attachmentStore.register(attachment.id, {
version: 1,
attachment,
contentBase64,
});
try {
const updated = await this.updateCard(id, {
metadata: {
...clearDiagnostics(existing.metadata, ["missing_proof"]),
attachments: [...(existing.metadata?.attachments ?? []), attachment].slice(
-MAX_CARD_ATTACHMENTS,
),
},
});
if (!updated.metadata?.attachments?.some((entry) => entry.id === attachment.id)) {
await this.attachmentStore.delete(attachment.id);
throw new Error("attachment metadata was trimmed before it could be indexed.");
}
return updated;
} catch (error) {
await this.attachmentStore.delete(attachment.id);
throw error;
}
});
}
async listAttachments(id: string): Promise<{
card: WorkboardCard;
attachments: WorkboardAttachment[];
}> {
const card = await this.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
return { card, attachments: card.metadata?.attachments ?? [] };
}
async getAttachment(id: string): Promise<PersistedWorkboardAttachment | undefined> {
const attachmentId = id.trim();
const entry = await this.attachmentStore.lookup(attachmentId);
return entry?.version === 1 ? entry : undefined;
}
async deleteAttachment(
cardId: string,
attachmentId: string,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const existing = await this.get(cardId);
if (!existing) {
throw new Error(`card not found: ${cardId}`);
}
assertCanMutateClaimedCard(existing, scope);
const attachments = existing.metadata?.attachments ?? [];
if (!attachments.some((attachment) => attachment.id === attachmentId)) {
throw new Error(`attachment not found: ${attachmentId}`);
}
await this.attachmentStore.delete(attachmentId);
return await this.updateCard(cardId, {
metadata: {
...existing.metadata,
attachments: attachments.filter((attachment) => attachment.id !== attachmentId),
},
});
});
}
async addWorkerLog(
id: string,
input: WorkboardWorkerLogInput,
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
const now = Date.now();
const message = normalizeBoundedString(input.message, undefined, 800, "worker log message");
if (!message) {
throw new Error("worker log message is required.");
}
const level =
input.level === "warning" || input.level === "error" || input.level === "info"
? input.level
: "info";
const sessionKey = normalizeBoundedString(input.sessionKey, undefined, 240, "session key");
const runId = normalizeBoundedString(input.runId, undefined, 160, "run id");
const log: WorkboardWorkerLog = {
id: randomUUID(),
level,
message,
createdAt: now,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
return await this.updateMetadata(id, (existing) => {
assertCanMutateClaimedCard(existing, scope);
return {
...existing.metadata,
workerLogs: [...(existing.metadata?.workerLogs ?? []), log].slice(-MAX_CARD_WORKER_LOGS),
};
});
}
async recordProtocolViolation(
id: string,
input: WorkboardProtocolViolationInput = {},
scope?: WorkboardMutationScope,
): Promise<WorkboardCard> {
return await this.enqueueMutation(async () => {
const card = await this.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
assertCanMutateClaimedCard(card, scope);
const now = Date.now();
const detail =
normalizeBoundedString(input.detail, undefined, 800, "protocol violation detail") ??
"Worker stopped without completing or blocking the card.";
const sessionKey = normalizeBoundedString(input.sessionKey, undefined, 240, "session key");
const runId = normalizeBoundedString(input.runId, undefined, 160, "run id");
const log: WorkboardWorkerLog = {
id: randomUUID(),
level: "error",
message: detail,
createdAt: now,
...(sessionKey ? { sessionKey } : {}),
...(runId ? { runId } : {}),
};
const execution =
card.execution?.status === "running"
? { ...card.execution, status: "blocked" as const, updatedAt: now }
: card.execution;
const attempts = closeRunningAttempts(card.metadata?.attempts, now, "blocked", detail);
const notification: WorkboardNotification = {
id: randomUUID(),
kind: "failed",
createdAt: now,
sequence: this.nextNotificationSequence(now),
message: capText(detail, 240) ?? "Worker protocol violation.",
...(sessionKey || cardSessionKey(card)
? { sessionKey: sessionKey ?? cardSessionKey(card) }
: {}),
...(runId || cardRunId(card) ? { runId: runId ?? cardRunId(card) } : {}),
};
return await this.updateCard(card.id, {
status: card.status === "done" ? card.status : "blocked",
...(execution ? { execution } : {}),
metadata: {
...card.metadata,
workerLogs: [...(card.metadata?.workerLogs ?? []), log].slice(-MAX_CARD_WORKER_LOGS),
workerProtocol: {
state: "violated",
updatedAt: now,
detail,
},
claim: undefined,
...(attempts ? { attempts } : {}),
failureCount: (card.metadata?.failureCount ?? 0) + 1,
notifications: [...(card.metadata?.notifications ?? []), notification].slice(
-MAX_CARD_NOTIFICATIONS,
),
},
});
});
}
async claim(
id: string,
input: WorkboardClaimInput,
): 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);
if (cardParentIds(guarded).length > 0 && guarded.status !== "ready") {
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.");
}
const existingClaim = guarded.metadata?.claim;
if (existingClaim && isFutureDateTimestampMs(existingClaim.expiresAt, { nowMs: now })) {
throw new Error(`card already claimed by ${existingClaim.ownerId}.`);
}
const metadata = clearDiagnostics(guarded.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);
const token = normalizeOptionalString(input.token);
const ownerId = normalizeOptionalString(input.ownerId);
if (token && 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.");
}
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) {
const token = normalizeOptionalString(input.token);
const ownerId = normalizeOptionalString(input.ownerId);
if (token && 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.");
}
}
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 promote(
id: string,
input: WorkboardPromoteInput = {},
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 reason = normalizeBoundedString(input.reason, undefined, 1000, "promote reason");
const comments = reason
? [
...(existing.metadata?.comments ?? []),
{ id: randomUUID(), body: reason, createdAt: Date.now() },
].slice(-MAX_CARD_COMMENTS)
: existing.metadata?.comments;
return await this.updateCard(
id,
{
status: "ready",
metadata: {
...clearDiagnostics(existing.metadata, ["stranded_ready", "blocked_too_long"]),
comments,
stale: null,
},
},
{ enforceStatusHolds: input.force !== true },
);
});
}
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;
}
});
}
async subscribeNotifications(
input: WorkboardNotificationSubscribeInput,
): Promise<WorkboardNotificationSubscription> {
return await this.enqueueMutation(async () => {
const subscription = normalizeNotificationSubscription(input);
await this.subscriptionStore.register(subscription.id, { version: 1, subscription });
return subscription;
});
}
async listNotificationSubscriptions(
input: WorkboardNotificationListOptions = {},
): Promise<{ subscriptions: WorkboardNotificationSubscription[] }> {
const boardId = normalizeBoardId(input.boardId);
const cardId = normalizeBoundedString(input.cardId, undefined, 120, "card id");
const subscriptions = (await this.subscriptionStore.entries())
.map((entry) => entry.value)
.filter(
(entry): entry is PersistedWorkboardNotificationSubscription =>
entry?.version === 1 && Boolean(entry.subscription?.id),
)
.map((entry) => entry.subscription)
.filter((subscription) => !boardId || subscription.boardId === boardId)
.filter((subscription) => !cardId || subscription.cardId === cardId)
.toSorted((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
return { subscriptions };
}
async deleteNotificationSubscription(id: string): Promise<{ deleted: boolean }> {
return { deleted: await this.subscriptionStore.delete(id.trim()) };
}
private async collectNotificationEvents(input: WorkboardNotificationEventsInput = {}): Promise<{
subscription?: WorkboardNotificationSubscription;
events: WorkboardNotification[];
}> {
const subscriptionId = normalizeBoundedString(
input.subscriptionId,
undefined,
120,
"subscription id",
);
const boardId = normalizeBoardId(input.boardId);
const cardId = normalizeBoundedString(input.cardId, undefined, 120, "card id");
const limit =
typeof input.limit === "number" && Number.isFinite(input.limit)
? Math.max(1, Math.min(200, Math.trunc(input.limit)))
: 50;
const subscriptionEntry = subscriptionId
? await this.subscriptionStore.lookup(subscriptionId)
: undefined;
if (subscriptionId && !subscriptionEntry?.subscription) {
throw new Error(`notification subscription not found: ${subscriptionId}`);
}
const subscription = subscriptionEntry?.subscription;
const effectiveCardId = subscription?.cardId ?? cardId;
const effectiveBoardId = effectiveCardId ? undefined : (subscription?.boardId ?? boardId);
const effectiveSessionKey = subscription?.sessionKey;
const effectiveRunId = subscription?.runId;
const events: WorkboardNotification[] = [];
for (const card of await this.list({ boardId: effectiveBoardId })) {
if (effectiveCardId && card.id !== effectiveCardId) {
continue;
}
const stale = card.metadata?.stale;
const notifications = [
...(card.metadata?.notifications ?? []),
...(stale
? [
{
id: `stale:${card.id}:${stale.detectedAt}`,
kind: "stale" as const,
createdAt: stale.detectedAt,
sequence: stale.detectedAt * 1000,
message: stale.reason,
...(cardSessionKey(card) ? { sessionKey: cardSessionKey(card) } : {}),
...(cardRunId(card) ? { runId: cardRunId(card) } : {}),
},
]
: []),
];
for (const event of notifications) {
const eventSessionKey = event.sessionKey ?? cardSessionKey(card);
const eventRunId = event.runId ?? cardRunId(card);
if (effectiveSessionKey && eventSessionKey !== effectiveSessionKey) {
continue;
}
if (effectiveRunId && eventRunId !== effectiveRunId) {
continue;
}
if (subscription?.eventKinds?.length && !subscription.eventKinds.includes(event.kind)) {
continue;
}
const eventSequence = notificationSequence(event);
if (subscription?.lastEventSequence && eventSequence !== undefined) {
if (
eventSequence < subscription.lastEventSequence ||
(eventSequence === subscription.lastEventSequence &&
event.id <= (subscription.lastEventId ?? ""))
) {
continue;
}
} else if (
subscription?.lastEventAt &&
(event.createdAt < subscription.lastEventAt ||
(event.createdAt === subscription.lastEventAt &&
event.id <= (subscription.lastEventId ?? "")))
) {
continue;
}
events.push(event);
}
}
const sorted = events.toSorted(compareNotifications).slice(0, limit);
return { ...(subscription ? { subscription } : {}), events: sorted };
}
async notificationEvents(input: WorkboardNotificationEventsInput = {}): Promise<{
subscription?: WorkboardNotificationSubscription;
events: WorkboardNotification[];
}> {
return await this.collectNotificationEvents(input);
}
async advanceNotificationEvents(input: WorkboardNotificationEventsInput = {}): Promise<{
subscription?: WorkboardNotificationSubscription;
events: WorkboardNotification[];
}> {
const subscriptionId = normalizeBoundedString(
input.subscriptionId,
undefined,
120,
"subscription id",
);
if (!subscriptionId) {
throw new Error("subscriptionId is required to advance notification events.");
}
return await this.enqueueMutation(async () => {
const result = await this.collectNotificationEvents({ ...input, subscriptionId });
if (!result.subscription || !result.events.length) {
return result;
}
const last = result.events.at(-1)!;
const lastSequence = notificationSequence(last);
const subscription: WorkboardNotificationSubscription = {
...result.subscription,
lastEventAt: last.createdAt,
lastEventId: last.id,
...(lastSequence !== undefined ? { lastEventSequence: lastSequence } : {}),
updatedAt: Date.now(),
};
delete subscription.deliveredEventIds;
if (lastSequence === undefined) {
delete subscription.lastEventSequence;
}
await this.subscriptionStore.register(subscription.id, {
version: 1,
subscription,
});
return { subscription, events: result.events };
});
}
async dispatch(now = Date.now()): Promise<WorkboardDispatchResult> {
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
const reclaimed: WorkboardCard[] = [];
const blocked: WorkboardCard[] = [];
const orchestrated: WorkboardCard[] = [];
const orchestratedByBoard = new Map<string, number>();
for (const card of await this.list()) {
let latest = await this.promoteDependencyReady(card.id, now);
const wasPromoted = latest.status !== card.status;
const claim = latest.metadata?.claim;
const latestAttempt = latestRunningAttempt(latest);
const maxRuntimeSeconds = latest.metadata?.automation?.maxRuntimeSeconds;
const runtimeStartedAt = latestAttempt?.startedAt ?? claim?.claimedAt ?? latest.startedAt;
const timedOut =
Boolean(maxRuntimeSeconds && runtimeStartedAt) &&
now - runtimeStartedAt! > secondsToDurationMs(maxRuntimeSeconds!);
const claimExpired = Boolean(claim?.expiresAt && now - claim.expiresAt > CLAIM_RECLAIM_MS);
const retriesExhausted = retryBudgetExhausted(latest);
if (latest.status === "running" && (timedOut || claimExpired)) {
const reason = timedOut
? "Run exceeded the card max runtime."
: "Claim expired without a recent heartbeat.";
const execution =
latest.execution?.status === "running"
? { ...latest.execution, status: "blocked" as const, updatedAt: now }
: latest.execution;
latest = await this.updateCard(latest.id, {
status: "blocked",
...(execution ? { execution } : {}),
metadata: {
...latest.metadata,
claim: undefined,
attempts: closeRunningAttempts(latest.metadata?.attempts, now, "blocked", reason),
failureCount: (latest.metadata?.failureCount ?? 0) + 1,
notifications: [
...(latest.metadata?.notifications ?? []),
{
id: randomUUID(),
kind: "failed" as const,
createdAt: now,
sequence: this.nextNotificationSequence(now),
message: reason,
},
].slice(-MAX_CARD_NOTIFICATIONS),
},
});
blocked.push(latest);
} else if (claimExpired) {
latest = await this.updateCard(latest.id, {
metadata: { ...latest.metadata, claim: undefined },
});
reclaimed.push(latest);
}
if (
!latest.metadata?.claim &&
retriesExhausted &&
isDependencyPromotableStatus(latest.status)
) {
latest = await this.updateCard(latest.id, {
status: "blocked",
metadata: {
...latest.metadata,
notifications: [
...(latest.metadata?.notifications ?? []),
{
id: randomUUID(),
kind: "failed" as const,
createdAt: now,
sequence: this.nextNotificationSequence(now),
message: "Card exhausted its retry budget.",
},
].slice(-MAX_CARD_NOTIFICATIONS),
},
});
blocked.push(latest);
}
if (latest.status === "ready") {
latest = await this.recordDispatch(latest, now);
}
if (await this.shouldAutoOrchestrate(latest)) {
const boardId = cardBoardId(latest);
const board = await this.boardStore.lookup(boardId);
const cap = board?.board.orchestration?.autoDecomposePerDispatch ?? 3;
const boardCount = orchestratedByBoard.get(boardId) ?? 0;
if (boardCount < cap) {
latest = await this.recordOrchestrationCandidate(latest, now);
orchestrated.push(latest);
orchestratedByBoard.set(boardId, boardCount + 1);
}
}
if (wasPromoted && latest.status !== "blocked") {
promoted.push(latest);
}
}
return {
promoted,
reclaimed,
blocked,
orchestrated,
count: promoted.length + reclaimed.length + blocked.length + orchestrated.length,
};
});
}
async bulkUpdate(input: WorkboardBulkInput): Promise<{ cards: WorkboardCard[] }> {
const ids = Array.isArray(input.ids)
? input.ids.filter((id): id is string => typeof id === "string" && id.trim() !== "")
: [];
if (ids.length === 0) {
throw new Error("ids are required.");
}
const patch =
input.patch && typeof input.patch === "object" && !Array.isArray(input.patch)
? (input.patch as WorkboardCardPatch)
: {};
const cards: WorkboardCard[] = [];
for (const id of ids) {
const updated =
input.archived === undefined
? await this.update(id, patch)
: await this.archive(id, input.archived);
cards.push(updated);
}
return { cards };
}
async archive(id: string, archived: unknown): Promise<WorkboardCard> {
const shouldArchive = archived !== false;
return await this.updateMetadata(id, (existing) => ({
...existing.metadata,
archivedAt: shouldArchive ? Date.now() : 0,
}));
}
async exportCards(): Promise<{
cards: WorkboardCard[];
attachments: WorkboardAttachment[];
exportedAt: number;
}> {
const cards = await this.list();
const attachments = cards.flatMap((card) => card.metadata?.attachments ?? []);
return { cards, attachments, exportedAt: Date.now() };
}
async diagnostics(now = Date.now()): Promise<WorkboardDiagnosticsResult> {
const cards = await this.list();
const rows = cards.flatMap((card) => {
const diagnostics = computeCardDiagnostics(card, now);
return diagnostics.length ? [{ card, diagnostics }] : [];
});
return {
diagnostics: rows,
count: rows.reduce((total, row) => total + row.diagnostics.length, 0),
};
}
async refreshDiagnostics(now = Date.now()): Promise<WorkboardDiagnosticsResult> {
return await this.enqueueMutation(async () => {
const cards = await this.list();
const rows: WorkboardDiagnosticsResult["diagnostics"] = [];
for (const card of cards) {
const latest = await this.get(card.id);
if (!latest) {
continue;
}
const diagnostics = mergeDiagnostics(
latest.metadata?.diagnostics,
computeCardDiagnostics(latest, now),
);
if (diagnostics.length === 0 && !latest.metadata?.diagnostics?.length) {
continue;
}
const metadata = trimMetadataToBudget({ ...latest.metadata, diagnostics });
const next = removeUndefinedCardFields({
...latest,
metadata: metadataIsEmpty(metadata) ? undefined : metadata,
});
await this.store.register(next.id, { version: 1, card: next });
if (diagnostics.length > 0) {
rows.push({ card: next, diagnostics });
}
}
return {
diagnostics: rows,
count: rows.reduce((total, row) => total + row.diagnostics.length, 0),
};
});
}
async buildWorkerContext(id: string): Promise<string> {
const card = await this.get(id);
if (!card) {
throw new Error(`card not found: ${id}`);
}
return buildWorkerContext(card, await this.list());
}
static open(
openKeyedStore: (options: {
namespace: string;
maxEntries: number;
}) => WorkboardKeyedStore<unknown>,
) {
return new WorkboardStore(
openKeyedStore({
namespace: "workboard.cards",
maxEntries: MAX_CARDS,
}) as WorkboardKeyedStore,
{
boards: openKeyedStore({
namespace: "workboard.boards",
maxEntries: 200,
}) as WorkboardKeyedStore<PersistedWorkboardBoard>,
subscriptions: openKeyedStore({
namespace: "workboard.notify",
maxEntries: 2000,
}) as WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>,
attachments: openKeyedStore({
namespace: "workboard.attachments",
maxEntries: MAX_ATTACHMENT_ENTRIES,
}) as WorkboardKeyedStore<PersistedWorkboardAttachment>,
},
);
}
static openSqlite() {
const stores = createWorkboardSqliteStores();
return new WorkboardStore(stores.cards, {
boards: stores.boards,
subscriptions: stores.subscriptions,
attachments: stores.attachments,
});
}
}