From 561cf56c535287a8c7d12bb89b657ea891a4b98b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 02:33:22 -0700 Subject: [PATCH] refactor(workboard): share control ui contract (#102271) Co-authored-by: Peter Steinberger --- docs/plugins/workboard.md | 7 + .../tsconfig.package-boundary.paths.json | 3 + extensions/workboard/package.json | 1 + extensions/workboard/runtime-api.ts | 2 +- extensions/workboard/src/card-lookup.ts | 2 +- extensions/workboard/src/cli.ts | 6 +- extensions/workboard/src/command.ts | 6 +- .../workboard/src/dispatcher-workspace.ts | 2 +- extensions/workboard/src/dispatcher.ts | 6 +- extensions/workboard/src/gateway-helpers.ts | 2 +- .../src/gateway-workspace-methods.ts | 2 +- extensions/workboard/src/gateway.ts | 2 +- extensions/workboard/src/persistence-types.ts | 2 +- extensions/workboard/src/sqlite-store.ts | 20 +- extensions/workboard/src/store-automation.ts | 2 +- .../workboard/src/store-card-helpers.ts | 30 +- extensions/workboard/src/store-core.ts | 14 +- extensions/workboard/src/store-enrichment.ts | 12 +- extensions/workboard/src/store-inputs.ts | 2 +- extensions/workboard/src/store-normalizers.ts | 42 +-- .../workboard/src/store-notifications.ts | 5 +- extensions/workboard/src/store-promote.ts | 2 +- extensions/workboard/src/store-workflow.ts | 14 +- extensions/workboard/src/store.ts | 2 +- .../workboard/src/tools-card-mutations.ts | 2 +- extensions/workboard/src/tools.ts | 2 +- extensions/workboard/src/workspace-access.ts | 4 +- extensions/xai/tsconfig.json | 3 + .../workboard-contract/npm-shrinkwrap.json | 12 + packages/workboard-contract/package.json | 9 + .../workboard-contract/src/index.ts | 4 +- pnpm-lock.yaml | 8 + scripts/lib/extension-package-boundary.ts | 1 + test/vitest/vitest.shared.config.ts | 1 + tsconfig.json | 1 + tsdown.config.ts | 2 +- ui/package.json | 1 + ui/src/lib/workboard/index.test.ts | 82 +++++- .../metadata-contract-normalization.ts | 95 ++++++ .../lib/workboard/metadata-normalization.ts | 115 +++----- ui/src/lib/workboard/types.ts | 277 +----------------- ui/src/pages/workboard/view.test.ts | 15 +- ui/vite.config.ts | 1 + ui/vitest.config.ts | 4 + 44 files changed, 388 insertions(+), 439 deletions(-) create mode 100644 packages/workboard-contract/npm-shrinkwrap.json create mode 100644 packages/workboard-contract/package.json rename extensions/workboard/src/types.ts => packages/workboard-contract/src/index.ts (98%) create mode 100644 ui/src/lib/workboard/metadata-contract-normalization.ts diff --git a/docs/plugins/workboard.md b/docs/plugins/workboard.md index 8dff979d309b..4edc211851df 100644 --- a/docs/plugins/workboard.md +++ b/docs/plugins/workboard.md @@ -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)). diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 02c4865a6c21..21fbe7d9cbce 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -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" ], diff --git a/extensions/workboard/package.json b/extensions/workboard/package.json index 6d6677204d7d..83afc1daa994 100644 --- a/extensions/workboard/package.json +++ b/extensions/workboard/package.json @@ -9,6 +9,7 @@ }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*", + "@openclaw/workboard-contract": "workspace:*", "openclaw": "workspace:*" }, "peerDependencies": { diff --git a/extensions/workboard/runtime-api.ts b/extensions/workboard/runtime-api.ts index 43e3c85ac220..d9257c7ed07f 100644 --- a/extensions/workboard/runtime-api.ts +++ b/extensions/workboard/runtime-api.ts @@ -7,4 +7,4 @@ export type { WorkboardListResult, WorkboardPriority, WorkboardStatus, -} from "./src/types.js"; +} from "@openclaw/workboard-contract"; diff --git a/extensions/workboard/src/card-lookup.ts b/extensions/workboard/src/card-lookup.ts index 3808c01037eb..d43e957c101e 100644 --- a/extensions/workboard/src/card-lookup.ts +++ b/extensions/workboard/src/card-lookup.ts @@ -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 } diff --git a/extensions/workboard/src/cli.ts b/extensions/workboard/src/cli.ts index 777dd2588929..c9cdfa23d102 100644 --- a/extensions/workboard/src/cli.ts +++ b/extensions/workboard/src/cli.ts @@ -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; diff --git a/extensions/workboard/src/command.ts b/extensions/workboard/src/command.ts index ea27d8841451..9c199f740cbe 100644 --- a/extensions/workboard/src/command.ts +++ b/extensions/workboard/src/command.ts @@ -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, diff --git a/extensions/workboard/src/dispatcher-workspace.ts b/extensions/workboard/src/dispatcher-workspace.ts index f86cb03d4130..b30b24f52cb5 100644 --- a/extensions/workboard/src/dispatcher-workspace.ts +++ b/extensions/workboard/src/dispatcher-workspace.ts @@ -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, diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 3f1899f954f8..414188b94c46 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -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, diff --git a/extensions/workboard/src/gateway-helpers.ts b/extensions/workboard/src/gateway-helpers.ts index bd92c4f051b8..8a388dda1d92 100644 --- a/extensions/workboard/src/gateway-helpers.ts +++ b/extensions/workboard/src/gateway-helpers.ts @@ -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, diff --git a/extensions/workboard/src/gateway-workspace-methods.ts b/extensions/workboard/src/gateway-workspace-methods.ts index aaa0242caf2e..d57667b6802e 100644 --- a/extensions/workboard/src/gateway-workspace-methods.ts +++ b/extensions/workboard/src/gateway-workspace-methods.ts @@ -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, diff --git a/extensions/workboard/src/gateway.ts b/extensions/workboard/src/gateway.ts index ff5a28b7f2a2..c4611a45c30d 100644 --- a/extensions/workboard/src/gateway.ts +++ b/extensions/workboard/src/gateway.ts @@ -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; diff --git a/extensions/workboard/src/persistence-types.ts b/extensions/workboard/src/persistence-types.ts index 98191e38ef58..b561e5f24dfc 100644 --- a/extensions/workboard/src/persistence-types.ts +++ b/extensions/workboard/src/persistence-types.ts @@ -4,7 +4,7 @@ import type { WorkboardBoardMetadata, WorkboardCard, WorkboardNotificationSubscription, -} from "./types.js"; +} from "@openclaw/workboard-contract"; export type PersistedWorkboardCard = { version: 1; diff --git a/extensions/workboard/src/sqlite-store.ts b/extensions/workboard/src/sqlite-store.ts index bbf252700de1..17f2db49964c 100644 --- a/extensions/workboard/src/sqlite-store.ts +++ b/extensions/workboard/src/sqlite-store.ts @@ -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; diff --git a/extensions/workboard/src/store-automation.ts b/extensions/workboard/src/store-automation.ts index 50aef89abd81..a03787897bd9 100644 --- a/extensions/workboard/src/store-automation.ts +++ b/extensions/workboard/src/store-automation.ts @@ -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( diff --git a/extensions/workboard/src/store-card-helpers.ts b/extensions/workboard/src/store-card-helpers.ts index 59b87bf87db8..6ef9509f6451 100644 --- a/extensions/workboard/src/store-card-helpers.ts +++ b/extensions/workboard/src/store-card-helpers.ts @@ -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) { diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index 14972dae7d4e..20f5cf1dd4ec 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -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 = Promise.resolve(); diff --git a/extensions/workboard/src/store-enrichment.ts b/extensions/workboard/src/store-enrichment.ts index fc53af9680fa..39b38df30d2c 100644 --- a/extensions/workboard/src/store-enrichment.ts +++ b/extensions/workboard/src/store-enrichment.ts @@ -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( diff --git a/extensions/workboard/src/store-inputs.ts b/extensions/workboard/src/store-inputs.ts index 2731d1915e5b..d718914a8de9 100644 --- a/extensions/workboard/src/store-inputs.ts +++ b/extensions/workboard/src/store-inputs.ts @@ -5,7 +5,7 @@ import type { WorkboardStatus, WorkboardWorkspace, WorkboardWorkspaceAccess, -} from "./types.js"; +} from "@openclaw/workboard-contract"; type WorkboardCardInput = { title?: unknown; diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 09b4189ae24c..d61a904a5dd0 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -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 { diff --git a/extensions/workboard/src/store-notifications.ts b/extensions/workboard/src/store-notifications.ts index 985b115e315a..508470902c98 100644 --- a/extensions/workboard/src/store-notifications.ts +++ b/extensions/workboard/src/store-notifications.ts @@ -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( diff --git a/extensions/workboard/src/store-promote.ts b/extensions/workboard/src/store-promote.ts index 0098c984c607..3a76ac2c3bb8 100644 --- a/extensions/workboard/src/store-promote.ts +++ b/extensions/workboard/src/store-promote.ts @@ -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( diff --git a/extensions/workboard/src/store-workflow.ts b/extensions/workboard/src/store-workflow.ts index 5dd4dd8711b4..01b3092459a6 100644 --- a/extensions/workboard/src/store-workflow.ts +++ b/extensions/workboard/src/store-workflow.ts @@ -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); diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts index 28221c2abe40..0ca268dad166 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -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, diff --git a/extensions/workboard/src/tools-card-mutations.ts b/extensions/workboard/src/tools-card-mutations.ts index b4ec9d42c00c..4dc53a732360 100644 --- a/extensions/workboard/src/tools-card-mutations.ts +++ b/extensions/workboard/src/tools-card-mutations.ts @@ -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; diff --git a/extensions/workboard/src/tools.ts b/extensions/workboard/src/tools.ts index 015eecc10a6d..ab5320e74078 100644 --- a/extensions/workboard/src/tools.ts +++ b/extensions/workboard/src/tools.ts @@ -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; diff --git a/extensions/workboard/src/workspace-access.ts b/extensions/workboard/src/workspace-access.ts index 10b88157926a..6f014fc48659 100644 --- a/extensions/workboard/src/workspace-access.ts +++ b/extensions/workboard/src/workspace-access.ts @@ -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; type ResolveSandboxWorkspaceAuthority = diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index cb71f3123b6d..7fbe35e8ff45 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -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" ], diff --git a/packages/workboard-contract/npm-shrinkwrap.json b/packages/workboard-contract/npm-shrinkwrap.json new file mode 100644 index 000000000000..724177bb5553 --- /dev/null +++ b/packages/workboard-contract/npm-shrinkwrap.json @@ -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" + } + } +} diff --git a/packages/workboard-contract/package.json b/packages/workboard-contract/package.json new file mode 100644 index 000000000000..f46a8bf536ac --- /dev/null +++ b/packages/workboard-contract/package.json @@ -0,0 +1,9 @@ +{ + "name": "@openclaw/workboard-contract", + "version": "0.0.0-private", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/extensions/workboard/src/types.ts b/packages/workboard-contract/src/index.ts similarity index 98% rename from extensions/workboard/src/types.ts rename to packages/workboard-contract/src/index.ts index 726ba2faea91..2e2ab7fd5b8b 100644 --- a/extensions/workboard/src/types.ts +++ b/packages/workboard-contract/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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a9a35cdb047..2c6f01ab1660 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index 3bfb70a5d276..57fcec804bb5 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -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", diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 515eb925c7da..fc1019cb3a2f 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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}`, diff --git a/tsconfig.json b/tsconfig.json index 6ce69154a170..3ae4507e03a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"], diff --git a/tsdown.config.ts b/tsdown.config.ts index e0cff8964696..04e384ec39de 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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/") diff --git a/ui/package.json b/ui/package.json index 45aee650ff4a..0fd684d72efb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/lib/workboard/index.test.ts b/ui/src/lib/workboard/index.test.ts index 82755e423638..549d5e62e01b 100644 --- a/ui/src/lib/workboard/index.test.ts +++ b/ui/src/lib/workboard/index.test.ts @@ -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 () => { diff --git a/ui/src/lib/workboard/metadata-contract-normalization.ts b/ui/src/lib/workboard/metadata-contract-normalization.ts new file mode 100644 index 000000000000..60bc941add36 --- /dev/null +++ b/ui/src/lib/workboard/metadata-contract-normalization.ts @@ -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 }; +} diff --git a/ui/src/lib/workboard/metadata-normalization.ts b/ui/src/lib/workboard/metadata-normalization.ts index 6c4abd610b4d..17a7018be9ed 100644 --- a/ui/src/lib/workboard/metadata-normalization.ts +++ b/ui/src/lib/workboard/metadata-normalization.ts @@ -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) diff --git a/ui/src/lib/workboard/types.ts b/ui/src/lib/workboard/types.ts index df20f17f6117..76cd44aa1907 100644 --- a/ui/src/lib/workboard/types.ts +++ b/ui/src/lib/workboard/types.ts @@ -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" diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index 30e9a86ddd6a..ee72667ae39c 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -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, }, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d24d6087130b..61af369b332c 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -282,6 +282,7 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] { sourcePackageAlias("normalization-core", "string-normalization"), sourcePackageAlias("normalization-core", "utf16-slice"), sourcePackageAlias("normalization-core"), + sourcePackageAlias("workboard-contract"), ]; } diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 575fa182a530..51452fa29ec4 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -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"),