refactor(ui): split Workboard controller modules (#105995)

* refactor(ui): split Workboard controller modules

* chore(ui): track moved Workboard export

* chore(ui): avoid unrelated LOC baseline validation

* ci: refresh TypeScript LOC baseline

* ci: drop stale transcript export baseline
This commit is contained in:
Peter Steinberger
2026-07-12 22:55:07 -07:00
committed by GitHub
parent 20c5b2282e
commit e4a17ee160
18 changed files with 4487 additions and 4123 deletions
+1
View File
@@ -4809,6 +4809,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"ui/src/lib/workboard/index.ts: WorkboardWorkerLog",
"ui/src/lib/workboard/index.ts: WorkboardWorkerProtocol",
"ui/src/lib/workboard/index.ts: WorkboardWorkspace",
"ui/src/lib/workboard/mutations.ts: createWorkboardCard",
"ui/src/lib/workspace/bridge.ts: BRIDGE_ENVELOPE_VERSION",
"ui/src/lib/workspace/bridge.ts: isWellFormedInbound",
"ui/src/lib/workspace/bridge.ts: resetPromptRateStatesForTest",
-1
View File
@@ -1192,7 +1192,6 @@
"ui/src/lib/nodes/index.ts": 900,
"ui/src/lib/sessions/index.ts": 1457,
"ui/src/lib/skills/index.ts": 757,
"ui/src/lib/workboard/index.ts": 4121,
"ui/src/lib/workspace/index.ts": 779,
"ui/src/pages/agents/agents-page.ts": 964,
"ui/src/pages/agents/memory/dreaming.ts": 1280,
+155
View File
@@ -0,0 +1,155 @@
import type { GatewaySessionRow } from "../../api/types.ts";
import type {
WorkboardCard,
WorkboardDependencyState,
WorkboardMetadata,
WorkboardStaleState,
WorkboardUiState,
} from "./types.ts";
const WORKBOARD_STALE_SESSION_MS = 30 * 60 * 1000;
export function replaceCard(state: WorkboardUiState, card: WorkboardCard) {
const next = state.cards.filter((existing) => existing.id !== card.id);
next.push(card);
state.cards = next.toSorted((left, right) => left.position - right.position);
}
function parentDependencyIds(card: WorkboardCard): string[] {
const ids: string[] = [];
for (const link of card.metadata?.links ?? []) {
const id = link.type === "parent" ? link.targetCardId?.trim() : "";
if (id && !ids.includes(id)) {
ids.push(id);
}
}
return ids;
}
export function getWorkboardDependencyState(
card: WorkboardCard,
cards: readonly WorkboardCard[],
): WorkboardDependencyState {
const cardsById = new Map(cards.map((entry) => [entry.id, entry]));
const parents = parentDependencyIds(card).map((id) => {
const parent = cardsById.get(id);
return {
id,
title: parent?.title ?? id,
status: parent?.status,
done: parent?.status === "done",
missing: !parent,
};
});
return {
parents,
blockedParents: parents.filter((parent) => !parent.done),
};
}
export function removeCardAndReferences(
cards: readonly WorkboardCard[],
cardId: string,
): WorkboardCard[] {
const nextCards: WorkboardCard[] = [];
for (const card of cards) {
if (card.id === cardId) {
continue;
}
const links = card.metadata?.links;
if (!links?.some((link) => link.targetCardId === cardId)) {
nextCards.push(card);
continue;
}
const nextLinks = links.filter((link) => link.targetCardId !== cardId);
const metadata: WorkboardMetadata = { ...card.metadata, links: nextLinks };
if (nextLinks.length === 0) {
delete metadata.links;
}
nextCards.push(
Object.keys(metadata).length ? { ...card, metadata } : { ...card, metadata: undefined },
);
}
return nextCards;
}
export function resetDraftState(state: WorkboardUiState) {
const resolveStaleEdit = state.loaded && state.mutationReadiness === "stale_edit_draft";
state.draftOpen = false;
state.editingCardId = null;
state.draftTitle = "";
state.draftNotes = "";
state.draftStatus = "todo";
state.draftPriority = "normal";
state.draftLabels = "";
state.draftAgentId = "";
state.draftSessionKey = "";
state.draftTemplateId = "";
state.draftCommentBody = "";
if (resolveStaleEdit) {
state.mutationReadiness = "ready";
}
}
function normalizeDraftLabels(value: string): string[] {
const labels: string[] = [];
for (const label of value.split(",")) {
const trimmed = label.trim();
if (trimmed && !labels.includes(trimmed)) {
labels.push(trimmed);
}
if (labels.length >= 12) {
break;
}
}
return labels;
}
export function draftPayload(state: WorkboardUiState) {
return {
title: state.draftTitle,
notes: state.draftNotes,
status: state.draftStatus,
priority: state.draftPriority,
labels: normalizeDraftLabels(state.draftLabels),
agentId: state.draftAgentId,
sessionKey: state.draftSessionKey,
...(state.draftTemplateId ? { templateId: state.draftTemplateId } : {}),
};
}
export function isFailedSessionStatus(status: GatewaySessionRow["status"]): boolean {
return status === "failed" || status === "killed" || status === "timeout";
}
export function staleSessionState(session: GatewaySessionRow): WorkboardStaleState | undefined {
if (session.status !== "running") {
return undefined;
}
if (session.hasActiveRun !== false) {
return undefined;
}
if (
typeof session.updatedAt !== "number" ||
Date.now() - session.updatedAt < WORKBOARD_STALE_SESSION_MS
) {
return undefined;
}
return {
detectedAt: Date.now(),
lastSessionUpdatedAt: session.updatedAt,
reason: "Linked session has not reported recent activity.",
};
}
export function workboardCardSessionKey(card: WorkboardCard): string | undefined {
return card.sessionKey ?? card.execution?.sessionKey;
}
export function workboardCardRunId(card: WorkboardCard): string | undefined {
return card.runId ?? card.execution?.runId;
}
export function normalizeString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+173
View File
@@ -0,0 +1,173 @@
import type { GatewaySessionRow } from "../../api/types.ts";
import { getWorkboardLifecycle } from "./lifecycle.ts";
import { taskSessionKeyMatchesCardSession } from "./task-links.ts";
import type {
WorkboardCard,
WorkboardHealthKey,
WorkboardHealthSummary,
WorkboardTaskSummary,
WorkboardViewPresetId,
} from "./types.ts";
const WORKBOARD_RECENT_DONE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
function hasWorkboardProofEvidence(card: WorkboardCard): boolean {
return Boolean(
card.metadata?.proof?.length ||
card.metadata?.artifacts?.length ||
card.metadata?.attachments?.length,
);
}
function taskFailedTerminal(task: WorkboardTaskSummary | undefined): boolean {
return task?.status === "failed" || task?.status === "cancelled" || task?.status === "timed_out";
}
function taskFailureRepresentedByCard(
card: WorkboardCard,
task: WorkboardTaskSummary | undefined,
): boolean {
if (!task || !taskFailedTerminal(task)) {
return false;
}
const taskSessionKeys = [task.sessionKey, task.childSessionKey, task.ownerKey];
return Boolean(
card.metadata?.attempts?.some((attempt) => {
if (
attempt.status !== "failed" &&
attempt.status !== "blocked" &&
attempt.status !== "stopped"
) {
return false;
}
if (task.runId && attempt.runId) {
return attempt.runId === task.runId;
}
return Boolean(
attempt.sessionKey &&
taskSessionKeys.some((sessionKey) =>
taskSessionKeyMatchesCardSession(sessionKey, attempt.sessionKey ?? ""),
),
);
}),
);
}
function countCardFailedAttempts(card: WorkboardCard): number {
if (card.metadata?.failureCount !== undefined) {
return card.metadata.failureCount;
}
return (
card.metadata?.attempts?.filter(
(attempt) =>
attempt.status === "failed" || attempt.status === "blocked" || attempt.status === "stopped",
).length ?? 0
);
}
function cardRecentlyDone(card: WorkboardCard): boolean {
if (card.status !== "done") {
return false;
}
const doneAt = card.completedAt ?? card.updatedAt;
return Date.now() - doneAt <= WORKBOARD_RECENT_DONE_WINDOW_MS;
}
export function summarizeWorkboardHealth(params: {
cards: readonly WorkboardCard[];
tasksByCardId: ReadonlyMap<string, WorkboardTaskSummary>;
sessions: readonly GatewaySessionRow[];
}): WorkboardHealthSummary {
const summary: WorkboardHealthSummary = {
running: 0,
blocked: 0,
stale: 0,
readyUnassigned: 0,
missingProof: 0,
failedAttempts: 0,
};
for (const card of params.cards) {
const task = params.tasksByCardId.get(card.id);
if (workboardCardMatchesHealthKey(card, "running", params.sessions, task)) {
summary.running += 1;
}
if (workboardCardMatchesHealthKey(card, "blocked", params.sessions, task)) {
summary.blocked += 1;
}
if (workboardCardMatchesHealthKey(card, "stale", params.sessions, task)) {
summary.stale += 1;
}
if (workboardCardMatchesHealthKey(card, "readyUnassigned", params.sessions, task)) {
summary.readyUnassigned += 1;
}
if (workboardCardMatchesHealthKey(card, "missingProof", params.sessions, task)) {
summary.missingProof += 1;
}
summary.failedAttempts += countCardFailedAttempts(card);
if (taskFailedTerminal(task) && !taskFailureRepresentedByCard(card, task)) {
summary.failedAttempts += 1;
}
}
return summary;
}
export function workboardCardMatchesHealthKey(
card: WorkboardCard,
key: WorkboardHealthKey,
sessions: readonly GatewaySessionRow[],
task?: WorkboardTaskSummary,
): boolean {
const lifecycle = getWorkboardLifecycle(card, sessions, task);
switch (key) {
case "running":
return card.status === "running" || lifecycle.state === "running";
case "blocked":
return card.status === "blocked";
case "stale":
return Boolean(card.metadata?.stale || lifecycle.state === "stale");
case "readyUnassigned":
return card.status === "ready" && !card.agentId?.trim() && !card.metadata?.claim;
case "missingProof":
return card.status === "done" && !hasWorkboardProofEvidence(card);
case "failedAttempts":
return countCardFailedAttempts(card) > 0 || taskFailedTerminal(task);
}
return false;
}
export function filterWorkboardCardsForPreset(params: {
cards: readonly WorkboardCard[];
preset: WorkboardViewPresetId;
tasksByCardId: ReadonlyMap<string, WorkboardTaskSummary>;
sessions: readonly GatewaySessionRow[];
defaultAgentId?: string | null;
}): WorkboardCard[] {
const defaultAgentId = params.defaultAgentId?.trim();
return params.cards.filter((card) => {
const task = params.tasksByCardId.get(card.id);
const lifecycle = getWorkboardLifecycle(card, params.sessions, task);
switch (params.preset) {
case "all":
return true;
case "default_agent":
return defaultAgentId
? card.agentId === defaultAgentId || !card.agentId?.trim()
: !card.agentId;
case "ready":
return card.status === "ready";
case "running":
return card.status === "running" || lifecycle.state === "running";
case "blocked":
return card.status === "blocked";
case "review":
return card.status === "review";
case "stale":
return Boolean(card.metadata?.stale) || lifecycle.state === "stale";
case "missing_proof":
return card.status === "done" && !hasWorkboardProofEvidence(card);
case "recently_done":
return cardRecentlyDone(card);
}
return false;
});
}
+469
View File
@@ -0,0 +1,469 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { requestSessionCreate } from "../sessions/index.ts";
import {
normalizeString,
replaceCard,
workboardCardRunId,
workboardCardSessionKey,
} from "./card-state.ts";
import { formatError, isRecord } from "./normalization-utils.ts";
import { normalizeCardPayload, normalizeTaskSummary } from "./normalization.ts";
import {
getWorkboardState,
invalidateWorkboardLoads,
workboardMutationsReady,
type WorkboardHost,
} from "./runtime.ts";
import {
isMissingTaskLookupError,
listWorkboardTasks,
taskMatchesCard,
taskUpdatedAtValue,
WORKBOARD_TASK_LOOKUP_RETRY_DELAYS_MS,
} from "./task-links.ts";
import type {
WorkboardCard,
WorkboardExecution,
WorkboardExecutionEngine,
WorkboardExecutionMode,
WorkboardExecutionStatus,
WorkboardTaskSummary,
} from "./types.ts";
const WORKBOARD_ENGINE_MODELS = {
codex: "openai/gpt-5.6-sol",
claude: "anthropic/claude-sonnet-4-6",
} as const;
const WORKBOARD_SESSION_LABEL_MAX_CHARS = 512;
function buildCardPrompt(card: WorkboardCard): string {
const lines = [`Work on this OpenClaw Workboard card: ${card.title}`];
if (card.notes?.trim()) {
lines.push("", card.notes.trim());
}
if (card.labels.length > 0) {
lines.push("", `Labels: ${card.labels.join(", ")}`);
}
const parents = card.metadata?.links
?.filter((link) => link.type === "parent" && link.targetCardId)
.map((link) => link.targetCardId);
if (parents?.length) {
lines.push("", `Parents: ${parents.join(", ")}`);
}
if (card.metadata?.automation?.skills?.length) {
lines.push("", `Suggested skills: ${card.metadata.automation.skills.join(", ")}`);
}
if (card.metadata?.automation?.workspace) {
const workspace = card.metadata.automation.workspace;
lines.push("", `Workspace: ${workspace.kind}${workspace.path ? ` ${workspace.path}` : ""}`);
}
lines.push("", "When done, summarize what changed and what remains.");
return lines.join("\n");
}
function buildCardSessionLabel(card: WorkboardCard): string {
const suffix = card.id.trim().slice(0, 8) || "card";
const title = card.title.trim() || "Workboard card";
const suffixText = ` (${suffix})`;
if (title.length + suffixText.length <= WORKBOARD_SESSION_LABEL_MAX_CHARS) {
return `${title}${suffixText}`;
}
const titleMax = WORKBOARD_SESSION_LABEL_MAX_CHARS - suffixText.length;
return `${truncateUtf16Safe(title, titleMax - 3).trimEnd()}...${suffixText}`;
}
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
const sanitized = (value ?? fallback)
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return (sanitized || fallback).slice(0, 96);
}
function buildCardTaskSessionKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(card.metadata?.automation?.boardId, "default");
const cardId = sanitizeSessionSegment(card.id, "card");
const suffix = `subagent:workboard-${boardId}-${cardId}`;
const sessionKey = card.agentId
? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}`
: suffix;
const existing = workboardCardSessionKey(card)?.trim();
return existing === sessionKey ? existing : sessionKey;
}
function buildCardRunIdempotencyKey(card: WorkboardCard): string {
const boardId = sanitizeSessionSegment(card.metadata?.automation?.boardId, "default");
const cardId = sanitizeSessionSegment(card.id, "card");
return `workboard:${boardId}:${cardId}:${card.updatedAt}`;
}
function isScheduledForLater(card: WorkboardCard, now = Date.now()): boolean {
const scheduledAt = card.metadata?.automation?.scheduledAt;
if (typeof scheduledAt === "number") {
return scheduledAt > now;
}
return card.status === "scheduled";
}
function buildWorkboardExecution(params: {
card: WorkboardCard;
engine: WorkboardExecutionEngine;
mode: WorkboardExecutionMode;
sessionKey?: string | null;
runId?: string;
status: WorkboardExecutionStatus;
}): WorkboardExecution {
const now = Date.now();
return {
id: params.card.execution?.id ?? `${params.card.id}:${params.engine}`,
kind: "agent-session",
engine: params.engine,
mode: params.mode,
status: params.status,
model: WORKBOARD_ENGINE_MODELS[params.engine],
startedAt: now,
updatedAt: now,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.runId ? { runId: params.runId } : {}),
};
}
async function findTaskForStartedRun(params: {
client: GatewayBrowserClient;
card: WorkboardCard;
sessionKey: string;
runId?: string;
}): Promise<WorkboardTaskSummary | null> {
const probeCard = {
...params.card,
taskId: undefined,
sessionKey: params.sessionKey,
...(params.runId ? { runId: params.runId } : {}),
};
for (const delayMs of [0, ...WORKBOARD_TASK_LOOKUP_RETRY_DELAYS_MS]) {
if (delayMs > 0) {
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
let task: WorkboardTaskSummary | null = null;
try {
task =
(await listWorkboardTasks(params.client))
.filter((candidate) => taskMatchesCard(candidate, probeCard))
.toSorted((left, right) => taskUpdatedAtValue(right) - taskUpdatedAtValue(left))[0] ??
null;
} catch {
// Task registration/linkage is best effort after the run already started.
}
if (task) {
return task;
}
}
return null;
}
async function abortWorkboardSessionRun(params: {
client: GatewayBrowserClient;
sessionKey: string;
runId?: string;
}): Promise<boolean> {
let abortResult = await params.client.request("chat.abort", {
sessionKey: params.sessionKey,
...(params.runId ? { runId: params.runId } : {}),
});
let aborted =
isRecord(abortResult) &&
(abortResult.aborted === true ||
(Array.isArray(abortResult.runIds) && abortResult.runIds.length > 0));
if (!aborted && params.runId) {
abortResult = await params.client.request("chat.abort", {
sessionKey: params.sessionKey,
});
aborted =
isRecord(abortResult) &&
(abortResult.aborted === true ||
(Array.isArray(abortResult.runIds) && abortResult.runIds.length > 0));
}
return aborted;
}
function taskIsActive(task: WorkboardTaskSummary | undefined): task is WorkboardTaskSummary {
return task?.status === "queued" || task?.status === "running";
}
async function cancelWorkboardTaskRun(params: {
client: GatewayBrowserClient;
taskId: string;
}): Promise<{ cancelled: boolean; missing: boolean; task: WorkboardTaskSummary | null }> {
const result = await params.client.request("tasks.cancel", {
taskId: params.taskId,
reason: "Stopped from Workboard.",
});
return {
cancelled: isRecord(result) && result.cancelled === true,
missing: isRecord(result) && result.found === false,
task: isRecord(result) ? normalizeTaskSummary(result.task) : null,
};
}
export async function startWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
card: WorkboardCard;
engine?: WorkboardExecutionEngine;
mode?: WorkboardExecutionMode;
requestUpdate?: () => void;
}): Promise<string | null> {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
state.busyCardIds.has(params.card.id)
) {
return null;
}
const engine = params.engine;
const mode = params.mode ?? "autonomous";
state.error = null;
if (mode === "autonomous" && isScheduledForLater(params.card)) {
state.error = "Scheduled cards cannot start before their scheduled time.";
params.requestUpdate?.();
return null;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(params.card.id);
params.requestUpdate?.();
let preflightCard: WorkboardCard | null = null;
let createdSessionKey: string | null = null;
let createdRunId: string | undefined;
try {
const shouldClearManualSchedule =
mode === "manual" && params.card.metadata?.automation?.scheduledAt !== undefined;
const shouldUnscheduleManual = mode === "manual" && params.card.status === "scheduled";
const nextCardStatus =
mode === "autonomous" ? "running" : shouldUnscheduleManual ? "todo" : params.card.status;
const nextExecutionStatus = mode === "autonomous" ? "running" : "idle";
let card = params.card;
if (mode === "autonomous") {
const preflightPayload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: { status: nextCardStatus },
});
preflightCard = normalizeCardPayload(preflightPayload);
if (preflightCard) {
replaceCard(state, preflightCard);
card = preflightCard;
}
}
const created =
mode === "autonomous"
? await params.client.request("agent", {
sessionKey: buildCardTaskSessionKey(card),
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
message: buildCardPrompt(card),
deliver: false,
bootstrapContextMode: "lightweight",
idempotencyKey: buildCardRunIdempotencyKey(card),
})
: await requestSessionCreate(params.client, {
...(card.agentId ? { agentId: card.agentId } : {}),
label: buildCardSessionLabel(card),
...(engine ? { model: WORKBOARD_ENGINE_MODELS[engine] } : {}),
});
const sessionKey =
isRecord(created) && typeof created.sessionKey === "string" && created.sessionKey.trim()
? created.sessionKey.trim()
: isRecord(created) && typeof created.key === "string" && created.key.trim()
? created.key.trim()
: mode === "autonomous"
? buildCardTaskSessionKey(card)
: null;
const runId =
isRecord(created) && typeof created.runId === "string" && created.runId.trim()
? created.runId.trim()
: undefined;
if (mode === "autonomous" && !runId) {
throw new Error("Gateway agent method returned an invalid runId.");
}
createdSessionKey = sessionKey;
createdRunId = runId;
const task =
mode === "autonomous" && sessionKey
? await findTaskForStartedRun({
client: params.client,
card,
sessionKey,
runId,
})
: null;
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: nextCardStatus,
...(shouldClearManualSchedule ? { scheduledAt: null } : {}),
...(sessionKey ? { sessionKey } : {}),
runId: runId ?? null,
taskId: task?.taskId ?? null,
...(engine
? {
execution: buildWorkboardExecution({
card,
engine,
mode,
sessionKey,
runId,
status: nextExecutionStatus,
}),
}
: { execution: null }),
},
});
replaceCard(state, normalizeCardPayload(payload));
if (task) {
state.tasksByCardId.set(params.card.id, task);
} else {
state.tasksByCardId.delete(params.card.id);
}
return sessionKey;
} catch (error) {
if (mode === "autonomous" && createdSessionKey) {
try {
await abortWorkboardSessionRun({
client: params.client,
sessionKey: createdSessionKey,
runId: createdRunId,
});
} catch {
// Preserve the card-start failure; the user-facing repair is the rollback below.
}
}
if (preflightCard) {
try {
const rollbackPayload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: params.card.status,
startedAt: params.card.startedAt ?? null,
completedAt: params.card.completedAt ?? null,
...(params.card.execution !== undefined ? { execution: params.card.execution } : {}),
},
});
replaceCard(state, normalizeCardPayload(rollbackPayload) ?? params.card);
} catch {
replaceCard(state, params.card);
}
}
state.error = formatError(error);
return null;
} finally {
state.busyCardIds.delete(params.card.id);
params.requestUpdate?.();
}
}
export async function stopWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
card: WorkboardCard;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
const sessionKey = workboardCardSessionKey(params.card);
const task = state.tasksByCardId.get(params.card.id);
const cardTaskId = normalizeString(params.card.taskId);
const taskId = cardTaskId && !state.missingTaskIds.has(cardTaskId) ? cardTaskId : task?.taskId;
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
state.busyCardIds.has(params.card.id) ||
(!sessionKey && !taskId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(params.card.id);
state.error = null;
params.requestUpdate?.();
try {
let taskStopped = false;
if (taskId && (!task || taskIsActive(task))) {
try {
const cancelled = await cancelWorkboardTaskRun({
client: params.client,
taskId,
});
if (cancelled.missing) {
state.missingTaskIds.add(taskId);
if (task?.taskId === taskId || task?.id === taskId) {
state.tasksByCardId.delete(params.card.id);
}
taskStopped = !sessionKey;
} else if (cancelled.cancelled) {
taskStopped = true;
state.tasksByCardId.set(
params.card.id,
cancelled.task ?? {
...(task ?? { id: taskId, taskId }),
status: "cancelled",
updatedAt: Date.now(),
},
);
}
} catch (error) {
if (!isMissingTaskLookupError(error, taskId)) {
throw error;
}
state.missingTaskIds.add(taskId);
if (task?.taskId === taskId || task?.id === taskId) {
state.tasksByCardId.delete(params.card.id);
}
taskStopped = !sessionKey;
}
}
let sessionAborted = false;
if (sessionKey) {
try {
sessionAborted = await abortWorkboardSessionRun({
client: params.client,
sessionKey,
runId: workboardCardRunId(params.card),
});
} catch (error) {
if (!taskStopped) {
throw error;
}
}
}
if (!taskStopped && !sessionAborted) {
return;
}
const payload = await params.client.request("workboard.cards.update", {
id: params.card.id,
patch: {
status: "blocked",
...(params.card.execution
? {
execution: {
...params.card.execution,
status: "blocked",
updatedAt: Date.now(),
},
}
: {}),
},
});
replaceCard(state, normalizeCardPayload(payload));
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardIds.delete(params.card.id);
params.requestUpdate?.();
}
}
@@ -27,7 +27,7 @@ import {
syncWorkboardLifecycle,
type WorkboardCard,
type WorkboardTaskSummary,
} from "../../lib/workboard/index.ts";
} from "./index.ts";
function createClient(
responses: Record<string, unknown> | ((method: string, params: unknown) => unknown),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import { replaceCard, staleSessionState } from "./card-state.ts";
import {
executionStatusForLifecycle,
getLifecycleSyncKeys,
getWorkboardLifecycle,
hasPendingStatusTransition,
lifecycleSyncKey,
mergePatchMetadata,
shouldSkipLifecycleStatusWrite,
shouldSkipStaleLifecycleStatus,
shouldSyncCardStatus,
shouldSyncExecutionStatus,
} from "./lifecycle.ts";
import { formatError } from "./normalization-utils.ts";
import { normalizeCardPayload } from "./normalization.ts";
import {
currentWorkboardLifecycleReconciliationEpoch,
getWorkboardRuntime,
getWorkboardState,
isCurrentWorkboardLifecycleReconciliationEpoch,
isCurrentWorkboardLoadGeneration,
nextWorkboardLoadGeneration,
releaseWorkboardLifecycleWrite,
resetWorkboardLifecycleTaskConfirmations,
setWorkboardLifecycleTaskRefreshContinuation,
setWorkboardLifecycleTaskRefreshFailed,
setWorkboardLifecycleTasksPrepared,
shouldRefreshWorkboardTasksForLifecycle,
trackWorkboardLifecycleWrite,
workboardLifecycleRequiresTaskRefresh,
workboardLifecycleSyncBlocked,
workboardLifecycleTaskRefreshContinuationWaiting,
workboardLifecycleTaskRefreshRetryPending,
workboardLifecycleTasksPreparedAt,
workboardTaskLinksReadyForLifecycle,
WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_TIMEOUT_ERROR,
WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_WINDOW_MS,
type WorkboardHost,
} from "./runtime.ts";
import {
applyTaskSummariesToState,
getWorkboardTaskPollBatch,
listWorkboardTasks,
selectWorkboardMissingTaskConfirmationIds,
taskMatchesTrackedCardLink,
} from "./task-links.ts";
import type { WorkboardTaskLinkState, WorkboardUiState } from "./types.ts";
async function refreshWorkboardLifecycleTasks(
params: {
host: WorkboardHost;
client: GatewayBrowserClient;
requestUpdate?: () => void;
},
state: WorkboardUiState,
): Promise<number | null> {
const runtime = getWorkboardRuntime(params.host);
const existingRefresh = runtime.lifecycleTaskRefreshPromise;
if (existingRefresh) {
return await existingRefresh;
}
const refresh = (async () => {
const generation = nextWorkboardLoadGeneration(params.host);
try {
const previousTasksByCardId = state.tasksByCardId;
const confirmationNow = Date.now();
const confirmationExpired =
state.lifecycleTaskConfirmationStartedAt !== null &&
confirmationNow - state.lifecycleTaskConfirmationStartedAt >=
WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_WINDOW_MS;
if (state.lifecycleTaskRefreshContinueAt !== null && confirmationExpired) {
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
setWorkboardLifecycleTaskRefreshFailed(state, true, {
host: params.host,
requestUpdate: params.requestUpdate,
});
state.lifecycleTaskRefreshError = WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_TIMEOUT_ERROR;
params.requestUpdate?.();
return null;
}
if (state.lifecycleTaskConfirmationStartedAt === null || confirmationExpired) {
resetWorkboardLifecycleTaskConfirmations(state);
state.lifecycleTaskConfirmationStartedAt = confirmationNow;
}
const previouslyConfirmedTasks = [...previousTasksByCardId.values()].filter((task) =>
state.lifecycleConfirmedTaskIds.has(task.taskId),
);
const taskLinkState: WorkboardTaskLinkState = {
cards: state.cards,
tasksByCardId: new Map(),
missingTaskIds: new Set(state.missingTaskIds),
};
const taskSummaries = await listWorkboardTasks(params.client);
const confirmationResult = await getWorkboardTaskPollBatch(
params.client,
selectWorkboardMissingTaskConfirmationIds(
params.host,
taskLinkState.cards,
taskSummaries,
taskLinkState.missingTaskIds,
previousTasksByCardId,
state.lifecycleConfirmedTaskIds,
),
[],
);
const previousTasksToPreserve = confirmationResult.error
? taskLinkState.cards.flatMap((card) => {
const task = previousTasksByCardId.get(card.id);
return task &&
!confirmationResult.missingTaskIds.has(task.taskId) &&
taskMatchesTrackedCardLink(task, card, taskLinkState.missingTaskIds)
? [task]
: [];
})
: [];
applyTaskSummariesToState(
taskLinkState,
[
...taskSummaries,
...previouslyConfirmedTasks,
...confirmationResult.tasks,
...previousTasksToPreserve,
],
{ missingTaskIds: confirmationResult.missingTaskIds },
);
if (
!isCurrentWorkboardLoadGeneration(params.host, generation) ||
workboardLifecycleSyncBlocked(params.host, state)
) {
return null;
}
state.cards = taskLinkState.cards;
state.tasksByCardId = taskLinkState.tasksByCardId;
state.missingTaskIds = taskLinkState.missingTaskIds;
for (const task of confirmationResult.tasks) {
state.lifecycleConfirmedTaskIds.add(task.taskId);
}
for (const taskId of confirmationResult.missingTaskIds) {
state.lifecycleConfirmedTaskIds.add(taskId);
}
if (confirmationResult.error) {
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
setWorkboardLifecycleTaskRefreshFailed(state, true, {
host: params.host,
requestUpdate: params.requestUpdate,
});
state.lifecycleTaskRefreshError = confirmationResult.error;
params.requestUpdate?.();
return null;
}
if (!workboardTaskLinksReadyForLifecycle(taskLinkState)) {
setWorkboardLifecycleTaskRefreshContinuation(state, true, {
host: params.host,
requestUpdate: params.requestUpdate,
});
return null;
}
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
const recoveredTaskRefreshError = state.lifecycleTaskRefreshError;
setWorkboardLifecycleTaskRefreshFailed(state, false, { host: params.host });
state.lifecycleTaskRefreshError = null;
if (
recoveredTaskRefreshError !== null &&
state.lastRefreshError === recoveredTaskRefreshError
) {
state.lastRefreshError = null;
}
params.requestUpdate?.();
return Date.now();
} catch (error) {
if (
!isCurrentWorkboardLoadGeneration(params.host, generation) ||
workboardLifecycleSyncBlocked(params.host, state)
) {
return null;
}
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
setWorkboardLifecycleTaskRefreshFailed(state, true, {
host: params.host,
requestUpdate: params.requestUpdate,
});
state.lifecycleTaskRefreshError = formatError(error);
params.requestUpdate?.();
return null;
}
})();
runtime.lifecycleTaskRefreshPromise = refresh;
try {
return await refresh;
} finally {
if (runtime.lifecycleTaskRefreshPromise === refresh) {
delete runtime.lifecycleTaskRefreshPromise;
}
}
}
export async function syncWorkboardLifecycle(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
sessions: readonly GatewaySessionRow[];
canWrite?: boolean;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
const taskRefreshRetryPending = workboardLifecycleTaskRefreshRetryPending(state);
const taskRefreshContinuationWaiting = workboardLifecycleTaskRefreshContinuationWaiting(state);
if (
!params.client ||
!state.loaded ||
((taskRefreshRetryPending || taskRefreshContinuationWaiting) &&
workboardLifecycleRequiresTaskRefresh(state)) ||
workboardLifecycleSyncBlocked(params.host, state)
) {
return;
}
const reconciliationEpoch = currentWorkboardLifecycleReconciliationEpoch(params.host);
let tasksPreparedAt = workboardLifecycleTasksPreparedAt(state);
const tasksPrepared = tasksPreparedAt !== null;
setWorkboardLifecycleTasksPrepared(state, false, { host: params.host });
if (
!tasksPrepared &&
!taskRefreshRetryPending &&
!taskRefreshContinuationWaiting &&
shouldRefreshWorkboardTasksForLifecycle(state)
) {
tasksPreparedAt = await refreshWorkboardLifecycleTasks(
{
host: params.host,
client: params.client,
requestUpdate: params.requestUpdate,
},
state,
);
if (tasksPreparedAt === null && workboardLifecycleRequiresTaskRefresh(state)) {
// A null result without a recorded failure means the shared refresh was
// invalidated. Ask only the current, unblocked reconciliation to retry.
if (
!state.lifecycleTaskRefreshFailed &&
isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch) &&
!workboardLifecycleSyncBlocked(params.host, state)
) {
params.requestUpdate?.();
}
return;
}
}
if (
!isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch) ||
workboardLifecycleSyncBlocked(params.host, state)
) {
return;
}
// Read-only operators still need task-refresh recovery. Gate only the
// lifecycle card writeback after the shared task snapshot is current.
if (params.canWrite === false) {
setWorkboardLifecycleTasksPrepared(state, true, {
host: params.host,
preparedAt: tasksPreparedAt ?? Date.now(),
requestUpdate: params.requestUpdate,
});
return;
}
const syncKeys = getLifecycleSyncKeys(params.host);
let lifecycleWriteStarted = false;
for (const card of state.cards) {
if (
!isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch) ||
workboardLifecycleSyncBlocked(params.host, state)
) {
return;
}
const lifecycle = getWorkboardLifecycle(
card,
params.sessions,
state.tasksByCardId.get(card.id),
);
const executionStatus = executionStatusForLifecycle(lifecycle);
const patch: Record<string, unknown> = {};
if (
lifecycle.sourceUpdatedAt !== undefined &&
!shouldSkipLifecycleStatusWrite(params.host, card, lifecycle) &&
shouldSyncCardStatus(card, lifecycle.targetStatus)
) {
patch.status = lifecycle.targetStatus;
mergePatchMetadata(patch, {
lifecycleStatusSourceUpdatedAt: lifecycle.sourceUpdatedAt,
});
}
if (shouldSyncExecutionStatus(card, executionStatus)) {
patch.execution = {
...card.execution,
status: executionStatus,
updatedAt: Date.now(),
};
}
const stale = lifecycle.session ? staleSessionState(lifecycle.session) : undefined;
const existingStale = card.metadata?.stale;
if (stale) {
const staleChanged =
!existingStale ||
existingStale.lastSessionUpdatedAt !== stale.lastSessionUpdatedAt ||
existingStale.reason !== stale.reason;
if (staleChanged) {
mergePatchMetadata(patch, {
stale: {
...stale,
detectedAt: existingStale?.detectedAt ?? stale.detectedAt,
},
});
}
} else if (existingStale) {
mergePatchMetadata(patch, {
stale: null,
});
}
if (Object.keys(patch).length === 0) {
continue;
}
const key = lifecycleSyncKey(card, lifecycle);
if (syncKeys.get(card.id) === key || state.syncingCardIds.has(card.id)) {
continue;
}
const generation = nextWorkboardLoadGeneration(params.host);
lifecycleWriteStarted = true;
state.syncingCardIds.add(card.id);
params.requestUpdate?.();
let write: Promise<unknown> | null = null;
try {
write = params.client.request("workboard.cards.update", {
id: card.id,
patch,
});
trackWorkboardLifecycleWrite(params.host, write);
const payload = await write;
const currentCard = state.cards.find((candidate) => candidate.id === card.id);
const responseCard = normalizeCardPayload(payload);
// Lifecycle responses are full-card replacements. Any newer load or write
// invalidates this generation so its response cannot replace fresher state.
if (
!currentCard ||
!isCurrentWorkboardLoadGeneration(params.host, generation) ||
!isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch) ||
hasPendingStatusTransition(params.host, currentCard.id) ||
(currentCard.status !== card.status && responseCard.status !== currentCard.status) ||
(shouldSkipStaleLifecycleStatus(currentCard, lifecycle) &&
responseCard.status !== currentCard.status)
) {
continue;
}
replaceCard(state, responseCard);
syncKeys.set(card.id, key);
} catch (error) {
if (isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch)) {
state.error = formatError(error);
syncKeys.set(card.id, key);
}
} finally {
if (write) {
releaseWorkboardLifecycleWrite(params.host, write);
}
state.syncingCardIds.delete(card.id);
if (
isCurrentWorkboardLoadGeneration(params.host, generation) &&
isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch)
) {
setWorkboardLifecycleTasksPrepared(state, true, {
host: params.host,
preparedAt: tasksPreparedAt ?? Date.now(),
requestUpdate: params.requestUpdate,
});
}
params.requestUpdate?.();
}
}
if (
!lifecycleWriteStarted &&
isCurrentWorkboardLifecycleReconciliationEpoch(params.host, reconciliationEpoch)
) {
setWorkboardLifecycleTasksPrepared(state, true, {
host: params.host,
preparedAt: tasksPreparedAt ?? Date.now(),
requestUpdate: params.requestUpdate,
});
}
}
+254
View File
@@ -0,0 +1,254 @@
import type { GatewaySessionRow } from "../../api/types.ts";
import { isFailedSessionStatus, staleSessionState, workboardCardSessionKey } from "./card-state.ts";
import { isRecord } from "./normalization-utils.ts";
import { getWorkboardRuntime, type WorkboardHost } from "./runtime.ts";
import { sessionUpdatedAtValue, taskLifecycleSourceUpdatedAt } from "./task-links.ts";
import type {
WorkboardCard,
WorkboardExecutionStatus,
WorkboardLifecycle,
WorkboardStatus,
WorkboardTaskSummary,
} from "./types.ts";
export function findWorkboardSession(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
): GatewaySessionRow | null {
const sessionKey = workboardCardSessionKey(card);
if (!sessionKey) {
return null;
}
return sessions.find((session) => session.key === sessionKey) ?? null;
}
export function getWorkboardLifecycle(
card: WorkboardCard,
sessions: readonly GatewaySessionRow[],
task?: WorkboardTaskSummary,
): WorkboardLifecycle {
const session = findWorkboardSession(card, sessions);
if (task) {
switch (task.status) {
case "queued":
case "running":
if (
session &&
(session.abortedLastRun ||
session.status === "done" ||
isFailedSessionStatus(session.status))
) {
break;
}
return {
session,
state: "running",
targetStatus: "running",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
case "completed":
return {
session,
state: "succeeded",
targetStatus: "review",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
case "failed":
case "cancelled":
case "timed_out":
return {
session,
state: "failed",
targetStatus: "blocked",
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
};
}
}
if (!workboardCardSessionKey(card)) {
return { session: null, state: "unlinked" };
}
if (!session) {
return { session: null, state: "missing" };
}
if (staleSessionState(session)) {
return {
session,
state: "stale",
targetStatus: "running",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.hasActiveRun === true || session.status === "running") {
return {
session,
state: "running",
targetStatus: "running",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.abortedLastRun || isFailedSessionStatus(session.status)) {
return {
session,
state: "failed",
targetStatus: "blocked",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
if (session.status === "done") {
return {
session,
state: "succeeded",
targetStatus: "review",
sourceUpdatedAt: sessionUpdatedAtValue(session),
};
}
return { session, state: "idle" };
}
export function shouldSyncCardStatus(
card: WorkboardCard,
targetStatus: WorkboardStatus | undefined,
) {
if (!targetStatus || card.status === targetStatus) {
return false;
}
if (targetStatus === "running") {
return card.status === "backlog" || card.status === "todo" || card.status === "ready";
}
if (targetStatus === "blocked" || targetStatus === "review") {
return card.status === "running" || card.status === "todo" || card.status === "ready";
}
return false;
}
function pendingStatusTransitionMap(host: WorkboardHost) {
return getWorkboardRuntime(host).pendingStatusTransitions;
}
export function recordPendingStatusTransition(
host: WorkboardHost,
card: WorkboardCard | undefined,
status: WorkboardStatus,
): boolean {
if (!card || card.status === status) {
return false;
}
pendingStatusTransitionMap(host).add(card.id);
return true;
}
export function clearPendingStatusTransition(
host: WorkboardHost,
cardId: string,
recorded: boolean,
) {
if (!recorded) {
return;
}
getWorkboardRuntime(host).pendingStatusTransitions.delete(cardId);
}
export function hasPendingStatusTransition(host: WorkboardHost, cardId: string): boolean {
return getWorkboardRuntime(host).pendingStatusTransitions.has(cardId);
}
export function shouldSkipStaleLifecycleStatus(
card: WorkboardCard,
lifecycle: WorkboardLifecycle,
): boolean {
if (lifecycle.sourceUpdatedAt === undefined) {
return false;
}
const lifecycleStatusSourceUpdatedAt = card.metadata?.lifecycleStatusSourceUpdatedAt;
if (lifecycleStatusSourceUpdatedAt !== undefined) {
return lifecycle.sourceUpdatedAt < lifecycleStatusSourceUpdatedAt;
}
const statusTransitionAt = latestStatusTransitionAt(card);
return statusTransitionAt !== undefined && lifecycle.sourceUpdatedAt < statusTransitionAt;
}
export function shouldSkipLifecycleStatusWrite(
host: WorkboardHost,
card: WorkboardCard,
lifecycle: WorkboardLifecycle,
): boolean {
return (
hasPendingStatusTransition(host, card.id) || shouldSkipStaleLifecycleStatus(card, lifecycle)
);
}
function latestStatusTransitionAt(card: WorkboardCard): number | undefined {
for (let index = (card.events?.length ?? 0) - 1; index >= 0; index -= 1) {
const event = card.events?.[index];
if (
(event?.kind === "moved" || event?.kind === "created") &&
((event.kind === "created" && card.status !== "todo") ||
(event.kind === "moved" && event.fromStatus !== event.toStatus)) &&
event.toStatus === card.status &&
typeof event.at === "number" &&
Number.isFinite(event.at)
) {
return event.at;
}
}
return undefined;
}
export function executionStatusForLifecycle(
lifecycle: WorkboardLifecycle,
): WorkboardExecutionStatus | undefined {
switch (lifecycle.state) {
case "running":
case "stale":
return "running";
case "succeeded":
return "review";
case "failed":
return "blocked";
case "missing":
return undefined;
case "idle":
return "idle";
case "unlinked":
return undefined;
}
return undefined;
}
export function shouldSyncExecutionStatus(
card: WorkboardCard,
targetStatus: WorkboardExecutionStatus | undefined,
) {
return Boolean(card.execution && targetStatus && card.execution.status !== targetStatus);
}
export function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): string {
const session = lifecycle.session;
return [
card.id,
card.status,
card.updatedAt,
lifecycle.targetStatus ?? "",
lifecycle.state,
session?.status ?? "",
session?.hasActiveRun === true ? "active" : "idle",
session?.updatedAt ?? "",
lifecycle.sourceUpdatedAt ?? "",
card.execution?.status ?? "",
card.execution?.updatedAt ?? "",
].join(":");
}
export function getLifecycleSyncKeys(host: WorkboardHost): Map<string, string> {
return getWorkboardRuntime(host).lifecycleSyncKeys;
}
export function mergePatchMetadata(
patch: Record<string, unknown>,
metadata: Record<string, unknown>,
) {
patch.metadata = {
...(isRecord(patch.metadata) ? patch.metadata : {}),
...metadata,
};
}
+488
View File
@@ -0,0 +1,488 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { formatError } from "./normalization-utils.ts";
import { normalizeCardsPayload } from "./normalization.ts";
import {
currentWorkboardPollingGeneration,
clearWorkboardLifecycleTaskPreparedTimer,
clearWorkboardLifecycleTaskRetryTimer,
getWorkboardRuntime,
getWorkboardState,
isCurrentWorkboardLoadGeneration,
isCurrentWorkboardPollingGeneration,
nextWorkboardLoadGeneration,
nextWorkboardPollingGeneration,
resetWorkboardLifecycleTaskConfirmations,
setWorkboardLifecycleTaskRefreshFailed,
setWorkboardLifecycleTasksPrepared,
shouldRefreshWorkboardTasksForLifecycle,
workboardHasActiveWrites,
workboardTaskLinksReadyForLifecycle,
type WorkboardHost,
type WorkboardLoadToken,
} from "./runtime.ts";
import {
applyTaskSummariesToState,
getWorkboardTaskPollBatch,
listWorkboardTasks,
selectWorkboardMissingTaskConfirmationIds,
selectWorkboardTaskDiscoveryQueries,
selectWorkboardTaskPollIds,
taskMatchesTrackedCardLink,
} from "./task-links.ts";
import type {
WorkboardRefreshSource,
WorkboardTaskLinkState,
WorkboardTaskSummary,
WorkboardUiState,
} from "./types.ts";
type LoadWorkboardParams = {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
force?: boolean;
refreshDiagnostics?: boolean;
taskRefresh?: "all" | "linked";
preserveError?: boolean;
};
export async function loadWorkboard(params: LoadWorkboardParams): Promise<boolean> {
return await loadWorkboardInternal(params);
}
async function loadWorkboardInternal(
params: LoadWorkboardParams,
queuedAfterGeneration?: number,
): Promise<boolean> {
const runtime = getWorkboardRuntime(params.host);
const state = getWorkboardState(params.host);
if (
!params.client ||
state.dispatching ||
workboardHasActiveWrites(state) ||
(!params.force && (state.loaded || state.loadAttempted))
) {
return false;
}
const client = params.client;
const existingLoad = runtime.loadPromise;
if (existingLoad) {
const existingGeneration = runtime.loadGeneration;
const result = await existingLoad;
const existingLoadIsCurrent =
existingGeneration !== undefined &&
isCurrentWorkboardLoadGeneration(params.host, existingGeneration);
const currentLoadMarker = runtime.loadToken;
// Only follow a replacement created by this load's forced-waiter queue.
// Fresh loads after teardown or writes must not revive stale callers.
const queuedLoadReplacedExisting =
existingGeneration !== undefined &&
currentLoadMarker?.queuedAfterGeneration === existingGeneration &&
Boolean(runtime.loadPromise);
// Forced callers carry their own diagnostics/task-refresh contract, so a
// weaker in-flight load cannot satisfy them.
return params.force &&
(existingLoadIsCurrent || queuedLoadReplacedExisting) &&
!state.dispatching &&
!workboardHasActiveWrites(state)
? await loadWorkboardInternal(params, existingGeneration)
: result;
}
const generation = nextWorkboardLoadGeneration(params.host);
const loadToken: WorkboardLoadToken = { queuedAfterGeneration };
runtime.loadToken = loadToken;
const lastRefreshErrorBeforeLoad = state.lastRefreshError;
state.loadAttempted = true;
state.loading = true;
if (!params.preserveError) {
delete runtime.loadError;
state.error = null;
}
if (params.taskRefresh !== "linked" || !state.lifecycleTaskRefreshFailed) {
state.lastRefreshError = null;
}
params.requestUpdate?.();
const loadPromise = (async () => {
try {
if (params.refreshDiagnostics) {
try {
await client.request("workboard.cards.diagnostics.refresh", {});
} catch (error) {
if (isCurrentWorkboardLoadGeneration(params.host, generation)) {
state.lastRefreshError = formatError(error);
}
}
}
const payload = await client.request("workboard.cards.list", {});
const normalized = normalizeCardsPayload(payload);
if (!isCurrentWorkboardLoadGeneration(params.host, generation)) {
return false;
}
const previousTasksByCardId = state.tasksByCardId;
const taskLinkState: WorkboardTaskLinkState = {
cards: normalized.cards,
tasksByCardId: new Map(),
missingTaskIds: new Set(state.missingTaskIds),
};
let lifecycleTaskRefreshFailed = state.lifecycleTaskRefreshFailed;
let preserveLifecycleTaskRefreshFailure = false;
let nextTaskRefreshError: string | null = null;
let nextUnfilteredCursor: string | null | undefined;
if (taskLinkState.cards.length > 0) {
const preparedTaskSummaries = taskLinkState.cards.flatMap((card) => {
const task = previousTasksByCardId.get(card.id);
return task && taskMatchesTrackedCardLink(task, card, taskLinkState.missingTaskIds)
? [task]
: [];
});
try {
const pollResult =
params.taskRefresh === "linked"
? await getWorkboardTaskPollBatch(
client,
selectWorkboardTaskPollIds(
params.host,
taskLinkState.cards,
previousTasksByCardId,
taskLinkState.missingTaskIds,
),
selectWorkboardTaskDiscoveryQueries(
params.host,
taskLinkState.cards,
previousTasksByCardId,
taskLinkState.missingTaskIds,
),
)
: null;
let taskSummaries: WorkboardTaskSummary[];
let missingTaskIds: ReadonlySet<string>;
let taskRefreshError: string | null;
if (pollResult) {
taskSummaries = [
...pollResult.tasks,
...preparedTaskSummaries.filter(
(task) => !pollResult.missingTaskIds.has(task.taskId),
),
];
missingTaskIds = pollResult.missingTaskIds;
taskRefreshError = pollResult.error;
} else {
const listedTaskSummaries = await listWorkboardTasks(client);
const confirmationResult = await getWorkboardTaskPollBatch(
client,
selectWorkboardMissingTaskConfirmationIds(
params.host,
taskLinkState.cards,
listedTaskSummaries,
taskLinkState.missingTaskIds,
previousTasksByCardId,
),
[],
);
const previousTasksToPreserve = confirmationResult.error
? preparedTaskSummaries.filter(
(task) => !confirmationResult.missingTaskIds.has(task.taskId),
)
: [];
taskSummaries = [
...listedTaskSummaries,
...confirmationResult.tasks,
...previousTasksToPreserve,
];
missingTaskIds = confirmationResult.missingTaskIds;
taskRefreshError = confirmationResult.error;
}
nextUnfilteredCursor = pollResult?.nextUnfilteredCursor;
applyTaskSummariesToState(taskLinkState, taskSummaries, { missingTaskIds });
preserveLifecycleTaskRefreshFailure =
params.taskRefresh === "linked" &&
state.lifecycleTaskRefreshFailed &&
!taskRefreshError &&
shouldRefreshWorkboardTasksForLifecycle(taskLinkState);
lifecycleTaskRefreshFailed =
Boolean(taskRefreshError) || preserveLifecycleTaskRefreshFailure;
if (taskRefreshError) {
nextTaskRefreshError = taskRefreshError;
}
} catch (error) {
applyTaskSummariesToState(taskLinkState, preparedTaskSummaries);
// Render-driven lifecycle sync runs after every update. Defer a
// failed task refresh until a later authoritative refresh.
lifecycleTaskRefreshFailed = true;
nextTaskRefreshError = formatError(error);
}
} else {
lifecycleTaskRefreshFailed = false;
}
if (!isCurrentWorkboardLoadGeneration(params.host, generation)) {
return false;
}
if (params.taskRefresh === "linked" && shouldDeferWorkboardPoll(state)) {
return false;
}
if (nextUnfilteredCursor !== undefined) {
if (nextUnfilteredCursor) {
runtime.defaultTaskDiscoveryCursor = nextUnfilteredCursor;
} else {
delete runtime.defaultTaskDiscoveryCursor;
}
}
state.cards = taskLinkState.cards;
state.statuses = normalized.statuses;
state.tasksByCardId = taskLinkState.tasksByCardId;
state.missingTaskIds = taskLinkState.missingTaskIds;
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
const recoveredFromLifecycleTaskRefresh =
state.lifecycleTaskRefreshFailed && !lifecycleTaskRefreshFailed;
if (!preserveLifecycleTaskRefreshFailure) {
setWorkboardLifecycleTaskRefreshFailed(state, lifecycleTaskRefreshFailed, {
host: params.host,
requestUpdate: params.requestUpdate,
});
}
if (!lifecycleTaskRefreshFailed) {
state.lifecycleTaskRefreshError = null;
if (
recoveredFromLifecycleTaskRefresh &&
state.lastRefreshError === lastRefreshErrorBeforeLoad
) {
state.lastRefreshError = null;
}
}
if (nextTaskRefreshError) {
state.lifecycleTaskRefreshError = nextTaskRefreshError;
state.lastRefreshError = nextTaskRefreshError;
}
setWorkboardLifecycleTasksPrepared(
state,
!lifecycleTaskRefreshFailed &&
workboardTaskLinksReadyForLifecycle(taskLinkState, {
requireRunningTaskDiscovery: params.taskRefresh === "linked",
}),
{ host: params.host, requestUpdate: params.requestUpdate },
);
const recoveredLoadError = runtime.loadError;
if (recoveredLoadError !== undefined && state.error === recoveredLoadError) {
state.error = null;
}
delete runtime.loadError;
// Preserve stale edit text for recovery, but never re-enable its full-card
// save payload after canonical state may have changed.
state.mutationReadiness = state.editingCardId ? "stale_edit_draft" : "ready";
state.loaded = true;
return true;
} catch (error) {
if (isCurrentWorkboardLoadGeneration(params.host, generation)) {
const formattedError = formatError(error);
if (params.preserveError) {
state.lastRefreshError = formattedError;
} else {
runtime.loadError = formattedError;
state.error = formattedError;
}
}
return false;
} finally {
const isCurrentGeneration = isCurrentWorkboardLoadGeneration(params.host, generation);
const ownsLoad = runtime.loadToken === loadToken;
if (!isCurrentGeneration && !state.loaded) {
state.loadAttempted = false;
}
if (isCurrentGeneration || (ownsLoad && !state.draftSaving)) {
state.loading = false;
}
if (ownsLoad) {
delete runtime.loadPromise;
delete runtime.loadToken;
}
params.requestUpdate?.();
}
})();
runtime.loadPromise = loadPromise;
return await loadPromise;
}
export async function refreshWorkboard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
source: WorkboardRefreshSource;
refreshDiagnostics?: boolean;
pollGeneration?: number;
}) {
const state = getWorkboardState(params.host);
const pollGeneration =
params.source === "poll"
? (params.pollGeneration ?? currentWorkboardPollingGeneration(params.host))
: null;
if (
pollGeneration !== null &&
!isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
return;
}
if (state.dispatching || workboardHasActiveWrites(state)) {
return;
}
const startedAt = Date.now();
state.lastRefreshStartedAt = startedAt;
state.lastRefreshSource = params.source;
if (params.source !== "poll" || !state.lifecycleTaskRefreshFailed) {
state.lastRefreshError = null;
}
if (params.source === "poll") {
state.pollRefreshInProgress = true;
}
params.requestUpdate?.();
if (!params.client) {
state.lastRefreshError = "Gateway client unavailable";
if (
pollGeneration !== null &&
isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
state.pollRefreshInProgress = false;
}
params.requestUpdate?.();
return;
}
try {
const refreshed = await loadWorkboard({
host: params.host,
client: params.client,
requestUpdate: params.requestUpdate,
force: true,
refreshDiagnostics: params.refreshDiagnostics,
taskRefresh: params.source === "poll" ? "linked" : "all",
preserveError: params.source === "poll",
});
state.lastRefreshSource = params.source;
if (params.source !== "poll" && state.error) {
state.lastRefreshError = state.error;
} else if (refreshed) {
state.lastRefreshAt = Date.now();
}
} finally {
if (
pollGeneration !== null &&
isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
state.pollRefreshInProgress = false;
}
params.requestUpdate?.();
}
}
function workboardDocumentHidden(): boolean {
return typeof document !== "undefined" && document.visibilityState === "hidden";
}
function shouldDeferWorkboardPoll(state: WorkboardUiState): boolean {
return Boolean(
state.draftOpen ||
state.editingCardId ||
workboardHasActiveWrites(state) ||
state.draggedCardId ||
state.dispatching ||
state.detailCommentBody.trim() ||
state.draftCommentBody.trim(),
);
}
function clearWorkboardPolling(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.pollingTimer;
if (timer) {
clearTimeout(timer);
delete runtime.pollingTimer;
}
}
function scheduleWorkboardPoll(host: WorkboardHost) {
clearWorkboardPolling(host);
const runtime = getWorkboardRuntime(host);
const entry = runtime.pollingEntry;
if (!entry?.enabled || !entry.client || entry.intervalMs <= 0) {
return;
}
const pollingGeneration = currentWorkboardPollingGeneration(host);
const timer = setTimeout(() => {
delete runtime.pollingTimer;
if (!isCurrentWorkboardPollingGeneration(host, pollingGeneration)) {
return;
}
const current = runtime.pollingEntry;
const state = getWorkboardState(host);
if (!current?.enabled || !current.client || current.intervalMs <= 0) {
return;
}
const run = async () => {
if (!workboardDocumentHidden() && !shouldDeferWorkboardPoll(state)) {
await refreshWorkboard({
host,
client: current.client,
requestUpdate: current.requestUpdate,
source: "poll",
pollGeneration: pollingGeneration,
});
}
};
void run().finally(() => {
if (isCurrentWorkboardPollingGeneration(host, pollingGeneration)) {
scheduleWorkboardPoll(host);
}
});
}, entry.intervalMs);
runtime.pollingTimer = timer;
}
export function configureWorkboardPolling(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
enabled: boolean;
requestUpdate?: () => void;
}) {
const runtime = getWorkboardRuntime(params.host);
const state = getWorkboardState(params.host);
const intervalMs = state.autoRefreshIntervalMs;
const previous = runtime.pollingEntry;
const enabled = params.enabled && intervalMs > 0;
runtime.pollingEntry = {
client: params.client,
enabled,
intervalMs,
requestUpdate: params.requestUpdate,
};
if (!enabled) {
clearWorkboardPolling(params.host);
clearWorkboardLifecycleTaskPreparedTimer(params.host);
clearWorkboardLifecycleTaskRetryTimer(params.host);
return;
}
const configChanged =
!previous ||
previous.enabled !== enabled ||
previous.intervalMs !== intervalMs ||
previous.client !== params.client;
if (!state.pollRefreshInProgress && (configChanged || !runtime.pollingTimer)) {
scheduleWorkboardPoll(params.host);
}
}
export function stopWorkboardPolling(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
nextWorkboardPollingGeneration(host);
clearWorkboardPolling(host);
delete runtime.pollingEntry;
const state = runtime.state;
if (!state?.pollRefreshInProgress) {
return;
}
state.pollRefreshInProgress = false;
state.loading = false;
if (!state.loaded) {
state.loadAttempted = false;
}
nextWorkboardLoadGeneration(host);
delete runtime.loadPromise;
delete runtime.loadToken;
}
@@ -0,0 +1,453 @@
import { isRecord } from "./normalization-utils.ts";
import {
WORKBOARD_ATTEMPT_STATUSES,
WORKBOARD_DIAGNOSTIC_SEVERITIES,
WORKBOARD_EVENT_KINDS,
WORKBOARD_EXECUTION_ENGINES,
WORKBOARD_EXECUTION_MODES,
WORKBOARD_EXECUTION_STATUSES,
WORKBOARD_LINK_TYPES,
WORKBOARD_PROOF_STATUSES,
WORKBOARD_STATUSES,
WORKBOARD_TEMPLATE_IDS,
type WorkboardArtifact,
type WorkboardAttachment,
type WorkboardAttemptStatus,
type WorkboardAutomation,
type WorkboardComment,
type WorkboardDiagnostic,
type WorkboardDiagnosticSeverity,
type WorkboardEvent,
type WorkboardEventKind,
type WorkboardExecution,
type WorkboardExecutionEngine,
type WorkboardExecutionMode,
type WorkboardExecutionStatus,
type WorkboardLink,
type WorkboardLinkType,
type WorkboardMetadata,
type WorkboardNotification,
type WorkboardProof,
type WorkboardProofStatus,
type WorkboardRunAttempt,
type WorkboardStatus,
type WorkboardTemplateId,
type WorkboardWorkerLog,
type WorkboardWorkerProtocol,
type WorkboardWorkspace,
} from "./types.ts";
export function normalizeExecution(value: unknown): WorkboardExecution | undefined {
if (!isRecord(value)) {
return undefined;
}
const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : "";
const engine = WORKBOARD_EXECUTION_ENGINES.includes(value.engine as WorkboardExecutionEngine)
? (value.engine as WorkboardExecutionEngine)
: null;
const mode = WORKBOARD_EXECUTION_MODES.includes(value.mode as WorkboardExecutionMode)
? (value.mode as WorkboardExecutionMode)
: null;
const status = WORKBOARD_EXECUTION_STATUSES.includes(value.status as WorkboardExecutionStatus)
? (value.status as WorkboardExecutionStatus)
: "idle";
const model = typeof value.model === "string" && value.model.trim() ? value.model.trim() : "";
const startedAt = typeof value.startedAt === "number" ? value.startedAt : 0;
const updatedAt = typeof value.updatedAt === "number" ? value.updatedAt : startedAt;
if (!id || !engine || !mode || !model || !startedAt) {
return undefined;
}
return {
id,
kind: "agent-session",
engine,
mode,
status,
model,
startedAt,
updatedAt,
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
};
}
function normalizeEvent(value: unknown): WorkboardEvent | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : "";
const kind = WORKBOARD_EVENT_KINDS.includes(value.kind as WorkboardEventKind)
? (value.kind as WorkboardEventKind)
: null;
const at = typeof value.at === "number" && Number.isFinite(value.at) ? value.at : 0;
if (!id || !kind || !at) {
return null;
}
const fromStatus = WORKBOARD_STATUSES.includes(value.fromStatus as WorkboardStatus)
? (value.fromStatus as WorkboardStatus)
: undefined;
const toStatus = WORKBOARD_STATUSES.includes(value.toStatus as WorkboardStatus)
? (value.toStatus as WorkboardStatus)
: undefined;
return {
id,
kind,
at,
...(fromStatus ? { fromStatus } : {}),
...(toStatus ? { toStatus } : {}),
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
};
}
export function normalizeEvents(value: unknown): WorkboardEvent[] {
return Array.isArray(value)
? value.map(normalizeEvent).filter((event): event is WorkboardEvent => event !== null)
: [];
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
: [];
}
function normalizeWorkerProtocolState(
value: unknown,
): WorkboardWorkerProtocol["state"] | undefined {
return value === "idle" ||
value === "running" ||
value === "completed" ||
value === "blocked" ||
value === "violated"
? value
: undefined;
}
function normalizeAutomation(value: unknown): WorkboardAutomation | undefined {
if (!isRecord(value)) {
return undefined;
}
const workspace = isRecord(value.workspace)
? {
kind:
value.workspace.kind === "scratch" ||
value.workspace.kind === "dir" ||
value.workspace.kind === "worktree"
? value.workspace.kind
: undefined,
...(typeof value.workspace.path === "string" ? { path: value.workspace.path } : {}),
...(typeof value.workspace.branch === "string" ? { branch: value.workspace.branch } : {}),
}
: undefined;
const automation: WorkboardAutomation = {
...(typeof value.tenant === "string" ? { tenant: value.tenant } : {}),
...(typeof value.boardId === "string" ? { boardId: value.boardId } : {}),
...(typeof value.createdByCardId === "string"
? { createdByCardId: value.createdByCardId }
: {}),
...(typeof value.idempotencyKey === "string" ? { idempotencyKey: value.idempotencyKey } : {}),
...(normalizeStringArray(value.skills).length
? { skills: normalizeStringArray(value.skills) }
: {}),
...(workspace?.kind ? { workspace: workspace as WorkboardWorkspace } : {}),
...(typeof value.maxRuntimeSeconds === "number"
? { maxRuntimeSeconds: value.maxRuntimeSeconds }
: {}),
...(typeof value.maxRetries === "number" ? { maxRetries: value.maxRetries } : {}),
...(typeof value.scheduledAt === "number" ? { scheduledAt: value.scheduledAt } : {}),
...(typeof value.summary === "string" ? { summary: value.summary } : {}),
...(normalizeStringArray(value.createdCardIds).length
? { createdCardIds: normalizeStringArray(value.createdCardIds) }
: {}),
...(typeof value.dispatchCount === "number" ? { dispatchCount: value.dispatchCount } : {}),
...(typeof value.lastDispatchAt === "number" ? { lastDispatchAt: value.lastDispatchAt } : {}),
};
return Object.keys(automation).length ? automation : undefined;
}
export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
if (!isRecord(value)) {
return undefined;
}
const attempts = Array.isArray(value.attempts)
? value.attempts.flatMap((entry): WorkboardRunAttempt[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.startedAt !== "number"
) {
return [];
}
const status = WORKBOARD_ATTEMPT_STATUSES.includes(entry.status as WorkboardAttemptStatus)
? (entry.status as WorkboardAttemptStatus)
: "running";
return [
{
id: entry.id,
status,
startedAt: entry.startedAt,
...(typeof entry.endedAt === "number" ? { endedAt: entry.endedAt } : {}),
...(WORKBOARD_EXECUTION_ENGINES.includes(entry.engine as WorkboardExecutionEngine)
? { engine: entry.engine as WorkboardExecutionEngine }
: {}),
...(WORKBOARD_EXECUTION_MODES.includes(entry.mode as WorkboardExecutionMode)
? { mode: entry.mode as WorkboardExecutionMode }
: {}),
...(typeof entry.model === "string" ? { model: entry.model } : {}),
...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}),
...(typeof entry.runId === "string" ? { runId: entry.runId } : {}),
...(typeof entry.error === "string" ? { error: entry.error } : {}),
},
];
})
: [];
const comments = Array.isArray(value.comments)
? value.comments.flatMap((entry): WorkboardComment[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.body !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
body: entry.body,
createdAt: entry.createdAt,
...(typeof entry.updatedAt === "number" ? { updatedAt: entry.updatedAt } : {}),
},
];
})
: [];
const links = Array.isArray(value.links)
? value.links.flatMap((entry): WorkboardLink[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
type: WORKBOARD_LINK_TYPES.includes(entry.type as WorkboardLinkType)
? (entry.type as WorkboardLinkType)
: "relates_to",
createdAt: entry.createdAt,
...(typeof entry.targetCardId === "string" ? { targetCardId: entry.targetCardId } : {}),
...(typeof entry.title === "string" ? { title: entry.title } : {}),
...(typeof entry.url === "string" ? { url: entry.url } : {}),
},
];
})
: [];
const proof = Array.isArray(value.proof)
? value.proof.flatMap((entry): WorkboardProof[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
status: WORKBOARD_PROOF_STATUSES.includes(entry.status as WorkboardProofStatus)
? (entry.status as WorkboardProofStatus)
: "unknown",
createdAt: entry.createdAt,
...(typeof entry.label === "string" ? { label: entry.label } : {}),
...(typeof entry.command === "string" ? { command: entry.command } : {}),
...(typeof entry.url === "string" ? { url: entry.url } : {}),
...(typeof entry.note === "string" ? { note: entry.note } : {}),
},
];
})
: [];
const artifacts = Array.isArray(value.artifacts)
? value.artifacts.flatMap((entry): WorkboardArtifact[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
createdAt: entry.createdAt,
...(typeof entry.label === "string" ? { label: entry.label } : {}),
...(typeof entry.url === "string" ? { url: entry.url } : {}),
...(typeof entry.path === "string" ? { path: entry.path } : {}),
...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
},
];
})
: [];
const attachments = Array.isArray(value.attachments)
? value.attachments.flatMap((entry): WorkboardAttachment[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.cardId !== "string" ||
typeof entry.fileName !== "string" ||
typeof entry.byteSize !== "number" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
cardId: entry.cardId,
fileName: entry.fileName,
byteSize: entry.byteSize,
createdAt: entry.createdAt,
...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
...(typeof entry.note === "string" ? { note: entry.note } : {}),
},
];
})
: [];
const workerLogs = Array.isArray(value.workerLogs)
? value.workerLogs.flatMap((entry): WorkboardWorkerLog[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.message !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
level:
entry.level === "warning" || entry.level === "error" || entry.level === "info"
? entry.level
: "info",
message: entry.message,
createdAt: entry.createdAt,
...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}),
...(typeof entry.runId === "string" ? { runId: entry.runId } : {}),
},
];
})
: [];
const workerProtocolRecord = isRecord(value.workerProtocol) ? value.workerProtocol : null;
const workerProtocolState = normalizeWorkerProtocolState(workerProtocolRecord?.state);
const workerProtocol = workerProtocolState
? {
state: workerProtocolState,
updatedAt:
typeof workerProtocolRecord?.updatedAt === "number"
? workerProtocolRecord.updatedAt
: Date.now(),
...(typeof workerProtocolRecord?.detail === "string"
? { detail: workerProtocolRecord.detail }
: {}),
}
: undefined;
const claim = isRecord(value.claim)
? {
ownerId: typeof value.claim.ownerId === "string" ? value.claim.ownerId : "",
...(typeof value.claim.token === "string" ? { token: value.claim.token } : {}),
claimedAt: typeof value.claim.claimedAt === "number" ? value.claim.claimedAt : 0,
lastHeartbeatAt:
typeof value.claim.lastHeartbeatAt === "number" ? value.claim.lastHeartbeatAt : 0,
...(typeof value.claim.expiresAt === "number" ? { expiresAt: value.claim.expiresAt } : {}),
}
: undefined;
const diagnostics = Array.isArray(value.diagnostics)
? value.diagnostics.flatMap((entry): WorkboardDiagnostic[] => {
if (!isRecord(entry) || typeof entry.kind !== "string" || typeof entry.title !== "string") {
return [];
}
return [
{
kind: entry.kind,
severity: WORKBOARD_DIAGNOSTIC_SEVERITIES.includes(
entry.severity as WorkboardDiagnosticSeverity,
)
? (entry.severity as WorkboardDiagnosticSeverity)
: "warning",
title: entry.title,
detail: typeof entry.detail === "string" ? entry.detail : entry.title,
firstSeenAt: typeof entry.firstSeenAt === "number" ? entry.firstSeenAt : Date.now(),
lastSeenAt: typeof entry.lastSeenAt === "number" ? entry.lastSeenAt : Date.now(),
count: typeof entry.count === "number" ? entry.count : 1,
},
];
})
: [];
const notifications = Array.isArray(value.notifications)
? value.notifications.flatMap((entry): WorkboardNotification[] => {
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.kind !== "string" ||
typeof entry.message !== "string" ||
typeof entry.createdAt !== "number"
) {
return [];
}
return [
{
id: entry.id,
kind: entry.kind,
message: entry.message,
createdAt: entry.createdAt,
...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}),
...(typeof entry.runId === "string" ? { runId: entry.runId } : {}),
},
];
})
: [];
const stale = isRecord(value.stale)
? {
detectedAt:
typeof value.stale.detectedAt === "number" ? value.stale.detectedAt : Date.now(),
...(typeof value.stale.lastSessionUpdatedAt === "number"
? { lastSessionUpdatedAt: value.stale.lastSessionUpdatedAt }
: {}),
reason:
typeof value.stale.reason === "string"
? value.stale.reason
: "Session has not reported recent activity.",
}
: undefined;
const automation = normalizeAutomation(value.automation);
const lifecycleStatusSourceUpdatedAt =
typeof value.lifecycleStatusSourceUpdatedAt === "number" &&
Number.isFinite(value.lifecycleStatusSourceUpdatedAt)
? Math.max(0, Math.trunc(value.lifecycleStatusSourceUpdatedAt))
: undefined;
const metadata: WorkboardMetadata = {
...(attempts.length ? { attempts } : {}),
...(comments.length ? { comments } : {}),
...(links.length ? { links } : {}),
...(proof.length ? { proof } : {}),
...(artifacts.length ? { artifacts } : {}),
...(attachments.length ? { attachments } : {}),
...(workerLogs.length ? { workerLogs } : {}),
...(workerProtocol ? { workerProtocol } : {}),
...(automation ? { automation } : {}),
...(claim?.ownerId && claim.claimedAt ? { claim } : {}),
...(diagnostics.length ? { diagnostics } : {}),
...(notifications.length ? { notifications } : {}),
...(WORKBOARD_TEMPLATE_IDS.includes(value.templateId as WorkboardTemplateId)
? { templateId: value.templateId as WorkboardTemplateId }
: {}),
...(typeof value.archivedAt === "number" ? { archivedAt: value.archivedAt } : {}),
...(stale ? { stale } : {}),
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
...(typeof value.failureCount === "number" ? { failureCount: value.failureCount } : {}),
};
return Object.keys(metadata).length ? metadata : undefined;
}
+319
View File
@@ -0,0 +1,319 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
draftPayload,
removeCardAndReferences,
replaceCard,
resetDraftState,
} from "./card-state.ts";
import { clearPendingStatusTransition, recordPendingStatusTransition } from "./lifecycle.ts";
import { formatError, isRecord } from "./normalization-utils.ts";
import { normalizeCardPayload, normalizeCardsPayload } from "./normalization.ts";
import {
getWorkboardState,
invalidateWorkboardLoads,
resetWorkboardLifecycleTaskConfirmations,
setWorkboardLifecycleTaskRefreshFailed,
workboardHasActiveWrites,
workboardMutationsReady,
type WorkboardHost,
} from "./runtime.ts";
import { applyTaskSummariesToState, listWorkboardTasks } from "./task-links.ts";
import type { WorkboardDispatchSummary, WorkboardStatus } from "./types.ts";
function normalizeDispatchSummary(value: unknown): WorkboardDispatchSummary {
const countArray = (key: string) =>
isRecord(value) && Array.isArray(value[key]) ? value[key].length : 0;
return {
started: countArray("started"),
failures: countArray("startFailures"),
promoted: countArray("promoted"),
blocked: countArray("blocked"),
reclaimed: countArray("reclaimed"),
orchestrated: countArray("orchestrated"),
};
}
export async function createWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
!state.draftTitle.trim() ||
state.dispatching ||
state.draftSaving
) {
return;
}
invalidateWorkboardLoads(params.host);
state.draftSaving = true;
state.loading = true;
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.create", draftPayload(state));
replaceCard(state, normalizeCardPayload(payload));
resetDraftState(state);
} catch (error) {
state.error = formatError(error);
} finally {
state.draftSaving = false;
state.loading = false;
params.requestUpdate?.();
}
}
export async function saveWorkboardCardDraft(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (!state.editingCardId) {
await createWorkboardCard(params);
return;
}
if (
!params.client ||
!workboardMutationsReady(state) ||
!state.draftTitle.trim() ||
state.dispatching ||
state.draftSaving ||
state.busyCardIds.has(state.editingCardId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.draftSaving = true;
state.loading = true;
state.error = null;
const cardId = state.editingCardId;
const pendingStatusRecorded = recordPendingStatusTransition(
params.host,
state.cards.find((card) => card.id === cardId),
state.draftStatus,
);
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.update", {
id: cardId,
patch: draftPayload(state),
});
replaceCard(state, normalizeCardPayload(payload));
resetDraftState(state);
} catch (error) {
state.error = formatError(error);
} finally {
clearPendingStatusTransition(params.host, cardId, pendingStatusRecorded);
state.draftSaving = false;
state.loading = false;
params.requestUpdate?.();
}
}
export async function addWorkboardCardComment(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId?: string;
body?: string;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
const cardId = params.cardId ?? state.editingCardId;
const body = (params.body ?? state.draftCommentBody).trim();
if (
!cardId ||
!params.client ||
!workboardMutationsReady(state) ||
!body ||
state.dispatching ||
state.draftSaving ||
state.busyCardIds.has(cardId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(cardId);
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.comment", {
id: cardId,
body,
});
replaceCard(state, normalizeCardPayload(payload));
if (params.body === undefined) {
state.draftCommentBody = "";
} else if (state.detailCardId === cardId) {
state.detailCommentBody = "";
}
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardIds.delete(cardId);
params.requestUpdate?.();
}
}
export async function moveWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
status: WorkboardStatus;
position: number;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
state.busyCardIds.has(params.cardId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(params.cardId);
state.error = null;
const pendingStatusRecorded = recordPendingStatusTransition(
params.host,
state.cards.find((card) => card.id === params.cardId),
params.status,
);
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.move", {
id: params.cardId,
status: params.status,
position: params.position,
});
replaceCard(state, normalizeCardPayload(payload));
} catch (error) {
state.error = formatError(error);
} finally {
clearPendingStatusTransition(params.host, params.cardId, pendingStatusRecorded);
state.busyCardIds.delete(params.cardId);
if (state.draggedCardId === params.cardId) {
state.draggedCardId = null;
}
params.requestUpdate?.();
}
}
export async function deleteWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
state.busyCardIds.has(params.cardId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(params.cardId);
state.error = null;
params.requestUpdate?.();
try {
await params.client.request("workboard.cards.delete", { id: params.cardId });
state.cards = removeCardAndReferences(state.cards, params.cardId);
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardIds.delete(params.cardId);
params.requestUpdate?.();
}
}
export async function archiveWorkboardCard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
cardId: string;
archived?: boolean;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
state.busyCardIds.has(params.cardId)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.busyCardIds.add(params.cardId);
state.error = null;
params.requestUpdate?.();
try {
const payload = await params.client.request("workboard.cards.archive", {
id: params.cardId,
archived: params.archived ?? true,
});
replaceCard(state, normalizeCardPayload(payload));
} catch (error) {
state.error = formatError(error);
} finally {
state.busyCardIds.delete(params.cardId);
params.requestUpdate?.();
}
}
export async function dispatchWorkboard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}) {
const state = getWorkboardState(params.host);
if (
!params.client ||
!workboardMutationsReady(state) ||
state.dispatching ||
workboardHasActiveWrites(state)
) {
return;
}
invalidateWorkboardLoads(params.host);
state.dispatching = true;
state.error = null;
state.lastDispatchSummary = null;
params.requestUpdate?.();
try {
const dispatchResult = await params.client.request("workboard.cards.dispatch", {});
const payload = await params.client.request("workboard.cards.list", {});
const normalized = normalizeCardsPayload(payload);
state.cards = normalized.cards;
state.statuses = normalized.statuses;
state.lastDispatchSummary = normalizeDispatchSummary(dispatchResult);
state.tasksByCardId = new Map();
resetWorkboardLifecycleTaskConfirmations(state, { host: params.host });
try {
applyTaskSummariesToState(state, await listWorkboardTasks(params.client));
setWorkboardLifecycleTaskRefreshFailed(state, false, { host: params.host });
state.lifecycleTaskRefreshError = null;
state.lastRefreshError = null;
} catch (error) {
setWorkboardLifecycleTaskRefreshFailed(state, true, {
host: params.host,
requestUpdate: params.requestUpdate,
});
state.lastRefreshError = formatError(error);
}
// A teardown may have invalidated this in-flight dispatch. Keep its cached
// result reload-required so reconnect cannot treat an old completion as canonical.
state.loaded = workboardMutationsReady(state);
} catch (error) {
state.error = formatError(error);
} finally {
state.dispatching = false;
params.requestUpdate?.();
}
}
@@ -0,0 +1,16 @@
export function formatError(error: unknown): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
if (isRecord(error) && typeof error.message === "string" && error.message.trim()) {
return error.message.trim();
}
return "Unknown workboard error.";
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
+152
View File
@@ -0,0 +1,152 @@
import {
normalizeEvents,
normalizeExecution,
normalizeMetadata,
} from "./metadata-normalization.ts";
import { isRecord } from "./normalization-utils.ts";
import {
WORKBOARD_PRIORITIES,
WORKBOARD_STATUSES,
type WorkboardCard,
type WorkboardPriority,
type WorkboardStatus,
type WorkboardTaskStatus,
type WorkboardTaskSummary,
} from "./types.ts";
function normalizeCard(value: unknown): WorkboardCard | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" ? value.id : "";
const title = typeof value.title === "string" ? value.title : "";
const status = WORKBOARD_STATUSES.includes(value.status as WorkboardStatus)
? (value.status as WorkboardStatus)
: "todo";
const priority = WORKBOARD_PRIORITIES.includes(value.priority as WorkboardPriority)
? (value.priority as WorkboardPriority)
: "normal";
if (!id || !title) {
return null;
}
const execution = normalizeExecution(value.execution);
const events = normalizeEvents(value.events);
const metadata = normalizeMetadata(value.metadata);
return {
id,
title,
status,
priority,
labels: Array.isArray(value.labels)
? value.labels.filter((label): label is string => typeof label === "string")
: [],
position: typeof value.position === "number" ? value.position : 0,
createdAt: typeof value.createdAt === "number" ? value.createdAt : 0,
updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
...(typeof value.notes === "string" ? { notes: value.notes } : {}),
...(typeof value.agentId === "string" ? { agentId: value.agentId } : {}),
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
...(typeof value.taskId === "string" ? { taskId: value.taskId } : {}),
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
...(execution ? { execution } : {}),
...(typeof value.startedAt === "number" ? { startedAt: value.startedAt } : {}),
...(typeof value.completedAt === "number" ? { completedAt: value.completedAt } : {}),
...(events.length ? { events } : {}),
...(metadata ? { metadata } : {}),
};
}
export function normalizeCardsPayload(payload: unknown): {
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
} {
if (!isRecord(payload)) {
return { cards: [], statuses: WORKBOARD_STATUSES };
}
const cards = Array.isArray(payload.cards)
? payload.cards.map(normalizeCard).filter((card): card is WorkboardCard => card !== null)
: [];
const statuses = Array.isArray(payload.statuses)
? payload.statuses.filter((status): status is WorkboardStatus =>
WORKBOARD_STATUSES.includes(status as WorkboardStatus),
)
: WORKBOARD_STATUSES;
return { cards, statuses: statuses.length ? statuses : WORKBOARD_STATUSES };
}
export function normalizeCardPayload(payload: unknown): WorkboardCard {
const card = isRecord(payload) ? normalizeCard(payload.card) : null;
if (!card) {
throw new Error("workboard response did not include a card");
}
return card;
}
function normalizeTaskStatus(value: unknown): WorkboardTaskStatus | null {
switch (value) {
case "queued":
case "running":
case "completed":
case "failed":
case "cancelled":
case "timed_out":
return value;
default:
return null;
}
}
export function normalizeTaskSummary(value: unknown): WorkboardTaskSummary | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" && value.id.trim() ? value.id.trim() : null;
const taskId = typeof value.taskId === "string" && value.taskId.trim() ? value.taskId.trim() : id;
const status = normalizeTaskStatus(value.status);
if (!id || !taskId || !status) {
return null;
}
return {
id,
taskId,
status,
...(typeof value.title === "string" ? { title: value.title } : {}),
...(typeof value.agentId === "string" ? { agentId: value.agentId } : {}),
...(typeof value.sessionKey === "string" ? { sessionKey: value.sessionKey } : {}),
...(typeof value.childSessionKey === "string"
? { childSessionKey: value.childSessionKey }
: {}),
...(typeof value.ownerKey === "string" ? { ownerKey: value.ownerKey } : {}),
...(typeof value.runId === "string" ? { runId: value.runId } : {}),
...(typeof value.sourceId === "string" ? { sourceId: value.sourceId } : {}),
...(typeof value.updatedAt === "number" || typeof value.updatedAt === "string"
? { updatedAt: value.updatedAt }
: {}),
...(typeof value.progressSummary === "string"
? { progressSummary: value.progressSummary }
: {}),
...(typeof value.terminalSummary === "string"
? { terminalSummary: value.terminalSummary }
: {}),
...(typeof value.error === "string" ? { error: value.error } : {}),
};
}
export function normalizeTasksPage(payload: unknown): {
tasks: WorkboardTaskSummary[];
nextCursor: string | null;
} {
if (!isRecord(payload) || !Array.isArray(payload.tasks)) {
return { tasks: [], nextCursor: null };
}
return {
tasks: payload.tasks
.map(normalizeTaskSummary)
.filter((task): task is WorkboardTaskSummary => task !== null),
nextCursor:
typeof payload.nextCursor === "string" && payload.nextCursor.trim()
? payload.nextCursor.trim()
: null,
};
}
+467
View File
@@ -0,0 +1,467 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { normalizeString, workboardCardSessionKey } from "./card-state.ts";
import {
WORKBOARD_STATUSES,
type WorkboardAutoRefreshIntervalMs,
type WorkboardTaskLinkState,
type WorkboardUiState,
} from "./types.ts";
export type WorkboardHost = object;
export type WorkboardLoadToken = {
queuedAfterGeneration?: number;
};
type WorkboardPollingEntry = {
client: GatewayBrowserClient | null;
enabled: boolean;
intervalMs: WorkboardAutoRefreshIntervalMs;
requestUpdate?: () => void;
};
type WorkboardRuntime = {
state?: WorkboardUiState;
loadPromise?: Promise<boolean>;
loadToken?: WorkboardLoadToken;
loadError?: string;
lifecycleTaskRefreshPromise?: Promise<number | null>;
lifecycleWrites: Set<Promise<unknown>>;
loadGeneration?: number;
lifecycleReconciliationEpoch?: number;
pollingGeneration?: number;
taskPollOffset?: number;
taskDiscoveryOffset?: number;
defaultTaskDiscoveryCursor?: string;
pollingTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskPreparedTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskRetryTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskContinuationTimer?: ReturnType<typeof setTimeout>;
pollingEntry?: WorkboardPollingEntry;
pendingStatusTransitions: Set<string>;
lifecycleSyncKeys: Map<string, string>;
};
const workboardRuntimes = new WeakMap<WorkboardHost, WorkboardRuntime>();
export const WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_WINDOW_MS = 5000;
export const WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_TIMEOUT_ERROR =
"Task confirmation exceeded its freshness window.";
const WORKBOARD_LIFECYCLE_TASK_RETRY_MS = 5000;
const WORKBOARD_LIFECYCLE_TASK_CONTINUE_MS = 100;
export function nextWorkboardLoadGeneration(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
const generation = (runtime.loadGeneration ?? 0) + 1;
runtime.loadGeneration = generation;
return generation;
}
export function isCurrentWorkboardLoadGeneration(host: WorkboardHost, generation: number): boolean {
return getWorkboardRuntime(host).loadGeneration === generation;
}
export function nextWorkboardPollingGeneration(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
const generation = (runtime.pollingGeneration ?? 0) + 1;
runtime.pollingGeneration = generation;
return generation;
}
export function currentWorkboardPollingGeneration(host: WorkboardHost): number {
return getWorkboardRuntime(host).pollingGeneration ?? 0;
}
export function isCurrentWorkboardPollingGeneration(
host: WorkboardHost,
generation: number,
): boolean {
return currentWorkboardPollingGeneration(host) === generation;
}
function nextWorkboardLifecycleReconciliationEpoch(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
const epoch = (runtime.lifecycleReconciliationEpoch ?? 0) + 1;
runtime.lifecycleReconciliationEpoch = epoch;
return epoch;
}
export function currentWorkboardLifecycleReconciliationEpoch(host: WorkboardHost): number {
return getWorkboardRuntime(host).lifecycleReconciliationEpoch ?? 0;
}
export function isCurrentWorkboardLifecycleReconciliationEpoch(
host: WorkboardHost,
epoch: number,
): boolean {
return currentWorkboardLifecycleReconciliationEpoch(host) === epoch;
}
export function invalidateWorkboardLoads(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const state = runtime.state;
if (state) {
setWorkboardLifecycleTasksPrepared(state, false, { host });
resetWorkboardLifecycleTaskConfirmations(state, { host });
if (runtime.loadPromise) {
if (!state.draftSaving) {
state.loading = false;
}
if (!state.loaded) {
state.loadAttempted = false;
}
}
}
nextWorkboardLoadGeneration(host);
delete runtime.loadPromise;
delete runtime.loadToken;
nextWorkboardLifecycleReconciliationEpoch(host);
}
export function clearWorkboardLifecycleTaskPreparedTimer(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.lifecycleTaskPreparedTimer;
if (timer) {
clearTimeout(timer);
delete runtime.lifecycleTaskPreparedTimer;
}
}
export function clearWorkboardLifecycleTaskRetryTimer(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.lifecycleTaskRetryTimer;
if (timer) {
clearTimeout(timer);
delete runtime.lifecycleTaskRetryTimer;
}
}
function clearWorkboardLifecycleTaskContinuationTimer(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.lifecycleTaskContinuationTimer;
if (timer) {
clearTimeout(timer);
delete runtime.lifecycleTaskContinuationTimer;
}
}
export function trackWorkboardLifecycleWrite(host: WorkboardHost, write: Promise<unknown>) {
getWorkboardRuntime(host).lifecycleWrites.add(write);
}
export function releaseWorkboardLifecycleWrite(host: WorkboardHost, write: Promise<unknown>) {
getWorkboardRuntime(host).lifecycleWrites.delete(write);
}
export async function waitForWorkboardLifecycleWrites(host: WorkboardHost) {
while (true) {
const writes = getWorkboardRuntime(host).lifecycleWrites;
if (!writes.size) {
return;
}
await Promise.allSettled(writes);
}
}
export function resetWorkboardLifecycleTaskConfirmations(
state: WorkboardUiState,
options: { host?: WorkboardHost } = {},
) {
state.lifecycleConfirmedTaskIds = new Set();
state.lifecycleTaskConfirmationStartedAt = null;
setWorkboardLifecycleTaskRefreshContinuation(state, false, options);
}
export function stopWorkboardLifecycleRefresh(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
clearWorkboardLifecycleTaskPreparedTimer(host);
clearWorkboardLifecycleTaskRetryTimer(host);
clearWorkboardLifecycleTaskContinuationTimer(host);
delete runtime.lifecycleTaskRefreshPromise;
const state = runtime.state;
if (state) {
setWorkboardLifecycleTasksPrepared(state, false);
setWorkboardLifecycleTaskRefreshFailed(state, false);
state.lifecycleTaskRefreshError = null;
resetWorkboardLifecycleTaskConfirmations(state, { host });
// In-flight lifecycle writes clear themselves in finally. Keep them visible
// so reconnect loads wait for their backend mutations before becoming writable.
// Detach stale loads so reconnecting can start fresh without letting the
// old request clear a concurrent draft-save loading state.
if (!state.draftSaving) {
state.loading = false;
}
// Keep cached cards visible across disconnects, but require a canonical
// reload before accepting writes against data that may now be stale.
state.mutationReadiness = "canonical_reload_required";
state.loaded = false;
state.loadAttempted = false;
}
nextWorkboardLoadGeneration(host);
delete runtime.loadPromise;
delete runtime.loadToken;
nextWorkboardLifecycleReconciliationEpoch(host);
}
export function setWorkboardLifecycleTasksPrepared(
state: WorkboardUiState,
prepared: boolean,
options: {
host?: WorkboardHost;
preparedAt?: number;
requestUpdate?: () => void;
} = {},
) {
const preparedAt = options.preparedAt ?? Date.now();
state.lifecycleTasksPrepared = prepared;
state.lifecycleTasksPreparedAt = prepared ? preparedAt : null;
const host = options.host;
if (!host) {
return;
}
clearWorkboardLifecycleTaskPreparedTimer(host);
if (
!prepared ||
!options.requestUpdate ||
state.autoRefreshIntervalMs === 0 ||
!shouldRefreshWorkboardTasksForLifecycle(state)
) {
return;
}
const nextTimer = setTimeout(
() => {
delete getWorkboardRuntime(host).lifecycleTaskPreparedTimer;
options.requestUpdate?.();
},
Math.max(0, preparedAt + state.autoRefreshIntervalMs - Date.now()),
);
getWorkboardRuntime(host).lifecycleTaskPreparedTimer = nextTimer;
}
export function workboardLifecycleTasksPreparedAt(state: WorkboardUiState, now = Date.now()) {
if (!state.lifecycleTasksPrepared || state.lifecycleTasksPreparedAt === null) {
return null;
}
if (
state.autoRefreshIntervalMs > 0 &&
now - state.lifecycleTasksPreparedAt >= state.autoRefreshIntervalMs
) {
return null;
}
return state.lifecycleTasksPreparedAt;
}
export function setWorkboardLifecycleTaskRefreshFailed(
state: WorkboardUiState,
failed: boolean,
options: {
host?: WorkboardHost;
requestUpdate?: () => void;
retryDelayMs?: number;
} = {},
) {
const retryDelayMs = options.retryDelayMs ?? WORKBOARD_LIFECYCLE_TASK_RETRY_MS;
state.lifecycleTaskRefreshFailed = failed;
state.lifecycleTaskRefreshRetryAt = failed ? Date.now() + retryDelayMs : null;
const host = options.host;
if (!host) {
return;
}
clearWorkboardLifecycleTaskRetryTimer(host);
if (!failed || !options.requestUpdate || state.autoRefreshIntervalMs === 0) {
return;
}
const nextTimer = setTimeout(() => {
delete getWorkboardRuntime(host).lifecycleTaskRetryTimer;
options.requestUpdate?.();
}, retryDelayMs);
getWorkboardRuntime(host).lifecycleTaskRetryTimer = nextTimer;
}
export function setWorkboardLifecycleTaskRefreshContinuation(
state: WorkboardUiState,
pending: boolean,
options: {
host?: WorkboardHost;
requestUpdate?: () => void;
} = {},
) {
state.lifecycleTaskRefreshContinueAt = pending
? Date.now() + WORKBOARD_LIFECYCLE_TASK_CONTINUE_MS
: null;
const host = options.host;
if (!host) {
return;
}
clearWorkboardLifecycleTaskContinuationTimer(host);
if (!pending || !options.requestUpdate) {
return;
}
// Continue bounded exact-confirmation even when routine polling is off.
// Keep this separate so render polling cannot cancel the freshness-bounded sequence.
const nextTimer = setTimeout(() => {
delete getWorkboardRuntime(host).lifecycleTaskContinuationTimer;
options.requestUpdate?.();
}, WORKBOARD_LIFECYCLE_TASK_CONTINUE_MS);
getWorkboardRuntime(host).lifecycleTaskContinuationTimer = nextTimer;
}
export function workboardLifecycleTaskRefreshRetryPending(
state: WorkboardUiState,
now = Date.now(),
) {
return (
state.lifecycleTaskRefreshFailed &&
state.lifecycleTaskRefreshRetryAt !== null &&
now < state.lifecycleTaskRefreshRetryAt
);
}
export function workboardLifecycleTaskRefreshContinuationWaiting(
state: WorkboardUiState,
now = Date.now(),
) {
return (
state.lifecycleTaskRefreshContinueAt !== null && now < state.lifecycleTaskRefreshContinueAt
);
}
function createDefaultState(): WorkboardUiState {
return {
loading: false,
loaded: false,
loadAttempted: false,
mutationReadiness: "ready",
error: null,
cards: [],
statuses: WORKBOARD_STATUSES,
tasksByCardId: new Map(),
missingTaskIds: new Set(),
lastDispatchSummary: null,
dispatching: false,
query: "",
priorityFilter: "all",
agentFilter: "all",
viewPreset: "all",
activeHealthHighlight: null,
showArchived: false,
layout: "compact",
hideEmptyColumns: false,
autoRefreshIntervalMs: 0,
lastRefreshAt: null,
lastRefreshStartedAt: null,
lastRefreshError: null,
lastRefreshSource: null,
pollRefreshInProgress: false,
lifecycleTasksPrepared: false,
lifecycleTasksPreparedAt: null,
lifecycleTaskRefreshFailed: false,
lifecycleTaskRefreshRetryAt: null,
lifecycleTaskRefreshContinueAt: null,
lifecycleTaskRefreshError: null,
lifecycleConfirmedTaskIds: new Set(),
lifecycleTaskConfirmationStartedAt: null,
draftOpen: false,
draftSaving: false,
editingCardId: null,
draftTitle: "",
draftNotes: "",
draftStatus: "todo",
draftPriority: "normal",
draftLabels: "",
draftAgentId: "",
draftSessionKey: "",
draftTemplateId: "",
draftCommentBody: "",
detailCardId: null,
detailCommentBody: "",
busyCardIds: new Set(),
draggedCardId: null,
syncingCardIds: new Set(),
capturingSessionKeys: new Set(),
};
}
export function getWorkboardRuntime(host: WorkboardHost): WorkboardRuntime {
let runtime = workboardRuntimes.get(host);
if (!runtime) {
runtime = {
lifecycleWrites: new Set(),
pendingStatusTransitions: new Set(),
lifecycleSyncKeys: new Map(),
};
workboardRuntimes.set(host, runtime);
}
return runtime;
}
export function getWorkboardState(host: WorkboardHost): WorkboardUiState {
const runtime = getWorkboardRuntime(host);
runtime.state ??= createDefaultState();
return runtime.state;
}
export function workboardMutationsReady(state: WorkboardUiState): boolean {
return state.mutationReadiness === "ready";
}
export function workboardHasActiveWrites(state: WorkboardUiState): boolean {
return Boolean(
state.draftSaving ||
state.busyCardIds.size ||
state.syncingCardIds.size ||
state.capturingSessionKeys.size,
);
}
function workboardHasActiveLoad(host: WorkboardHost): boolean {
return Boolean(getWorkboardRuntime(host).loadPromise);
}
export function workboardLifecycleSyncBlocked(
host: WorkboardHost,
state: WorkboardUiState,
): boolean {
return Boolean(
state.draftOpen ||
state.editingCardId ||
state.draggedCardId ||
state.dispatching ||
workboardHasActiveWrites(state) ||
workboardHasActiveLoad(host),
);
}
export function workboardLifecycleRequiresTaskRefresh(state: WorkboardTaskLinkState): boolean {
return (
state.tasksByCardId.size > 0 ||
state.cards.some((card) => {
const taskId = normalizeString(card.taskId);
return Boolean(taskId && !state.missingTaskIds.has(taskId));
})
);
}
export function shouldRefreshWorkboardTasksForLifecycle(state: WorkboardTaskLinkState): boolean {
return (
workboardLifecycleRequiresTaskRefresh(state) ||
state.cards.some((card) => card.status === "running" && Boolean(workboardCardSessionKey(card)))
);
}
export function workboardTaskLinksReadyForLifecycle(
state: WorkboardTaskLinkState,
options: { requireRunningTaskDiscovery?: boolean } = {},
): boolean {
return state.cards.every((card) => {
const taskId = normalizeString(card.taskId);
if (taskId) {
return state.missingTaskIds.has(taskId) || state.tasksByCardId.has(card.id);
}
return (
!options.requireRunningTaskDiscovery ||
card.status !== "running" ||
!workboardCardSessionKey(card) ||
state.tasksByCardId.has(card.id)
);
});
}
+221
View File
@@ -0,0 +1,221 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import {
isFailedSessionStatus,
normalizeString,
replaceCard,
workboardCardSessionKey,
} from "./card-state.ts";
import { loadWorkboard } from "./loading.ts";
import { formatError, isRecord } from "./normalization-utils.ts";
import { normalizeCardPayload } from "./normalization.ts";
import {
getWorkboardState,
invalidateWorkboardLoads,
waitForWorkboardLifecycleWrites,
type WorkboardHost,
} from "./runtime.ts";
import type { WorkboardCard, WorkboardStatus } from "./types.ts";
const SESSION_CAPTURE_HISTORY_LIMIT = 40;
const SESSION_CAPTURE_HISTORY_MAX_CHARS = 6000;
const SESSION_CAPTURE_TEXT_MAX_CHARS = 700;
const WORKBOARD_CAPTURE_TITLE_MAX_CHARS = 180;
function textFromContent(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (!Array.isArray(value)) {
return "";
}
return value
.map((part) => {
if (!isRecord(part)) {
return "";
}
if (typeof part.text === "string") {
return part.text;
}
if (typeof part.content === "string") {
return part.content;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
function extractChatHistoryText(
messages: unknown[],
role: "assistant" | "user",
direction: "first" | "last",
): string | null {
const ordered = direction === "first" ? messages : messages.toReversed();
for (const message of ordered) {
if (!isRecord(message) || message.role !== role) {
continue;
}
const text = textFromContent(message.content).trim();
if (text) {
return text;
}
}
return null;
}
function clampSessionCaptureText(value: string): string {
const compact = value.replace(/\s+/g, " ").trim();
if (compact.length <= SESSION_CAPTURE_TEXT_MAX_CHARS) {
return compact;
}
return `${truncateUtf16Safe(compact, SESSION_CAPTURE_TEXT_MAX_CHARS - 3).trimEnd()}...`;
}
function clampSessionCaptureTitle(value: string): string {
const compact = value.replace(/\s+/g, " ").trim();
if (compact.length <= WORKBOARD_CAPTURE_TITLE_MAX_CHARS) {
return compact;
}
return `${truncateUtf16Safe(compact, WORKBOARD_CAPTURE_TITLE_MAX_CHARS - 3).trimEnd()}...`;
}
function sessionTitle(session: GatewaySessionRow, recentUserText: string | null): string {
const title =
normalizeString(session.label) ??
normalizeString(session.displayName) ??
recentUserText ??
session.key;
return clampSessionCaptureTitle(title);
}
function sessionCaptureStatus(session: GatewaySessionRow): WorkboardStatus {
if (session.hasActiveRun === true || session.status === "running") {
return "running";
}
if (session.abortedLastRun || isFailedSessionStatus(session.status)) {
return "blocked";
}
if (session.status === "done") {
return "review";
}
return "todo";
}
async function loadSessionCaptureHistory(params: {
client: GatewayBrowserClient;
sessionKey: string;
}): Promise<unknown[]> {
try {
const payload = await params.client.request("chat.history", {
sessionKey: params.sessionKey,
limit: SESSION_CAPTURE_HISTORY_LIMIT,
maxChars: SESSION_CAPTURE_HISTORY_MAX_CHARS,
});
return isRecord(payload) && Array.isArray(payload.messages) ? payload.messages : [];
} catch {
return [];
}
}
function buildSessionCaptureNotes(params: {
session: GatewaySessionRow;
recentUserText: string | null;
lastAssistantText: string | null;
}): string {
const lines = [`Session: ${params.session.key}`];
if (params.recentUserText) {
lines.push("", `Recent user prompt: ${clampSessionCaptureText(params.recentUserText)}`);
}
if (params.lastAssistantText) {
lines.push("", `Latest assistant note: ${clampSessionCaptureText(params.lastAssistantText)}`);
}
return lines.join("\n");
}
export async function captureSessionToWorkboard(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
session: GatewaySessionRow;
requestUpdate?: () => void;
}): Promise<WorkboardCard | null> {
const state = getWorkboardState(params.host);
if (!params.client || params.session.kind === "global" || state.dispatching) {
return null;
}
if (state.capturingSessionKeys.has(params.session.key)) {
return state.cards.find((card) => workboardCardSessionKey(card) === params.session.key) ?? null;
}
state.error = null;
let captureStarted = false;
try {
if (!state.loaded) {
await waitForWorkboardLifecycleWrites(params.host);
await loadWorkboard({
host: params.host,
client: params.client,
requestUpdate: params.requestUpdate,
force: true,
});
}
if (!state.loaded || state.dispatching) {
return null;
}
if (state.capturingSessionKeys.has(params.session.key)) {
return (
state.cards.find((card) => workboardCardSessionKey(card) === params.session.key) ?? null
);
}
state.capturingSessionKeys.add(params.session.key);
captureStarted = true;
params.requestUpdate?.();
const existing = state.cards.find(
(card) => workboardCardSessionKey(card) === params.session.key,
);
if (existing) {
if (existing.metadata?.archivedAt) {
invalidateWorkboardLoads(params.host);
const payload = await params.client.request("workboard.cards.archive", {
id: existing.id,
archived: false,
});
const restored = normalizeCardPayload(payload);
replaceCard(state, restored);
return restored;
}
return existing;
}
const messages = await loadSessionCaptureHistory({
client: params.client,
sessionKey: params.session.key,
});
const recentUserText = extractChatHistoryText(messages, "user", "last");
const lastAssistantText = extractChatHistoryText(messages, "assistant", "last");
invalidateWorkboardLoads(params.host);
const payload = await params.client.request("workboard.cards.create", {
title: sessionTitle(params.session, recentUserText),
notes: buildSessionCaptureNotes({
session: params.session,
recentUserText,
lastAssistantText,
}),
status: sessionCaptureStatus(params.session),
priority: "normal",
agentId: "",
sessionKey: params.session.key,
});
const card = normalizeCardPayload(payload);
replaceCard(state, card);
return card;
} catch (error) {
state.error = formatError(error);
return null;
} finally {
if (captureStarted) {
state.capturingSessionKeys.delete(params.session.key);
params.requestUpdate?.();
}
}
}
+435
View File
@@ -0,0 +1,435 @@
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow } from "../../api/types.ts";
import { normalizeString, workboardCardRunId, workboardCardSessionKey } from "./card-state.ts";
import { formatError, isRecord } from "./normalization-utils.ts";
import { normalizeTaskSummary, normalizeTasksPage } from "./normalization.ts";
import { getWorkboardRuntime, type WorkboardHost } from "./runtime.ts";
import type { WorkboardCard, WorkboardTaskLinkState, WorkboardTaskSummary } from "./types.ts";
const WORKBOARD_TASKS_LIST_LIMIT = 500;
const WORKBOARD_TASK_POLL_BATCH_SIZE = 32;
const WORKBOARD_TASK_DISCOVERY_BATCH_SIZE = 4;
export const WORKBOARD_TASK_LOOKUP_RETRY_DELAYS_MS = [100, 250, 500] as const;
export async function listWorkboardTasks(
client: GatewayBrowserClient,
): Promise<WorkboardTaskSummary[]> {
const tasks: WorkboardTaskSummary[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
while (true) {
const payload = await client.request("tasks.list", {
limit: WORKBOARD_TASKS_LIST_LIMIT,
...(cursor ? { cursor } : {}),
});
const page = normalizeTasksPage(payload);
tasks.push(...page.tasks);
if (!page.nextCursor || seenCursors.has(page.nextCursor)) {
return tasks;
}
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
}
}
export function taskUpdatedAtValue(task: WorkboardTaskSummary): number {
if (typeof task.updatedAt === "number") {
return task.updatedAt;
}
if (typeof task.updatedAt === "string") {
const parsed = Date.parse(task.updatedAt);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
export function taskLifecycleSourceUpdatedAt(task: WorkboardTaskSummary): number | undefined {
const updatedAt = taskUpdatedAtValue(task);
return updatedAt > 0 ? updatedAt : undefined;
}
export function sessionUpdatedAtValue(session: GatewaySessionRow): number | undefined {
return typeof session.updatedAt === "number" && Number.isFinite(session.updatedAt)
? session.updatedAt
: undefined;
}
export function taskSessionKeyMatchesCardSession(
taskSessionKey: string | undefined,
cardSessionKey: string,
): boolean {
if (!taskSessionKey) {
return false;
}
if (taskSessionKey === cardSessionKey) {
return true;
}
return (
cardSessionKey.startsWith("subagent:workboard-") &&
taskSessionKey.endsWith(`:${cardSessionKey}`)
);
}
export function taskMatchesCard(task: WorkboardTaskSummary, card: WorkboardCard): boolean {
const cardTaskId = normalizeString(card.taskId);
if (cardTaskId && (task.taskId === cardTaskId || task.id === cardTaskId)) {
return true;
}
const cardSessionKey = workboardCardSessionKey(card);
const taskSessionMatches = cardSessionKey
? [task.sessionKey, task.childSessionKey, task.ownerKey].some((taskSessionKey) =>
taskSessionKeyMatchesCardSession(taskSessionKey, cardSessionKey),
)
: false;
const cardRunId = workboardCardRunId(card);
if (cardRunId && task.runId === cardRunId) {
return cardSessionKey ? taskSessionMatches : true;
}
return taskSessionMatches;
}
function taskMatchesCanonicalCardLink(task: WorkboardTaskSummary, card: WorkboardCard): boolean {
const cardTaskId = normalizeString(card.taskId);
if (cardTaskId) {
// Exact persisted task IDs stay authoritative when card run metadata is stale
// or an otherwise matching task summary omits its optional run ID.
return task.taskId === cardTaskId || task.id === cardTaskId;
}
const cardRunId = workboardCardRunId(card);
if (cardRunId && task.runId !== cardRunId) {
return false;
}
return taskMatchesCard(task, card);
}
export function taskMatchesTrackedCardLink(
task: WorkboardTaskSummary,
card: WorkboardCard,
missingTaskIds: ReadonlySet<string>,
): boolean {
const cardTaskId = normalizeString(card.taskId);
return cardTaskId && missingTaskIds.has(cardTaskId)
? taskMatchesCard(task, card)
: taskMatchesCanonicalCardLink(task, card);
}
function selectRotatingBatch<T>(
host: WorkboardHost,
items: readonly T[],
limit: number,
offsetKey: "taskPollOffset" | "taskDiscoveryOffset",
): T[] {
const runtime = getWorkboardRuntime(host);
if (items.length <= limit) {
runtime[offsetKey] = 0;
return [...items];
}
const offset = (runtime[offsetKey] ?? 0) % items.length;
const batch = Array.from(
{ length: limit },
(_, index) => items[(offset + index) % items.length],
).filter((item): item is T => item !== undefined);
runtime[offsetKey] = (offset + batch.length) % items.length;
return batch;
}
export function selectWorkboardTaskPollIds(
host: WorkboardHost,
cards: readonly WorkboardCard[],
previousTasksByCardId: ReadonlyMap<string, WorkboardTaskSummary>,
missingTaskIds: ReadonlySet<string>,
): string[] {
// Prepared summaries cover links between polls; rotate a hard-bounded batch
// so active, terminal, and unresolved task IDs are eventually revalidated.
const ids: string[] = [];
const seen = new Set<string>();
for (const card of cards) {
const previousTask = previousTasksByCardId.get(card.id);
const previousMatches = previousTask
? taskMatchesTrackedCardLink(previousTask, card, missingTaskIds)
: false;
let taskId: string | undefined;
if (previousMatches && previousTask) {
taskId = previousTask.taskId;
} else if (!previousMatches) {
taskId = normalizeString(card.taskId) ?? undefined;
}
if (taskId && missingTaskIds.has(taskId)) {
continue;
}
if (taskId && !seen.has(taskId)) {
seen.add(taskId);
ids.push(taskId);
}
}
return selectRotatingBatch(host, ids, WORKBOARD_TASK_POLL_BATCH_SIZE, "taskPollOffset");
}
type WorkboardTaskDiscoveryQuery = {
sessionKey?: string;
cursor?: string;
};
export function selectWorkboardTaskDiscoveryQueries(
host: WorkboardHost,
cards: readonly WorkboardCard[],
previousTasksByCardId: ReadonlyMap<string, WorkboardTaskSummary>,
missingTaskIds: ReadonlySet<string>,
): WorkboardTaskDiscoveryQuery[] {
const queries: WorkboardTaskDiscoveryQuery[] = [];
const seenSessionKeys = new Set<string>();
let hasUnfilteredQuery = false;
for (const card of cards) {
const previousTask = previousTasksByCardId.get(card.id);
const cardTaskId = normalizeString(card.taskId);
const hasCanonicalTask =
Boolean(cardTaskId && !missingTaskIds.has(cardTaskId)) ||
(previousTask ? taskMatchesTrackedCardLink(previousTask, card, missingTaskIds) : false);
const sessionKey = workboardCardSessionKey(card);
if (card.status !== "running" || hasCanonicalTask || !sessionKey) {
continue;
}
// The gateway filter is exact-match only. Default-agent Workboard sessions
// omit the canonical agent prefix, so rotate through bounded unfiltered pages.
if (sessionKey.startsWith("subagent:workboard-")) {
if (!hasUnfilteredQuery) {
hasUnfilteredQuery = true;
const cursor = getWorkboardRuntime(host).defaultTaskDiscoveryCursor;
queries.push(cursor ? { cursor } : {});
}
} else if (!seenSessionKeys.has(sessionKey)) {
seenSessionKeys.add(sessionKey);
queries.push({ sessionKey });
}
}
return selectRotatingBatch(
host,
queries,
WORKBOARD_TASK_DISCOVERY_BATCH_SIZE,
"taskDiscoveryOffset",
);
}
export function isMissingTaskLookupError(error: unknown, taskId: string): boolean {
// tasks.get currently has no structured not-found detail code.
return (
error instanceof GatewayRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message === `task not found: ${taskId}`
);
}
export async function getWorkboardTaskPollBatch(
client: GatewayBrowserClient,
taskIds: readonly string[],
discoveryQueries: readonly WorkboardTaskDiscoveryQuery[],
): Promise<{
tasks: WorkboardTaskSummary[];
missingTaskIds: Set<string>;
nextUnfilteredCursor?: string | null;
error: string | null;
}> {
const results = await Promise.allSettled([
...taskIds.map(async (taskId) => {
try {
const payload = await client.request("tasks.get", { taskId });
const task = isRecord(payload) ? normalizeTaskSummary(payload.task) : null;
return { tasks: task ? [task] : [] };
} catch (error) {
if (isMissingTaskLookupError(error, taskId)) {
return { tasks: [], missingTaskId: taskId };
}
throw error;
}
}),
...discoveryQueries.map(async (query) => {
const payload = await client.request("tasks.list", {
...query,
limit: WORKBOARD_TASKS_LIST_LIMIT,
});
const page = normalizeTasksPage(payload);
return {
tasks: page.tasks,
...(query.sessionKey ? {} : { nextUnfilteredCursor: page.nextCursor ?? null }),
};
}),
]);
const tasks: WorkboardTaskSummary[] = [];
const missingTaskIds = new Set<string>();
let nextUnfilteredCursor: string | null | undefined;
let error: string | null = null;
for (const result of results) {
if (result.status === "fulfilled") {
tasks.push(...result.value.tasks);
if ("missingTaskId" in result.value && result.value.missingTaskId) {
missingTaskIds.add(result.value.missingTaskId);
}
if ("nextUnfilteredCursor" in result.value) {
nextUnfilteredCursor = result.value.nextUnfilteredCursor;
}
} else {
error ??= formatError(result.reason);
}
}
return { tasks, missingTaskIds, nextUnfilteredCursor, error };
}
type WorkboardTaskIndex = {
byId: Map<string, WorkboardTaskSummary[]>;
byRunId: Map<string, WorkboardTaskSummary[]>;
bySessionKey: Map<string, WorkboardTaskSummary[]>;
};
function addTaskIndexEntry(
index: Map<string, WorkboardTaskSummary[]>,
key: string | undefined,
task: WorkboardTaskSummary,
) {
if (!key) {
return;
}
const tasks = index.get(key) ?? [];
tasks.push(task);
index.set(key, tasks);
}
function buildWorkboardTaskIndex(tasks: readonly WorkboardTaskSummary[]): WorkboardTaskIndex {
const index: WorkboardTaskIndex = {
byId: new Map(),
byRunId: new Map(),
bySessionKey: new Map(),
};
for (const task of tasks) {
addTaskIndexEntry(index.byId, task.id, task);
addTaskIndexEntry(index.byId, task.taskId, task);
addTaskIndexEntry(index.byRunId, task.runId, task);
for (const sessionKey of [task.sessionKey, task.childSessionKey, task.ownerKey]) {
addTaskIndexEntry(index.bySessionKey, sessionKey, task);
const nestedWorkboardSessionIndex = sessionKey?.lastIndexOf(":subagent:workboard-") ?? -1;
if (nestedWorkboardSessionIndex >= 0) {
addTaskIndexEntry(
index.bySessionKey,
sessionKey?.slice(nestedWorkboardSessionIndex + 1),
task,
);
}
}
}
return index;
}
function findLatestTaskForCard(
index: WorkboardTaskIndex,
card: WorkboardCard,
missingTaskIds?: ReadonlySet<string>,
): WorkboardTaskSummary | null {
const cardTaskId = normalizeString(card.taskId);
if (cardTaskId) {
let latestExact: WorkboardTaskSummary | null = null;
for (const task of index.byId.get(cardTaskId) ?? []) {
if (
taskMatchesCanonicalCardLink(task, card) &&
(!latestExact || taskUpdatedAtValue(task) > taskUpdatedAtValue(latestExact))
) {
latestExact = task;
}
}
if (latestExact || !missingTaskIds?.has(cardTaskId)) {
return latestExact;
}
}
const candidates = new Set<WorkboardTaskSummary>();
const addCandidates = (tasks: readonly WorkboardTaskSummary[] | undefined) => {
for (const task of tasks ?? []) {
candidates.add(task);
}
};
addCandidates(index.byRunId.get(workboardCardRunId(card) ?? ""));
addCandidates(index.bySessionKey.get(workboardCardSessionKey(card) ?? ""));
let latest: WorkboardTaskSummary | null = null;
for (const task of candidates) {
if (
taskMatchesCard(task, card) &&
(!latest || taskUpdatedAtValue(task) > taskUpdatedAtValue(latest))
) {
latest = task;
}
}
return latest;
}
export function selectWorkboardMissingTaskConfirmationIds(
host: WorkboardHost,
cards: readonly WorkboardCard[],
tasks: readonly WorkboardTaskSummary[],
missingTaskIds: ReadonlySet<string>,
previousTasksByCardId: ReadonlyMap<string, WorkboardTaskSummary> = new Map(),
confirmedTaskIds: ReadonlySet<string> = new Set(),
limit = WORKBOARD_TASK_POLL_BATCH_SIZE,
): string[] {
const taskIndex = buildWorkboardTaskIndex(tasks);
const ids: string[] = [];
const seen = new Set<string>();
for (const card of cards) {
const previousTask = previousTasksByCardId.get(card.id);
const previousMatches = previousTask
? taskMatchesTrackedCardLink(previousTask, card, missingTaskIds)
: false;
const taskId =
previousMatches && previousTask ? previousTask.taskId : normalizeString(card.taskId);
if (
!taskId ||
seen.has(taskId) ||
missingTaskIds.has(taskId) ||
confirmedTaskIds.has(taskId) ||
findLatestTaskForCard(taskIndex, card, missingTaskIds)
) {
continue;
}
seen.add(taskId);
ids.push(taskId);
}
return Number.isFinite(limit) ? selectRotatingBatch(host, ids, limit, "taskPollOffset") : ids;
}
export function applyTaskSummariesToState(
state: WorkboardTaskLinkState,
tasks: readonly WorkboardTaskSummary[],
options: {
missingTaskIds?: ReadonlySet<string>;
} = {},
) {
const tasksByCardId = new Map<string, WorkboardTaskSummary>();
const taskIndex = buildWorkboardTaskIndex(tasks);
// Keep historical card links read-only while remembering exact ledger misses.
// Confirmed misses stop blocking starts without writes from passive refresh paths.
const missingTaskIds = new Set([...state.missingTaskIds, ...(options.missingTaskIds ?? [])]);
const cards = state.cards.map((card) => {
const cardTaskId = normalizeString(card.taskId);
const task = findLatestTaskForCard(taskIndex, card, missingTaskIds);
if (!task) {
return card;
}
tasksByCardId.set(card.id, task);
const replacesMissingTask =
Boolean(cardTaskId && missingTaskIds.has(cardTaskId)) &&
task.taskId !== cardTaskId &&
task.id !== cardTaskId;
if (cardTaskId && !replacesMissingTask) {
missingTaskIds.delete(cardTaskId);
}
missingTaskIds.delete(task.taskId);
if (card.taskId === task.taskId || replacesMissingTask) {
return card;
}
return { ...card, taskId: task.taskId };
});
const linkedTaskIds = new Set(
cards
.map((card) => normalizeString(card.taskId))
.filter((taskId): taskId is string => Boolean(taskId)),
);
state.cards = cards;
state.tasksByCardId = tasksByCardId;
state.missingTaskIds = new Set([...missingTaskIds].filter((taskId) => linkedTaskIds.has(taskId)));
}
+420
View File
@@ -0,0 +1,420 @@
import type { GatewaySessionRow } from "../../api/types.ts";
export const WORKBOARD_STATUSES = [
"triage",
"backlog",
"todo",
"scheduled",
"ready",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
export const WORKBOARD_EXECUTION_ENGINES = ["codex", "claude"] as const;
export const WORKBOARD_EXECUTION_MODES = ["autonomous", "manual"] as const;
export const WORKBOARD_EXECUTION_STATUSES = [
"idle",
"running",
"review",
"blocked",
"done",
] as const;
export const WORKBOARD_EVENT_KINDS = [
"created",
"edited",
"moved",
"linked",
"specified",
"decomposed",
"claimed",
"heartbeat",
"execution_updated",
"attempt_started",
"attempt_updated",
"comment_added",
"link_added",
"proof_added",
"artifact_added",
"attachment_added",
"diagnostic",
"notification",
"dispatch",
"orchestration",
"protocol_violation",
"archived",
"unarchived",
"stale",
] as const;
export const WORKBOARD_ATTEMPT_STATUSES = [
"running",
"succeeded",
"failed",
"blocked",
"stopped",
] as const;
export const WORKBOARD_LINK_TYPES = [
"parent",
"child",
"blocks",
"blocked_by",
"relates_to",
] as const;
export const WORKBOARD_PROOF_STATUSES = ["passed", "failed", "skipped", "unknown"] as const;
export const WORKBOARD_TEMPLATE_IDS = ["bugfix", "docs", "release", "pr_review", "plugin"] as const;
export const WORKBOARD_DIAGNOSTIC_SEVERITIES = ["warning", "error", "critical"] as const;
export type WorkboardStatus = (typeof WORKBOARD_STATUSES)[number];
export type WorkboardPriority = (typeof WORKBOARD_PRIORITIES)[number];
export type WorkboardExecutionEngine = (typeof WORKBOARD_EXECUTION_ENGINES)[number];
export type WorkboardExecutionMode = (typeof WORKBOARD_EXECUTION_MODES)[number];
export type WorkboardExecutionStatus = (typeof WORKBOARD_EXECUTION_STATUSES)[number];
export type WorkboardEventKind = (typeof WORKBOARD_EVENT_KINDS)[number];
export type WorkboardAttemptStatus = (typeof WORKBOARD_ATTEMPT_STATUSES)[number];
export type WorkboardLinkType = (typeof WORKBOARD_LINK_TYPES)[number];
export type WorkboardProofStatus = (typeof WORKBOARD_PROOF_STATUSES)[number];
export type WorkboardTemplateId = (typeof WORKBOARD_TEMPLATE_IDS)[number];
export type WorkboardDiagnosticSeverity = (typeof WORKBOARD_DIAGNOSTIC_SEVERITIES)[number];
export type WorkboardExecution = {
id: string;
kind: "agent-session";
engine: WorkboardExecutionEngine;
mode: WorkboardExecutionMode;
status: WorkboardExecutionStatus;
model: string;
sessionKey?: string;
runId?: string;
startedAt: number;
updatedAt: number;
};
export type WorkboardEvent = {
id: string;
kind: WorkboardEventKind;
at: number;
fromStatus?: WorkboardStatus;
toStatus?: WorkboardStatus;
sessionKey?: string;
runId?: string;
};
export type WorkboardRunAttempt = {
id: string;
status: WorkboardAttemptStatus;
startedAt: number;
endedAt?: number;
engine?: WorkboardExecutionEngine;
mode?: WorkboardExecutionMode;
model?: string;
sessionKey?: string;
runId?: string;
error?: string;
};
export type WorkboardComment = {
id: string;
body: string;
createdAt: number;
updatedAt?: number;
};
export type WorkboardLink = {
id: string;
type: WorkboardLinkType;
createdAt: number;
targetCardId?: string;
title?: string;
url?: string;
};
export type WorkboardProof = {
id: string;
status: WorkboardProofStatus;
createdAt: number;
label?: string;
command?: string;
url?: string;
note?: string;
};
export type WorkboardStaleState = {
detectedAt: number;
lastSessionUpdatedAt?: number;
reason: string;
};
type WorkboardClaim = {
ownerId: string;
token?: string;
claimedAt: number;
lastHeartbeatAt: number;
expiresAt?: number;
};
export type WorkboardArtifact = {
id: string;
createdAt: number;
label?: string;
url?: string;
path?: string;
mimeType?: string;
};
export type WorkboardAttachment = {
id: string;
cardId: string;
createdAt: number;
fileName: string;
byteSize: number;
mimeType?: string;
note?: string;
};
export type WorkboardWorkerLog = {
id: string;
createdAt: number;
level: "info" | "warning" | "error";
message: string;
sessionKey?: string;
runId?: string;
};
export type WorkboardWorkerProtocol = {
state: "idle" | "running" | "completed" | "blocked" | "violated";
updatedAt: number;
detail?: string;
};
export type WorkboardDiagnostic = {
kind: string;
severity: WorkboardDiagnosticSeverity;
title: string;
detail: string;
firstSeenAt: number;
lastSeenAt: number;
count: number;
};
export type WorkboardNotification = {
id: string;
kind: string;
createdAt: number;
message: string;
sessionKey?: string;
runId?: string;
};
export type WorkboardWorkspace = {
kind: "scratch" | "dir" | "worktree";
path?: string;
branch?: string;
};
export type WorkboardAutomation = {
tenant?: string;
boardId?: string;
createdByCardId?: string;
idempotencyKey?: string;
skills?: string[];
workspace?: WorkboardWorkspace;
maxRuntimeSeconds?: number;
maxRetries?: number;
scheduledAt?: number;
summary?: string;
createdCardIds?: string[];
dispatchCount?: number;
lastDispatchAt?: number;
};
export type WorkboardMetadata = {
attempts?: WorkboardRunAttempt[];
comments?: WorkboardComment[];
links?: WorkboardLink[];
proof?: WorkboardProof[];
artifacts?: WorkboardArtifact[];
attachments?: WorkboardAttachment[];
workerLogs?: WorkboardWorkerLog[];
workerProtocol?: WorkboardWorkerProtocol;
automation?: WorkboardAutomation;
claim?: WorkboardClaim;
diagnostics?: WorkboardDiagnostic[];
notifications?: WorkboardNotification[];
templateId?: WorkboardTemplateId;
archivedAt?: number;
stale?: WorkboardStaleState;
lifecycleStatusSourceUpdatedAt?: number;
failureCount?: number;
};
export type WorkboardCard = {
id: string;
title: string;
notes?: string;
status: WorkboardStatus;
priority: WorkboardPriority;
labels: string[];
agentId?: string;
sessionKey?: string;
runId?: string;
taskId?: string;
sourceUrl?: string;
execution?: WorkboardExecution;
position: number;
createdAt: number;
updatedAt: number;
startedAt?: number;
completedAt?: number;
events?: WorkboardEvent[];
metadata?: WorkboardMetadata;
};
type WorkboardLifecycleState =
| "unlinked"
| "missing"
| "idle"
| "running"
| "stale"
| "succeeded"
| "failed";
export type WorkboardLifecycle = {
session: GatewaySessionRow | null;
state: WorkboardLifecycleState;
targetStatus?: WorkboardStatus;
sourceUpdatedAt?: number;
};
export type WorkboardTaskStatus =
| "queued"
| "running"
| "completed"
| "failed"
| "cancelled"
| "timed_out";
export type WorkboardTaskSummary = {
id: string;
taskId: string;
status: WorkboardTaskStatus;
title?: string;
agentId?: string;
sessionKey?: string;
childSessionKey?: string;
ownerKey?: string;
runId?: string;
sourceId?: string;
updatedAt?: number | string;
progressSummary?: string;
terminalSummary?: string;
error?: string;
};
type WorkboardDependencyParent = {
id: string;
title: string;
status?: WorkboardStatus;
done: boolean;
missing: boolean;
};
export type WorkboardDependencyState = {
parents: WorkboardDependencyParent[];
blockedParents: WorkboardDependencyParent[];
};
export type WorkboardDispatchSummary = {
started: number;
failures: number;
promoted: number;
blocked: number;
reclaimed: number;
orchestrated: number;
};
export type WorkboardAutoRefreshIntervalMs = 0 | 5000 | 15000 | 30000 | 60000;
export type WorkboardRefreshSource = "initial" | "manual" | "poll";
export type WorkboardViewPresetId =
| "all"
| "default_agent"
| "ready"
| "running"
| "blocked"
| "review"
| "stale"
| "missing_proof"
| "recently_done";
export type WorkboardHealthSummary = {
running: number;
blocked: number;
stale: number;
readyUnassigned: number;
missingProof: number;
failedAttempts: number;
};
export type WorkboardHealthKey = keyof WorkboardHealthSummary;
export type WorkboardUiState = {
loading: boolean;
loaded: boolean;
loadAttempted: boolean;
mutationReadiness: "ready" | "canonical_reload_required" | "stale_edit_draft";
error: string | null;
cards: WorkboardCard[];
statuses: readonly WorkboardStatus[];
tasksByCardId: Map<string, WorkboardTaskSummary>;
missingTaskIds: Set<string>;
lastDispatchSummary: WorkboardDispatchSummary | null;
dispatching: boolean;
query: string;
priorityFilter: "all" | WorkboardPriority;
agentFilter: string;
viewPreset: WorkboardViewPresetId;
activeHealthHighlight: WorkboardHealthKey | null;
showArchived: boolean;
layout: "comfortable" | "compact";
hideEmptyColumns: boolean;
autoRefreshIntervalMs: WorkboardAutoRefreshIntervalMs;
lastRefreshAt: number | null;
lastRefreshStartedAt: number | null;
lastRefreshError: string | null;
lastRefreshSource: WorkboardRefreshSource | null;
pollRefreshInProgress: boolean;
lifecycleTasksPrepared: boolean;
lifecycleTasksPreparedAt: number | null;
lifecycleTaskRefreshFailed: boolean;
lifecycleTaskRefreshRetryAt: number | null;
lifecycleTaskRefreshContinueAt: number | null;
lifecycleTaskRefreshError: string | null;
lifecycleConfirmedTaskIds: Set<string>;
lifecycleTaskConfirmationStartedAt: number | null;
draftOpen: boolean;
draftSaving: boolean;
editingCardId: string | null;
draftTitle: string;
draftNotes: string;
draftStatus: WorkboardStatus;
draftPriority: WorkboardPriority;
draftLabels: string;
draftAgentId: string;
draftSessionKey: string;
draftTemplateId: WorkboardTemplateId | "";
draftCommentBody: string;
detailCardId: string | null;
detailCommentBody: string;
busyCardIds: Set<string>;
draggedCardId: string | null;
syncingCardIds: Set<string>;
capturingSessionKeys: Set<string>;
};
export type WorkboardTaskLinkState = Pick<
WorkboardUiState,
"cards" | "tasksByCardId" | "missingTaskIds"
>;