refactor(workboard): share control ui contract (#102271)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Dallin Romney
2026-07-14 02:33:22 -07:00
committed by GitHub
parent 92cca9343e
commit 561cf56c53
44 changed files with 388 additions and 439 deletions
+7
View File
@@ -87,6 +87,13 @@ operator see how a card moved through the board without opening the linked
session; it is local operating context, not a replacement for session
transcripts or GitHub issue history.
The plugin and Control UI use one Workboard card contract. Dashboard refreshes
therefore preserve workspace provenance and authority, claim state, diagnostic
actions, and notification sequence numbers instead of projecting a smaller
UI-only copy of the card. Unknown diagnostic kinds, diagnostic severities, and
notification kinds are ignored until both surfaces support them; they are never
rewritten into another valid state.
Cards are stored in the plugin's own Gateway state and move with the rest of
that Gateway's OpenClaw state (see [Storage](#storage)).
@@ -278,6 +278,9 @@
"@openclaw/retry": [
"../dist/plugin-sdk/packages/retry/src/index.d.ts"
],
"@openclaw/workboard-contract": [
"../packages/workboard-contract/src/index.ts"
],
"@openclaw/acp-core": [
"../dist/plugin-sdk/packages/acp-core/src/index.d.ts"
],
+1
View File
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/workboard-contract": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
+1 -1
View File
@@ -7,4 +7,4 @@ export type {
WorkboardListResult,
WorkboardPriority,
WorkboardStatus,
} from "./src/types.js";
} from "@openclaw/workboard-contract";
+1 -1
View File
@@ -1,5 +1,5 @@
// Workboard plugin module implements card lookup behavior.
import type { WorkboardCard } from "./types.js";
import type { WorkboardCard } from "@openclaw/workboard-contract";
type WorkboardCardLookupResult =
| { card: WorkboardCard; error?: undefined }
+5 -1
View File
@@ -1,3 +1,8 @@
import {
WORKBOARD_STATUSES,
type WorkboardCard,
type WorkboardStatus,
} from "@openclaw/workboard-contract";
// Workboard plugin module implements cli behavior.
import type { Command } from "commander";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -7,7 +12,6 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js";
import type { WorkboardDispatchResult, WorkboardStore } from "./store.js";
import { WORKBOARD_STATUSES, type WorkboardCard, type WorkboardStatus } from "./types.js";
type JsonOptions = {
json?: boolean;
+5 -1
View File
@@ -1,3 +1,8 @@
import {
WORKBOARD_STATUSES,
type WorkboardCard,
type WorkboardStatus,
} from "@openclaw/workboard-contract";
// Workboard plugin module implements command behavior.
import type { OpenClawPluginApi } from "../api.js";
import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js";
@@ -7,7 +12,6 @@ import {
type WorkboardWorktreeRuntime,
} from "./dispatcher.js";
import type { WorkboardStore } from "./store.js";
import { WORKBOARD_STATUSES, type WorkboardCard, type WorkboardStatus } from "./types.js";
import {
canonicalizeWorkboardWorkspaceAccess,
resolveAgentWorkboardWorkspaceRuntime,
@@ -1,8 +1,8 @@
import type { WorkboardCard } from "@openclaw/workboard-contract";
// Workboard dispatch workspace helpers keep authority resolution outside the orchestration loop.
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { canonicalPathFromExistingAncestor } from "openclaw/plugin-sdk/security-runtime";
import type { WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
import {
assertCanonicalWorkboardRootAccess,
canonicalizeWorkboardWorkspaceAccess,
+5 -1
View File
@@ -1,5 +1,10 @@
// Workboard plugin module implements dispatcher behavior.
import path from "node:path";
import type {
WorkboardCard,
WorkboardExecution,
WorkboardWorkspace,
} from "@openclaw/workboard-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { canonicalPathFromExistingAncestor } from "openclaw/plugin-sdk/security-runtime";
@@ -10,7 +15,6 @@ import {
type ResolveAgentWorkspaceRuntime,
} from "./dispatcher-workspace.js";
import { WorkboardStore, type WorkboardDispatchResult } from "./store.js";
import type { WorkboardCard, WorkboardExecution, WorkboardWorkspace } from "./types.js";
import {
assertCanonicalWorkboardRootAccess,
assertWorkboardWorkspaceSourceAccess,
+1 -1
View File
@@ -1,10 +1,10 @@
import type { WorkboardCard } from "@openclaw/workboard-contract";
// Workboard plugin module implements shared gateway request helpers.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import type { OpenClawPluginApi } from "../api.js";
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
import type { WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
import {
resolveAgentWorkboardWorkspaceRuntime,
resolveConfiguredWorkboardWorkspaceAccess,
@@ -1,3 +1,4 @@
import type { WorkboardCard } from "@openclaw/workboard-contract";
// Workboard Gateway methods that can persist workspace-bearing card metadata.
import type { OpenClawPluginApi } from "../api.js";
import {
@@ -8,7 +9,6 @@ import {
type GatewayMethodContext,
} from "./gateway-helpers.js";
import type { WorkboardStore } from "./store.js";
import type { WorkboardCard } from "./types.js";
import {
assertWorkboardWorkspaceMutationAccess,
canonicalizeWorkboardWorkspaceAccess,
+1 -1
View File
@@ -1,3 +1,4 @@
import { WORKBOARD_STATUSES, type WorkboardCard } from "@openclaw/workboard-contract";
// Workboard plugin module implements gateway behavior.
import type { OpenClawPluginApi } from "../api.js";
import {
@@ -13,7 +14,6 @@ import {
registerWorkboardWorkspaceWorkflowMethods,
} from "./gateway-workspace-methods.js";
import { WorkboardStore } from "./store.js";
import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js";
const READ_SCOPE = "operator.read" as const;
const WRITE_SCOPE = "operator.write" as const;
@@ -4,7 +4,7 @@ import type {
WorkboardBoardMetadata,
WorkboardCard,
WorkboardNotificationSubscription,
} from "./types.js";
} from "@openclaw/workboard-contract";
export type PersistedWorkboardCard = {
version: 1;
+10 -10
View File
@@ -2,15 +2,6 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
import type {
WorkboardArtifact,
WorkboardAttachment,
@@ -25,7 +16,16 @@ import type {
WorkboardProof,
WorkboardRunAttempt,
WorkboardWorkerLog,
} from "./types.js";
} from "@openclaw/workboard-contract";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
const SCHEMA_VERSION = 2;
+1 -1
View File
@@ -1,7 +1,7 @@
import type { WorkboardAutomation, WorkboardWorkspaceAccess } from "@openclaw/workboard-contract";
// Workboard automation helpers normalize trusted host-issued workspace provenance.
import type { WorkboardLinkedCreateInput } from "./store-inputs.js";
import { normalizeAutomation, normalizeBoundedString } from "./store-normalizers.js";
import type { WorkboardAutomation, WorkboardWorkspaceAccess } from "./types.js";
import { isAbsoluteWorkspacePath } from "./workspace-path.js";
function normalizeTrustedWorkspaceAccess(
+15 -15
View File
@@ -1,4 +1,19 @@
import { randomUUID } from "node:crypto";
import {
WORKBOARD_STATUSES,
type WorkboardAttemptStatus,
type WorkboardCard,
type WorkboardDiagnostic,
type WorkboardDiagnosticAction,
type WorkboardDiagnosticKind,
type WorkboardDiagnosticSeverity,
type WorkboardEvent,
type WorkboardExecution,
type WorkboardMetadata,
type WorkboardNotification,
type WorkboardRunAttempt,
type WorkboardStatus,
} from "@openclaw/workboard-contract";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
@@ -16,21 +31,6 @@ import {
normalizeTimestamp,
removeUndefinedMetadataFields,
} from "./store-normalizers.js";
import {
WORKBOARD_STATUSES,
type WorkboardAttemptStatus,
type WorkboardCard,
type WorkboardDiagnostic,
type WorkboardDiagnosticAction,
type WorkboardDiagnosticKind,
type WorkboardDiagnosticSeverity,
type WorkboardEvent,
type WorkboardExecution,
type WorkboardMetadata,
type WorkboardNotification,
type WorkboardRunAttempt,
type WorkboardStatus,
} from "./types.js";
export function compareCards(left: WorkboardCard, right: WorkboardCard): number {
if (left.status !== right.status) {
+7 -7
View File
@@ -1,4 +1,11 @@
import { randomUUID } from "node:crypto";
import type {
WorkboardBoardMetadata,
WorkboardCard,
WorkboardLink,
WorkboardMetadata,
WorkboardStatus,
} from "@openclaw/workboard-contract";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
@@ -57,13 +64,6 @@ import {
syncExecutionSessionKey,
trimMetadataToBudget,
} from "./store-normalizers.js";
import type {
WorkboardBoardMetadata,
WorkboardCard,
WorkboardLink,
WorkboardMetadata,
WorkboardStatus,
} from "./types.js";
export class WorkboardCoreStore {
private mutationQueue: Promise<unknown> = Promise.resolve();
+6 -6
View File
@@ -1,4 +1,10 @@
import { randomUUID } from "node:crypto";
import type {
WorkboardAttachment,
WorkboardCard,
WorkboardNotification,
WorkboardWorkerLog,
} from "@openclaw/workboard-contract";
import type { PersistedWorkboardAttachment } from "./persistence-types.js";
import {
assertCanMutateClaimedCard,
@@ -30,12 +36,6 @@ import {
normalizeBoundedString,
normalizeProofInput,
} from "./store-normalizers.js";
import type {
WorkboardAttachment,
WorkboardCard,
WorkboardNotification,
WorkboardWorkerLog,
} from "./types.js";
export class WorkboardEnrichmentStore extends WorkboardCoreStore {
async addProof(
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
WorkboardStatus,
WorkboardWorkspace,
WorkboardWorkspaceAccess,
} from "./types.js";
} from "@openclaw/workboard-contract";
type WorkboardCardInput = {
title?: unknown;
+21 -21
View File
@@ -1,24 +1,4 @@
import { randomUUID } from "node:crypto";
import {
MAX_ATTACHMENT_BYTES,
MAX_CARD_ARTIFACTS,
MAX_CARD_ATTACHMENTS,
MAX_CARD_ATTEMPTS,
MAX_CARD_COMMENTS,
MAX_CARD_DIAGNOSTICS,
MAX_CARD_EVENTS,
MAX_CARD_LINKS,
MAX_CARD_METADATA_BYTES,
MAX_CARD_NOTIFICATIONS,
MAX_CARD_PROOF,
MAX_CARD_WORKER_LOGS,
} from "./store-constants.js";
import type {
WorkboardAttachmentInput,
WorkboardBoardInput,
WorkboardNotificationSubscribeInput,
WorkboardProofInput,
} from "./store-inputs.js";
import {
WORKBOARD_ATTEMPT_STATUSES,
WORKBOARD_DIAGNOSTIC_KINDS,
@@ -66,7 +46,27 @@ import {
type WorkboardWorkerLog,
type WorkboardWorkerProtocol,
type WorkboardWorkspace,
} from "./types.js";
} from "@openclaw/workboard-contract";
import {
MAX_ATTACHMENT_BYTES,
MAX_CARD_ARTIFACTS,
MAX_CARD_ATTACHMENTS,
MAX_CARD_ATTEMPTS,
MAX_CARD_COMMENTS,
MAX_CARD_DIAGNOSTICS,
MAX_CARD_EVENTS,
MAX_CARD_LINKS,
MAX_CARD_METADATA_BYTES,
MAX_CARD_NOTIFICATIONS,
MAX_CARD_PROOF,
MAX_CARD_WORKER_LOGS,
} from "./store-constants.js";
import type {
WorkboardAttachmentInput,
WorkboardBoardInput,
WorkboardNotificationSubscribeInput,
WorkboardProofInput,
} from "./store-inputs.js";
import { isAbsoluteWorkspacePath } from "./workspace-path.js";
export function normalizeOptionalString(value: unknown): string | undefined {
@@ -1,3 +1,7 @@
import type {
WorkboardNotification,
WorkboardNotificationSubscription,
} from "@openclaw/workboard-contract";
import type { PersistedWorkboardNotificationSubscription } from "./persistence-types.js";
import {
cardRunId,
@@ -16,7 +20,6 @@ import {
normalizeNotificationSubscription,
} from "./store-normalizers.js";
import { WorkboardWorkflowStore } from "./store-workflow.js";
import type { WorkboardNotification, WorkboardNotificationSubscription } from "./types.js";
export class WorkboardNotificationStore extends WorkboardWorkflowStore {
async subscribeNotifications(
+1 -1
View File
@@ -1,10 +1,10 @@
import { randomUUID } from "node:crypto";
import type { WorkboardCard } from "@openclaw/workboard-contract";
import { assertCanMutateClaimedCard } from "./store-card-helpers.js";
import { MAX_CARD_COMMENTS } from "./store-constants.js";
import { WorkboardEnrichmentStore } from "./store-enrichment.js";
import type { WorkboardMutationScope, WorkboardPromoteInput } from "./store-inputs.js";
import { clearDiagnostics, normalizeBoundedString } from "./store-normalizers.js";
import type { WorkboardCard } from "./types.js";
export class WorkboardPromoteStore extends WorkboardEnrichmentStore {
async move(
+7 -7
View File
@@ -1,5 +1,12 @@
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import type {
WorkboardArtifact,
WorkboardCard,
WorkboardClaim,
WorkboardNotification,
WorkboardRunAttempt,
} from "@openclaw/workboard-contract";
import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import {
@@ -49,13 +56,6 @@ import {
removeUndefinedMetadataFields,
} from "./store-normalizers.js";
import { WorkboardPromoteStore } from "./store-promote.js";
import type {
WorkboardArtifact,
WorkboardCard,
WorkboardClaim,
WorkboardNotification,
WorkboardRunAttempt,
} from "./types.js";
function assertClaimIdentity(claim: WorkboardClaim, input: WorkboardHeartbeatInput): void {
const token = normalizeOptionalString(input.token);
+1 -1
View File
@@ -1,5 +1,6 @@
// Workboard plugin module implements store behavior.
import { randomUUID } from "node:crypto";
import type { WorkboardAttachment, WorkboardCard } from "@openclaw/workboard-contract";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
@@ -39,7 +40,6 @@ import {
trimMetadataToBudget,
} from "./store-normalizers.js";
import { WorkboardNotificationStore } from "./store-notifications.js";
import type { WorkboardAttachment, WorkboardCard } from "./types.js";
export type {
PersistedWorkboardAttachment,
@@ -1,9 +1,9 @@
import { WORKBOARD_STATUSES, type WorkboardCard } from "@openclaw/workboard-contract";
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import type { AgentToolResult } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
import type { WorkboardMutationScope } from "./store-inputs.js";
import type { WorkboardStore } from "./store.js";
import { WORKBOARD_STATUSES, type WorkboardCard } from "./types.js";
type ScopedMoveParams = {
record: Record<string, unknown>;
+1 -1
View File
@@ -1,3 +1,4 @@
import type { WorkboardCard } from "@openclaw/workboard-contract";
// Workboard plugin module implements tools behavior.
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/core";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
@@ -6,7 +7,6 @@ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { Type } from "typebox";
import { WorkboardStore } from "./store.js";
import { cardIdField, claimTokenField, createWorkboardMoveTool } from "./tools-card-mutations.js";
import type { WorkboardCard } from "./types.js";
function contextOwner(ctx: OpenClawPluginToolContext | undefined): string {
const record = (ctx ?? {}) as Record<string, unknown>;
+2 -2
View File
@@ -1,3 +1,4 @@
import type { WorkboardWorkspace, WorkboardWorkspaceAccess } from "@openclaw/workboard-contract";
// Workboard workspace access follows the caller's canonical filesystem boundary.
import {
listAgentIds,
@@ -14,9 +15,8 @@ import {
canonicalPathFromExistingAncestor,
isPathInside,
} from "openclaw/plugin-sdk/security-runtime";
import type { WorkboardWorkspace, WorkboardWorkspaceAccess } from "./types.js";
export type { WorkboardWorkspaceAccess } from "./types.js";
export type { WorkboardWorkspaceAccess } from "@openclaw/workboard-contract";
type WorkboardConfig = NonNullable<OpenClawPluginToolContext["config"]>;
type ResolveSandboxWorkspaceAuthority =
+3
View File
@@ -261,6 +261,9 @@
"@openclaw/retry": [
"../../dist/plugin-sdk/packages/retry/src/index.d.ts"
],
"@openclaw/workboard-contract": [
"../../packages/workboard-contract/src/index.ts"
],
"@openclaw/acp-core": [
"../../dist/plugin-sdk/packages/acp-core/src/index.d.ts"
],
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@openclaw/workboard-contract",
"version": "0.0.0-private",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/workboard-contract",
"version": "0.0.0-private"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@openclaw/workboard-contract",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
@@ -1,4 +1,4 @@
// Workboard type declarations define plugin contracts.
// Workboard contract declarations define the plugin and Control UI data model.
export const WORKBOARD_STATUSES = [
"triage",
"backlog",
@@ -184,7 +184,7 @@ export type WorkboardWorkerProtocol = {
detail?: string;
};
type WorkboardStaleState = {
export type WorkboardStaleState = {
detectedAt: number;
lastSessionUpdatedAt?: number;
reason: string;
+8
View File
@@ -1896,6 +1896,9 @@ importers:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
'@openclaw/workboard-contract':
specifier: workspace:*
version: link:../../packages/workboard-contract
openclaw:
specifier: workspace:*
version: link:../..
@@ -2132,6 +2135,8 @@ importers:
packages/web-content-core: {}
packages/workboard-contract: {}
ui:
dependencies:
'@awesome.me/webawesome':
@@ -2185,6 +2190,9 @@ importers:
'@openclaw/uirouter':
specifier: 0.1.0
version: 0.1.0
'@openclaw/workboard-contract':
specifier: workspace:*
version: link:../packages/workboard-contract
dompurify:
specifier: 3.4.11
version: 3.4.11
@@ -189,6 +189,7 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
],
"@openclaw/normalization-core/*": ["../dist/plugin-sdk/packages/normalization-core/src/*.d.ts"],
"@openclaw/retry": ["../dist/plugin-sdk/packages/retry/src/index.d.ts"],
"@openclaw/workboard-contract": ["../packages/workboard-contract/src/index.ts"],
...buildPackageBoundaryDtsPaths({
packageName: "@openclaw/acp-core",
packageDir: "acp-core",
+1
View File
@@ -484,6 +484,7 @@ export const sharedVitestConfig = {
sourcePackageAlias("media-core", "read-byte-stream-with-limit"),
sourcePackageAlias("media-core"),
sourcePackageAlias("retry"),
sourcePackageAlias("workboard-contract"),
...sourcePackageAliasesFromExports("acp-core", acpCorePackageJson.exports),
...sourcePluginSdkSubpaths.map((subpath) => ({
find: `openclaw/plugin-sdk/${subpath}`,
+1
View File
@@ -163,6 +163,7 @@
],
"@openclaw/normalization-core/*": ["./packages/normalization-core/src/*"],
"@openclaw/retry": ["./packages/retry/src/index.ts"],
"@openclaw/workboard-contract": ["./packages/workboard-contract/src/index.ts"],
"@openclaw/acp-core": ["./packages/acp-core/src/index.ts"],
"@openclaw/acp-core/meta": ["./packages/acp-core/src/meta.ts"],
"@openclaw/acp-core/numeric-options": ["./packages/acp-core/src/numeric-options.ts"],
+1 -1
View File
@@ -228,7 +228,7 @@ function shouldAlwaysBundleDependency(id: string): boolean {
id === "@openclaw/retry" ||
id === "@openclaw/media-core" ||
id.startsWith("@openclaw/media-core/") ||
id === "@openclaw/acp-core" ||
["@openclaw/acp-core", "@openclaw/workboard-contract"].includes(id) ||
id.startsWith("@openclaw/acp-core/") ||
id === "zod" ||
id.startsWith("zod/")
+1
View File
@@ -25,6 +25,7 @@
"@openclaw/libterminal": "0.3.1",
"@openclaw/media-core": "workspace:*",
"@openclaw/normalization-core": "workspace:*",
"@openclaw/workboard-contract": "workspace:*",
"@openclaw/uirouter": "0.1.0",
"dompurify": "3.4.11",
"ghostty-web": "0.4.0",
+74 -8
View File
@@ -2782,7 +2782,7 @@ describe("workboard controller", () => {
expect(state.tasksByCardId.has("card-1")).toBe(false);
});
it("preserves automation metadata loaded from the plugin gateway method", async () => {
it("preserves contract-owned metadata loaded from the plugin gateway method", async () => {
const host = {};
const client = createClient({
"workboard.cards.list": {
@@ -2793,10 +2793,60 @@ describe("workboard controller", () => {
automation: {
tenant: "qa",
skills: ["testing"],
workspace: { kind: "scratch" },
workspace: {
kind: "worktree",
path: "/tmp/worktree",
branch: "work/card-1",
sourcePath: "/repo",
sourceBranch: "main",
},
workspaceAccess: {
unrestricted: false,
roots: ["/repo"],
writable: true,
},
dispatchCount: 2,
lastDispatchAt: 20,
},
claim: {
ownerId: "agent:main",
token: "[redacted]",
claimedAt: 10,
lastHeartbeatAt: 11,
},
diagnostics: [
{
kind: "missing_proof",
severity: "warning",
title: "Proof missing",
detail: "Attach focused validation.",
firstSeenAt: 12,
lastSeenAt: 13,
count: 1,
actions: [{ kind: "add_proof", label: "Add proof" }],
},
{ kind: "future_kind", title: "Invalid contract value" },
{
kind: "missing_proof",
severity: "future_severity",
title: "Invalid severity value",
},
],
notifications: [
{
id: "notification-1",
kind: "completed",
createdAt: 14,
sequence: 3,
message: "Card completed",
},
{
id: "notification-2",
kind: "future_kind",
createdAt: 15,
message: "Invalid contract value",
},
],
},
},
],
@@ -2806,13 +2856,29 @@ describe("workboard controller", () => {
await loadWorkboard({ host, client: client as never, force: true });
expect(getWorkboardState(host).cards[0]?.metadata?.automation).toMatchObject({
tenant: "qa",
skills: ["testing"],
workspace: { kind: "scratch" },
dispatchCount: 2,
lastDispatchAt: 20,
expect(getWorkboardState(host).cards[0]?.metadata).toMatchObject({
automation: {
tenant: "qa",
skills: ["testing"],
workspace: {
kind: "worktree",
sourcePath: "/repo",
sourceBranch: "main",
},
workspaceAccess: {
unrestricted: false,
roots: ["/repo"],
writable: true,
},
dispatchCount: 2,
lastDispatchAt: 20,
},
claim: { token: "[redacted]" },
diagnostics: [{ actions: [{ kind: "add_proof", label: "Add proof" }] }],
notifications: [{ sequence: 3 }],
});
expect(getWorkboardState(host).cards[0]?.metadata?.diagnostics).toHaveLength(1);
expect(getWorkboardState(host).cards[0]?.metadata?.notifications).toHaveLength(1);
});
it("updates cards from draft state when editing", async () => {
@@ -0,0 +1,95 @@
import { isRecord } from "./normalization-utils.ts";
import type {
WorkboardAutomation,
WorkboardDiagnosticAction,
WorkboardWorkspace,
WorkboardWorkspaceAccess,
} from "./types.ts";
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
: [];
}
function normalizeWorkspaceAccess(value: unknown): WorkboardWorkspaceAccess | undefined {
if (!isRecord(value) || typeof value.unrestricted !== "boolean") {
return undefined;
}
if (value.unrestricted) {
return { unrestricted: true };
}
if (!Array.isArray(value.roots) || typeof value.writable !== "boolean") {
return undefined;
}
return {
unrestricted: false,
roots: value.roots.filter((root): root is string => typeof root === "string"),
writable: value.writable,
};
}
export 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 } : {}),
...(typeof value.workspace.sourcePath === "string"
? { sourcePath: value.workspace.sourcePath }
: {}),
...(typeof value.workspace.sourceBranch === "string"
? { sourceBranch: value.workspace.sourceBranch }
: {}),
}
: undefined;
const workspaceAccess = normalizeWorkspaceAccess(value.workspaceAccess);
const skills = normalizeStringArray(value.skills);
const createdCardIds = normalizeStringArray(value.createdCardIds);
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 } : {}),
...(skills.length ? { skills } : {}),
...(workspace?.kind ? { workspace: workspace as WorkboardWorkspace } : {}),
...(workspaceAccess ? { workspaceAccess } : {}),
...(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 } : {}),
...(createdCardIds.length ? { createdCardIds } : {}),
...(typeof value.dispatchCount === "number" ? { dispatchCount: value.dispatchCount } : {}),
...(typeof value.lastDispatchAt === "number" ? { lastDispatchAt: value.lastDispatchAt } : {}),
};
return Object.keys(automation).length ? automation : undefined;
}
export function normalizeDiagnosticAction(value: unknown): WorkboardDiagnosticAction | null {
if (
!isRecord(value) ||
(value.kind !== "claim" &&
value.kind !== "unblock" &&
value.kind !== "promote" &&
value.kind !== "reclaim" &&
value.kind !== "reassign" &&
value.kind !== "add_proof" &&
value.kind !== "open_session") ||
typeof value.label !== "string"
) {
return null;
}
return { kind: value.kind, label: value.label };
}
+45 -70
View File
@@ -1,21 +1,29 @@
import {
normalizeAutomation,
normalizeDiagnosticAction,
} from "./metadata-contract-normalization.ts";
import { isRecord } from "./normalization-utils.ts";
import {
WORKBOARD_ATTEMPT_STATUSES,
WORKBOARD_DIAGNOSTIC_KINDS,
WORKBOARD_DIAGNOSTIC_SEVERITIES,
WORKBOARD_EVENT_KINDS,
WORKBOARD_EXECUTION_ENGINES,
WORKBOARD_EXECUTION_MODES,
WORKBOARD_EXECUTION_STATUSES,
WORKBOARD_LINK_TYPES,
WORKBOARD_NOTIFICATION_KINDS,
WORKBOARD_PROOF_STATUSES,
WORKBOARD_STATUSES,
WORKBOARD_TEMPLATE_IDS,
type WorkboardArtifact,
type WorkboardAttachment,
type WorkboardAttemptStatus,
type WorkboardAutomation,
type WorkboardClaim,
type WorkboardComment,
type WorkboardDiagnostic,
type WorkboardDiagnosticAction,
type WorkboardDiagnosticKind,
type WorkboardDiagnosticSeverity,
type WorkboardEvent,
type WorkboardEventKind,
@@ -27,6 +35,7 @@ import {
type WorkboardLinkType,
type WorkboardMetadata,
type WorkboardNotification,
type WorkboardNotificationKind,
type WorkboardProof,
type WorkboardProofStatus,
type WorkboardRunAttempt,
@@ -34,7 +43,6 @@ import {
type WorkboardTemplateId,
type WorkboardWorkerLog,
type WorkboardWorkerProtocol,
type WorkboardWorkspace,
} from "./types.ts";
export function normalizeExecution(value: unknown): WorkboardExecution | undefined {
@@ -106,12 +114,6 @@ export function normalizeEvents(value: unknown): WorkboardEvent[] {
: [];
}
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 {
@@ -124,48 +126,6 @@ function normalizeWorkerProtocolState(
: 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;
@@ -354,34 +314,48 @@ export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined
: {}),
}
: 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 claim: WorkboardClaim | undefined =
isRecord(value.claim) &&
typeof value.claim.ownerId === "string" &&
typeof value.claim.token === "string" &&
typeof value.claim.claimedAt === "number" &&
typeof value.claim.lastHeartbeatAt === "number"
? {
ownerId: value.claim.ownerId,
token: value.claim.token,
claimedAt: value.claim.claimedAt,
lastHeartbeatAt: value.claim.lastHeartbeatAt,
...(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") {
if (
!isRecord(entry) ||
!WORKBOARD_DIAGNOSTIC_KINDS.includes(entry.kind as WorkboardDiagnosticKind) ||
!WORKBOARD_DIAGNOSTIC_SEVERITIES.includes(
entry.severity as WorkboardDiagnosticSeverity,
) ||
typeof entry.title !== "string"
) {
return [];
}
return [
{
kind: entry.kind,
severity: WORKBOARD_DIAGNOSTIC_SEVERITIES.includes(
entry.severity as WorkboardDiagnosticSeverity,
)
? (entry.severity as WorkboardDiagnosticSeverity)
: "warning",
kind: entry.kind as WorkboardDiagnosticKind,
severity: entry.severity as WorkboardDiagnosticSeverity,
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,
actions: Array.isArray(entry.actions)
? entry.actions
.map(normalizeDiagnosticAction)
.filter((action): action is WorkboardDiagnosticAction => action !== null)
: [],
},
];
})
@@ -391,7 +365,7 @@ export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined
if (
!isRecord(entry) ||
typeof entry.id !== "string" ||
typeof entry.kind !== "string" ||
!WORKBOARD_NOTIFICATION_KINDS.includes(entry.kind as WorkboardNotificationKind) ||
typeof entry.message !== "string" ||
typeof entry.createdAt !== "number"
) {
@@ -400,9 +374,10 @@ export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined
return [
{
id: entry.id,
kind: entry.kind,
kind: entry.kind as WorkboardNotificationKind,
message: entry.message,
createdAt: entry.createdAt,
...(typeof entry.sequence === "number" ? { sequence: entry.sequence } : {}),
...(typeof entry.sessionKey === "string" ? { sessionKey: entry.sessionKey } : {}),
...(typeof entry.runId === "string" ? { runId: entry.runId } : {}),
},
@@ -438,7 +413,7 @@ export function normalizeMetadata(value: unknown): WorkboardMetadata | undefined
...(workerLogs.length ? { workerLogs } : {}),
...(workerProtocol ? { workerProtocol } : {}),
...(automation ? { automation } : {}),
...(claim?.ownerId && claim.claimedAt ? { claim } : {}),
...(claim ? { claim } : {}),
...(diagnostics.length ? { diagnostics } : {}),
...(notifications.length ? { notifications } : {}),
...(WORKBOARD_TEMPLATE_IDS.includes(value.templateId as WorkboardTemplateId)
+7 -270
View File
@@ -1,275 +1,12 @@
import type {
WorkboardCard,
WorkboardPriority,
WorkboardStatus,
WorkboardTemplateId,
} from "@openclaw/workboard-contract";
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;
};
export * from "@openclaw/workboard-contract";
type WorkboardLifecycleState =
| "unlinked"
+12 -3
View File
@@ -535,16 +535,22 @@ describe("renderWorkboard", () => {
updatedAt: 1,
metadata: {
attempts: [{ id: "attempt-1", status: "failed", startedAt: 1 }],
claim: { ownerId: "agent-1", claimedAt: 1, lastHeartbeatAt: Date.now() },
claim: {
ownerId: "agent-1",
token: "[redacted]",
claimedAt: 1,
lastHeartbeatAt: Date.now(),
},
diagnostics: [
{
kind: "protocol_violation",
kind: "orphaned_session",
severity: "warning",
title: "Old diagnostic",
detail: "Older detail.",
firstSeenAt: 1,
lastSeenAt: 1,
count: 1,
actions: [],
},
{
kind: "repeated_failures",
@@ -554,6 +560,7 @@ describe("renderWorkboard", () => {
firstSeenAt: 1,
lastSeenAt: 2,
count: 1,
actions: [],
},
],
notifications: [{ id: "note-1", kind: "failed", createdAt: 1, message: "Needs proof." }],
@@ -600,13 +607,14 @@ describe("renderWorkboard", () => {
metadata: {
diagnostics: [
{
kind: "protocol_violation",
kind: "orphaned_session",
severity: "warning",
title: `${"x".repeat(62)}🚀tail`,
detail: "Boundary detail.",
firstSeenAt: 1,
lastSeenAt: 1,
count: 1,
actions: [],
},
],
},
@@ -651,6 +659,7 @@ describe("renderWorkboard", () => {
metadata: {
claim: {
ownerId: "agent-1",
token: "[redacted]",
claimedAt: 1,
lastHeartbeatAt: Date.now() - 42_000,
},
+1
View File
@@ -282,6 +282,7 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
sourcePackageAlias("normalization-core", "string-normalization"),
sourcePackageAlias("normalization-core", "utf16-slice"),
sourcePackageAlias("normalization-core"),
sourcePackageAlias("workboard-contract"),
];
}
+4
View File
@@ -58,6 +58,10 @@ const workspaceSourceAliases = [
find: "@openclaw/media-core",
replacement: path.resolve(repoRoot, "packages/media-core/src/index.ts"),
},
{
find: "@openclaw/workboard-contract",
replacement: path.resolve(repoRoot, "packages/workboard-contract/src/index.ts"),
},
{
find: /^@openclaw\/net-policy\/(.+)$/u,
replacement: path.resolve(repoRoot, "packages/net-policy/src/$1"),