Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2
extensions/workboard/api.ts
Normal file
2
extensions/workboard/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Workboard API module exposes the plugin public contract.
|
||||
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
448
extensions/workboard/doctor-contract-api.test.ts
Normal file
448
extensions/workboard/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
// Workboard tests cover doctor contract api plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createPluginStateKeyedStoreForTests as createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stateMigrations } from "./doctor-contract-api.js";
|
||||
import { createWorkboardSqliteStores } from "./src/sqlite-store.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard } from "./src/store.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStore<T>("workboard", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workboard doctor contract", () => {
|
||||
it("migrates shipped .28 plugin-state workboard data into sqlite", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
try {
|
||||
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
||||
namespace: "workboard.cards",
|
||||
maxEntries: 2000,
|
||||
env,
|
||||
});
|
||||
const boardStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.boards",
|
||||
maxEntries: 200,
|
||||
env,
|
||||
});
|
||||
const notifyStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.notify",
|
||||
maxEntries: 2000,
|
||||
env,
|
||||
});
|
||||
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: 42_000,
|
||||
env,
|
||||
});
|
||||
await boardStore.register("planning", {
|
||||
version: 1,
|
||||
board: { id: "planning", name: "Planning", createdAt: 1, updatedAt: 2 },
|
||||
});
|
||||
await cardStore.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Migrate me",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["sqlite"],
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
metadata: {
|
||||
automation: { boardId: "planning" },
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await notifyStore.register("sub-1", {
|
||||
version: 1,
|
||||
subscription: { id: "sub-1", boardId: "planning", createdAt: 1, updatedAt: 2 },
|
||||
});
|
||||
await attachmentStore.register("attachment-1", {
|
||||
version: 1,
|
||||
attachment: {
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
|
||||
const migration = stateMigrations[0];
|
||||
await expect(
|
||||
migration.detectLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
preview: [expect.stringContaining("4 legacy .28 plugin-state KV entries")],
|
||||
});
|
||||
|
||||
const result = await migration.migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 4 Workboard .28 plugin-state KV entries")],
|
||||
warnings: [],
|
||||
});
|
||||
expect(await cardStore.entries()).toEqual([]);
|
||||
expect(await boardStore.entries()).toEqual([]);
|
||||
expect(await notifyStore.entries()).toEqual([]);
|
||||
expect(await attachmentStore.entries()).toEqual([]);
|
||||
|
||||
const sqlite = createWorkboardSqliteStores({ env });
|
||||
const store = new WorkboardStore(sqlite.cards, {
|
||||
boards: sqlite.boards,
|
||||
subscriptions: sqlite.subscriptions,
|
||||
attachments: sqlite.attachments,
|
||||
});
|
||||
expect(await store.get("card-1")).toMatchObject({
|
||||
title: "Migrate me",
|
||||
metadata: {
|
||||
automation: { boardId: "planning" },
|
||||
attachments: [expect.objectContaining({ id: "attachment-1" })],
|
||||
},
|
||||
});
|
||||
expect(await store.getAttachment("attachment-1")).toMatchObject({
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
expect(await store.listBoards()).toMatchObject({
|
||||
boards: [
|
||||
expect.objectContaining({ id: "default" }),
|
||||
expect.objectContaining({ id: "planning" }),
|
||||
],
|
||||
});
|
||||
expect(await store.listNotificationSubscriptions({ boardId: "planning" })).toMatchObject({
|
||||
subscriptions: [expect.objectContaining({ id: "sub-1" })],
|
||||
});
|
||||
sqlite.close();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes attachment migration when the owning card was already copied", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
try {
|
||||
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: 42_000,
|
||||
env,
|
||||
});
|
||||
await attachmentStore.register("attachment-1", {
|
||||
version: 1,
|
||||
attachment: {
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
|
||||
const sqlite = createWorkboardSqliteStores({ env });
|
||||
await sqlite.cards.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Already copied",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
metadata: {
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
sqlite.close();
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
changes: [expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry")],
|
||||
warnings: [],
|
||||
});
|
||||
expect(await attachmentStore.entries()).toEqual([]);
|
||||
|
||||
const reopenedStores = createWorkboardSqliteStores({ env });
|
||||
expect(await reopenedStores.attachments.lookup("attachment-1")).toMatchObject({
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
reopenedStores.close();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips malformed legacy attachments without aborting valid attachment migration", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
try {
|
||||
const attachmentStore = createPluginStateKeyedStore<unknown>("workboard", {
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: 42_000,
|
||||
env,
|
||||
});
|
||||
await attachmentStore.register("broken", { version: 1 });
|
||||
await attachmentStore.register("attachment-1", {
|
||||
version: 1,
|
||||
attachment: {
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
|
||||
const sqlite = createWorkboardSqliteStores({ env });
|
||||
await sqlite.cards.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Already copied",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
metadata: {
|
||||
attachments: [
|
||||
{
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "proof.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
sqlite.close();
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry"),
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining("Skipped malformed legacy Workboard attachment entry broken"),
|
||||
]);
|
||||
expect((await attachmentStore.entries()).map((entry) => entry.key)).toEqual(["broken"]);
|
||||
|
||||
const reopenedStores = createWorkboardSqliteStores({ env });
|
||||
expect(await reopenedStores.attachments.lookup("attachment-1")).toMatchObject({
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
reopenedStores.close();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps orphan legacy attachments when migrated card metadata does not reference them", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
try {
|
||||
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
||||
namespace: "workboard.cards",
|
||||
maxEntries: 2000,
|
||||
env,
|
||||
});
|
||||
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: 42_000,
|
||||
env,
|
||||
});
|
||||
await cardStore.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Migrated card",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
await attachmentStore.register("attachment-1", {
|
||||
version: 1,
|
||||
attachment: {
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 2,
|
||||
fileName: "orphan.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
contentBase64: Buffer.from("ok").toString("base64"),
|
||||
});
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
expect.stringContaining("Migrated 1 Workboard .28 plugin-state KV entry"),
|
||||
]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining("does not reference the attachment"),
|
||||
]);
|
||||
expect(await cardStore.entries()).toEqual([]);
|
||||
expect(await attachmentStore.entries()).toHaveLength(1);
|
||||
|
||||
const reopenedStores = createWorkboardSqliteStores({ env });
|
||||
expect(await reopenedStores.attachments.lookup("attachment-1")).toBeUndefined();
|
||||
reopenedStores.close();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps current sqlite rows when legacy kv ids conflict", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-doctor-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
try {
|
||||
const cardStore = createPluginStateKeyedStore<PersistedWorkboardCard>("workboard", {
|
||||
namespace: "workboard.cards",
|
||||
maxEntries: 2000,
|
||||
env,
|
||||
});
|
||||
const attachmentStore = createPluginStateKeyedStore("workboard", {
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: 42_000,
|
||||
env,
|
||||
});
|
||||
await cardStore.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Legacy card",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
await attachmentStore.register("attachment-1", {
|
||||
version: 1,
|
||||
attachment: {
|
||||
id: "attachment-1",
|
||||
cardId: "card-1",
|
||||
createdAt: 1,
|
||||
fileName: "old.txt",
|
||||
byteSize: 2,
|
||||
},
|
||||
contentBase64: Buffer.from("no").toString("base64"),
|
||||
});
|
||||
|
||||
const sqlite = createWorkboardSqliteStores({ env });
|
||||
await sqlite.cards.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Current card",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1000,
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
},
|
||||
});
|
||||
sqlite.close();
|
||||
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.stringContaining("SQLite target already exists"),
|
||||
expect.stringContaining("owning card was not migrated"),
|
||||
]);
|
||||
expect(await cardStore.entries()).toHaveLength(1);
|
||||
expect(await attachmentStore.entries()).toHaveLength(1);
|
||||
|
||||
const reopenedStores = createWorkboardSqliteStores({ env });
|
||||
const store = new WorkboardStore(reopenedStores.cards);
|
||||
expect(await store.get("card-1")).toMatchObject({ title: "Current card" });
|
||||
expect(await reopenedStores.attachments.lookup("attachment-1")).toBeUndefined();
|
||||
reopenedStores.close();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
289
extensions/workboard/doctor-contract-api.ts
Normal file
289
extensions/workboard/doctor-contract-api.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Workboard API module exposes the plugin public contract.
|
||||
import type {
|
||||
PluginDoctorStateMigration,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import type {
|
||||
PersistedWorkboardAttachment,
|
||||
PersistedWorkboardBoard,
|
||||
PersistedWorkboardCard,
|
||||
PersistedWorkboardNotificationSubscription,
|
||||
WorkboardKeyedStore,
|
||||
} from "./src/persistence-types.js";
|
||||
import { createWorkboardSqliteStores, resolveWorkboardSqlitePath } from "./src/sqlite-store.js";
|
||||
|
||||
const MAX_CARDS = 2000;
|
||||
|
||||
function migrationEnv(params: { env: NodeJS.ProcessEnv; stateDir: string }): NodeJS.ProcessEnv {
|
||||
return { ...params.env, OPENCLAW_STATE_DIR: params.stateDir };
|
||||
}
|
||||
|
||||
function openLegacyStore<T>(params: {
|
||||
context: PluginDoctorStateMigrationContext;
|
||||
env: NodeJS.ProcessEnv;
|
||||
namespace: string;
|
||||
maxEntries: number;
|
||||
}): WorkboardKeyedStore<T> {
|
||||
return params.context.openPluginStateKeyedStore<T>({
|
||||
namespace: params.namespace,
|
||||
maxEntries: params.maxEntries,
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
function isPersistedCard(value: unknown): value is PersistedWorkboardCard {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && (value as PersistedWorkboardCard).version === 1,
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedBoard(value: unknown): value is PersistedWorkboardBoard {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && (value as PersistedWorkboardBoard).version === 1,
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedSubscription(
|
||||
value: unknown,
|
||||
): value is PersistedWorkboardNotificationSubscription {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as PersistedWorkboardNotificationSubscription).version === 1,
|
||||
);
|
||||
}
|
||||
|
||||
function isPersistedAttachment(value: unknown): value is PersistedWorkboardAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as {
|
||||
version?: unknown;
|
||||
attachment?: Partial<PersistedWorkboardAttachment["attachment"]>;
|
||||
contentBase64?: unknown;
|
||||
};
|
||||
const attachment = candidate.attachment;
|
||||
return (
|
||||
candidate.version === 1 &&
|
||||
attachment !== undefined &&
|
||||
typeof attachment === "object" &&
|
||||
typeof attachment.id === "string" &&
|
||||
typeof attachment.cardId === "string" &&
|
||||
typeof attachment.fileName === "string" &&
|
||||
typeof attachment.byteSize === "number" &&
|
||||
typeof attachment.createdAt === "number" &&
|
||||
typeof candidate.contentBase64 === "string"
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateNamespace<T>(params: {
|
||||
label: string;
|
||||
legacy: WorkboardKeyedStore<T>;
|
||||
target: WorkboardKeyedStore<T>;
|
||||
isValid: (value: unknown) => value is T;
|
||||
}): Promise<{ imported: number; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
let imported = 0;
|
||||
for (const entry of await params.legacy.entries()) {
|
||||
if (!params.isValid(entry.value)) {
|
||||
warnings.push(`Skipped malformed legacy Workboard ${params.label} entry ${entry.key}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const targetEntry = await params.target.lookup(entry.key);
|
||||
if (targetEntry) {
|
||||
if (JSON.stringify(targetEntry) === JSON.stringify(entry.value)) {
|
||||
await params.legacy.delete(entry.key);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
warnings.push(
|
||||
`Skipped legacy Workboard ${params.label} entry ${entry.key} because the SQLite target already exists`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await params.target.register(entry.key, entry.value);
|
||||
await params.legacy.delete(entry.key);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating legacy Workboard ${params.label} entry ${entry.key}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { imported, warnings };
|
||||
}
|
||||
|
||||
async function targetCardReferencesAttachment(
|
||||
cards: WorkboardKeyedStore,
|
||||
attachment: PersistedWorkboardAttachment,
|
||||
): Promise<boolean> {
|
||||
const card = await cards.lookup(attachment.attachment.cardId);
|
||||
return Boolean(
|
||||
card?.version === 1 &&
|
||||
card.card.metadata?.attachments?.some(
|
||||
(entry) =>
|
||||
entry.id === attachment.attachment.id && entry.cardId === attachment.attachment.cardId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateAttachments(params: {
|
||||
legacy: WorkboardKeyedStore<PersistedWorkboardAttachment>;
|
||||
cards: WorkboardKeyedStore;
|
||||
target: WorkboardKeyedStore<PersistedWorkboardAttachment>;
|
||||
}): Promise<{ imported: number; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
let imported = 0;
|
||||
for (const entry of await params.legacy.entries()) {
|
||||
if (!isPersistedAttachment(entry.value)) {
|
||||
warnings.push(`Skipped malformed legacy Workboard attachment entry ${entry.key}`);
|
||||
continue;
|
||||
}
|
||||
if (!(await targetCardReferencesAttachment(params.cards, entry.value))) {
|
||||
warnings.push(
|
||||
`Skipped legacy Workboard attachment entry ${entry.key} because its owning card was not migrated or does not reference the attachment`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetEntry = await params.target.lookup(entry.key);
|
||||
if (targetEntry) {
|
||||
if (JSON.stringify(targetEntry) === JSON.stringify(entry.value)) {
|
||||
await params.legacy.delete(entry.key);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
warnings.push(
|
||||
`Skipped legacy Workboard attachment entry ${entry.key} because the SQLite target already exists`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await params.target.register(entry.key, entry.value);
|
||||
await params.legacy.delete(entry.key);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating legacy Workboard attachment entry ${entry.key}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { imported, warnings };
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "workboard-28-kv-to-sqlite",
|
||||
label: "Workboard .28 plugin-state KV",
|
||||
async detectLegacyState(params) {
|
||||
const env = migrationEnv(params);
|
||||
const cards = await openLegacyStore<PersistedWorkboardCard>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.cards",
|
||||
maxEntries: MAX_CARDS,
|
||||
}).entries();
|
||||
const boards = await openLegacyStore<PersistedWorkboardBoard>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.boards",
|
||||
maxEntries: 200,
|
||||
}).entries();
|
||||
const subscriptions = await openLegacyStore<PersistedWorkboardNotificationSubscription>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.notify",
|
||||
maxEntries: 2000,
|
||||
}).entries();
|
||||
const attachments = await openLegacyStore<PersistedWorkboardAttachment>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: MAX_CARDS * 21,
|
||||
}).entries();
|
||||
const count = cards.length + boards.length + subscriptions.length + attachments.length;
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
preview: [
|
||||
`- Workboard: ${count} legacy .28 plugin-state KV ${count === 1 ? "entry" : "entries"} → ${resolveWorkboardSqlitePath(env)}`,
|
||||
],
|
||||
};
|
||||
},
|
||||
async migrateLegacyState(params) {
|
||||
const env = migrationEnv(params);
|
||||
const cards = openLegacyStore<PersistedWorkboardCard>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.cards",
|
||||
maxEntries: MAX_CARDS,
|
||||
});
|
||||
const boards = openLegacyStore<PersistedWorkboardBoard>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.boards",
|
||||
maxEntries: 200,
|
||||
});
|
||||
const subscriptions = openLegacyStore<PersistedWorkboardNotificationSubscription>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.notify",
|
||||
maxEntries: 2000,
|
||||
});
|
||||
const attachments = openLegacyStore<PersistedWorkboardAttachment>({
|
||||
context: params.context,
|
||||
env,
|
||||
namespace: "workboard.attachments",
|
||||
maxEntries: MAX_CARDS * 21,
|
||||
});
|
||||
const sqlite = createWorkboardSqliteStores({ env });
|
||||
try {
|
||||
const cardResult = await migrateNamespace({
|
||||
label: "card",
|
||||
legacy: cards,
|
||||
target: sqlite.cards,
|
||||
isValid: isPersistedCard,
|
||||
});
|
||||
const boardResult = await migrateNamespace({
|
||||
label: "board",
|
||||
legacy: boards,
|
||||
target: sqlite.boards,
|
||||
isValid: isPersistedBoard,
|
||||
});
|
||||
const subscriptionResult = await migrateNamespace({
|
||||
label: "notification subscription",
|
||||
legacy: subscriptions,
|
||||
target: sqlite.subscriptions,
|
||||
isValid: isPersistedSubscription,
|
||||
});
|
||||
const attachmentResult = await migrateAttachments({
|
||||
legacy: attachments,
|
||||
cards: sqlite.cards,
|
||||
target: sqlite.attachments,
|
||||
});
|
||||
const imported =
|
||||
cardResult.imported +
|
||||
boardResult.imported +
|
||||
subscriptionResult.imported +
|
||||
attachmentResult.imported;
|
||||
return {
|
||||
changes:
|
||||
imported > 0
|
||||
? [
|
||||
`Migrated ${imported} Workboard .28 plugin-state KV ${imported === 1 ? "entry" : "entries"} → relational SQLite`,
|
||||
]
|
||||
: [],
|
||||
warnings: [
|
||||
...cardResult.warnings,
|
||||
...boardResult.warnings,
|
||||
...subscriptionResult.warnings,
|
||||
...attachmentResult.warnings,
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
71
extensions/workboard/index.ts
Normal file
71
extensions/workboard/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Workboard plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "./api.js";
|
||||
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
|
||||
import { registerWorkboardCommand } from "./src/command.js";
|
||||
import { WorkboardStore } from "./src/store.js";
|
||||
import { createWorkboardTools } from "./src/tools.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "workboard",
|
||||
name: "Workboard",
|
||||
description: "Dashboard workboard for agent-owned issues and sessions.",
|
||||
register(api) {
|
||||
const store = WorkboardStore.openSqlite();
|
||||
registerWorkboardGatewayMethods({ api, store });
|
||||
registerWorkboardCommand({ api, store });
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerWorkboardCli } = await import("./src/cli.js");
|
||||
registerWorkboardCli({ program, store });
|
||||
},
|
||||
{
|
||||
descriptors: [
|
||||
{
|
||||
name: "workboard",
|
||||
description: "Manage Workboard cards and worker dispatch",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
api.registerTool((context) => createWorkboardTools({ api, context, store }), {
|
||||
names: [
|
||||
"workboard_list",
|
||||
"workboard_create",
|
||||
"workboard_link",
|
||||
"workboard_read",
|
||||
"workboard_claim",
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
"workboard_attachment_add",
|
||||
"workboard_attachment_read",
|
||||
"workboard_attachment_delete",
|
||||
"workboard_block",
|
||||
"workboard_boards",
|
||||
"workboard_board_create",
|
||||
"workboard_board_archive",
|
||||
"workboard_board_delete",
|
||||
"workboard_stats",
|
||||
"workboard_runs",
|
||||
"workboard_specify",
|
||||
"workboard_decompose",
|
||||
"workboard_notify_subscribe",
|
||||
"workboard_notify_list",
|
||||
"workboard_notify_events",
|
||||
"workboard_notify_advance",
|
||||
"workboard_notify_unsubscribe",
|
||||
"workboard_promote",
|
||||
"workboard_reassign",
|
||||
"workboard_reclaim",
|
||||
"workboard_dispatch",
|
||||
"workboard_release",
|
||||
"workboard_comment",
|
||||
"workboard_proof",
|
||||
"workboard_worker_log",
|
||||
"workboard_protocol_violation",
|
||||
"workboard_unblock",
|
||||
],
|
||||
optional: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
164
extensions/workboard/openclaw.plugin.json
Normal file
164
extensions/workboard/openclaw.plugin.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"id": "workboard",
|
||||
"enabledByDefault": false,
|
||||
"activation": {
|
||||
"onStartup": true,
|
||||
"onCommands": ["workboard"]
|
||||
},
|
||||
"name": "Workboard",
|
||||
"description": "Dashboard workboard for agent-owned issues and sessions.",
|
||||
"contracts": {
|
||||
"tools": [
|
||||
"workboard_list",
|
||||
"workboard_create",
|
||||
"workboard_link",
|
||||
"workboard_read",
|
||||
"workboard_claim",
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
"workboard_attachment_add",
|
||||
"workboard_attachment_read",
|
||||
"workboard_attachment_delete",
|
||||
"workboard_block",
|
||||
"workboard_boards",
|
||||
"workboard_board_create",
|
||||
"workboard_board_archive",
|
||||
"workboard_board_delete",
|
||||
"workboard_stats",
|
||||
"workboard_runs",
|
||||
"workboard_specify",
|
||||
"workboard_decompose",
|
||||
"workboard_notify_subscribe",
|
||||
"workboard_notify_list",
|
||||
"workboard_notify_events",
|
||||
"workboard_notify_advance",
|
||||
"workboard_notify_unsubscribe",
|
||||
"workboard_promote",
|
||||
"workboard_reassign",
|
||||
"workboard_reclaim",
|
||||
"workboard_dispatch",
|
||||
"workboard_release",
|
||||
"workboard_comment",
|
||||
"workboard_proof",
|
||||
"workboard_worker_log",
|
||||
"workboard_protocol_violation",
|
||||
"workboard_unblock"
|
||||
]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "workboard",
|
||||
"kind": "runtime-slash",
|
||||
"cliCommand": "workboard"
|
||||
}
|
||||
],
|
||||
"toolMetadata": {
|
||||
"workboard_list": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_create": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_link": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_read": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_claim": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_heartbeat": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_complete": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_attachment_add": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_attachment_read": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_attachment_delete": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_block": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_boards": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_create": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_archive": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_delete": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_stats": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_runs": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_specify": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_decompose": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_subscribe": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_list": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_events": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_advance": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_unsubscribe": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_promote": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_reassign": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_reclaim": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_dispatch": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_release": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_comment": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_proof": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_worker_log": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_protocol_violation": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_unblock": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
27
extensions/workboard/package.json
Normal file
27
extensions/workboard/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@openclaw/workboard",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw dashboard workboard plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
10
extensions/workboard/runtime-api.ts
Normal file
10
extensions/workboard/runtime-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Workboard API module exposes the plugin public contract.
|
||||
export { registerWorkboardGatewayMethods } from "./src/gateway.js";
|
||||
export type {
|
||||
WorkboardCard,
|
||||
WorkboardClaim,
|
||||
WorkboardDiagnostic,
|
||||
WorkboardListResult,
|
||||
WorkboardPriority,
|
||||
WorkboardStatus,
|
||||
} from "./src/types.js";
|
||||
25
extensions/workboard/src/card-lookup.ts
Normal file
25
extensions/workboard/src/card-lookup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Workboard plugin module implements card lookup behavior.
|
||||
import type { WorkboardCard } from "./types.js";
|
||||
|
||||
export type WorkboardCardLookupResult =
|
||||
| { card: WorkboardCard; error?: undefined }
|
||||
| { card?: undefined; error: string };
|
||||
|
||||
export function resolveWorkboardCardByIdOrPrefix(
|
||||
cards: readonly WorkboardCard[],
|
||||
id: string,
|
||||
): WorkboardCardLookupResult {
|
||||
const exact = cards.find((card) => card.id === id);
|
||||
if (exact) {
|
||||
return { card: exact };
|
||||
}
|
||||
const matches = cards.filter((card) => card.id.startsWith(id));
|
||||
if (matches.length === 0) {
|
||||
return { error: `Card not found: ${id}` };
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { error: `Ambiguous card id prefix: ${id} (${matches.length} matches)` };
|
||||
}
|
||||
const card = matches[0];
|
||||
return card ? { card } : { error: `Card not found: ${id}` };
|
||||
}
|
||||
189
extensions/workboard/src/cli.test.ts
Normal file
189
extensions/workboard/src/cli.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Workboard tests cover cli plugin behavior.
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerWorkboardCli } from "./cli.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
||||
|
||||
const gatewayRuntime = vi.hoisted(() => ({
|
||||
callGatewayFromCli: vi.fn(),
|
||||
getRuntimeConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/gateway-runtime")>(
|
||||
"openclaw/plugin-sdk/gateway-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
callGatewayFromCli: gatewayRuntime.callGatewayFromCli,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({
|
||||
getRuntimeConfig: gatewayRuntime.getRuntimeConfig,
|
||||
}));
|
||||
|
||||
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
||||
const entries = new Map<string, T>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async lookup(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createProgram(store: WorkboardStore): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeErr: () => {},
|
||||
writeOut: () => {},
|
||||
});
|
||||
registerWorkboardCli({ program, store });
|
||||
return program;
|
||||
}
|
||||
|
||||
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
|
||||
const seen = new Map<string, string>();
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const card = await store.create({ title: `Card ${index}` });
|
||||
const prefix = card.id.slice(0, 1);
|
||||
if (seen.has(prefix)) {
|
||||
return prefix;
|
||||
}
|
||||
seen.set(prefix, card.id);
|
||||
}
|
||||
throw new Error("could not create cards with a shared prefix");
|
||||
}
|
||||
|
||||
async function captureStdout(run: () => Promise<void>): Promise<string> {
|
||||
const chunks: string[] = [];
|
||||
const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => {
|
||||
chunks.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
return chunks.join("");
|
||||
} finally {
|
||||
write.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("registerWorkboardCli", () => {
|
||||
beforeEach(() => {
|
||||
gatewayRuntime.callGatewayFromCli.mockReset();
|
||||
gatewayRuntime.getRuntimeConfig.mockReset();
|
||||
gatewayRuntime.getRuntimeConfig.mockReturnValue({});
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
});
|
||||
|
||||
it("redacts claim tokens from card JSON output", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Claimed worker", status: "running" });
|
||||
await store.claim(card.id, { ownerId: "worker", token: "secret-token" });
|
||||
const program = createProgram(store);
|
||||
|
||||
const listOutput = await captureStdout(async () => {
|
||||
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
|
||||
});
|
||||
const showOutput = await captureStdout(async () => {
|
||||
await program.parseAsync(["workboard", "show", card.id, "--json"], { from: "user" });
|
||||
});
|
||||
|
||||
expect(listOutput).not.toContain("secret-token");
|
||||
expect(showOutput).not.toContain("secret-token");
|
||||
expect(listOutput).toContain("[redacted]");
|
||||
expect(showOutput).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("hides archived cards from text output by default and reveals them with --include-archived", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
await store.create({ title: "Active card" });
|
||||
const archived = await store.create({ title: "Archived card" });
|
||||
await store.archive(archived.id, true);
|
||||
const program = createProgram(store);
|
||||
|
||||
const defaultOutput = await captureStdout(async () => {
|
||||
await program.parseAsync(["workboard", "list"], { from: "user" });
|
||||
});
|
||||
const includeOutput = await captureStdout(async () => {
|
||||
await program.parseAsync(["workboard", "list", "--include-archived"], { from: "user" });
|
||||
});
|
||||
|
||||
expect(defaultOutput).toContain("Active card");
|
||||
expect(defaultOutput).not.toContain("Archived card");
|
||||
expect(includeOutput).toContain("Active card");
|
||||
expect(includeOutput).toContain("Archived card");
|
||||
});
|
||||
|
||||
it("preserves archived cards in JSON list output by default", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const archived = await store.create({ title: "Archived card" });
|
||||
await store.archive(archived.id, true);
|
||||
const program = createProgram(store);
|
||||
|
||||
const output = await captureStdout(async () => {
|
||||
await program.parseAsync(["workboard", "list", "--json"], { from: "user" });
|
||||
});
|
||||
|
||||
expect(output).toContain(archived.id);
|
||||
expect(output).toContain("archivedAt");
|
||||
});
|
||||
|
||||
it("does not fall back to local dispatch for explicit gateway targets", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Remote target", status: "ready" });
|
||||
const program = createProgram(store);
|
||||
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
|
||||
new Error("connect ECONNREFUSED 127.0.0.1:18789"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["workboard", "dispatch", "--url", "ws://remote"], { from: "user" }),
|
||||
).rejects.toThrow("ECONNREFUSED");
|
||||
|
||||
const after = await store.get(card.id);
|
||||
expect(after?.status).toBe("ready");
|
||||
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fall back to local dispatch for configured remote gateways", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Configured remote target", status: "ready" });
|
||||
const program = createProgram(store);
|
||||
gatewayRuntime.getRuntimeConfig.mockReturnValue({
|
||||
gateway: { mode: "remote", remote: { url: "wss://gateway.example" } },
|
||||
});
|
||||
gatewayRuntime.callGatewayFromCli.mockRejectedValueOnce(
|
||||
new Error("connect ECONNREFUSED gateway.example:443"),
|
||||
);
|
||||
|
||||
await expect(program.parseAsync(["workboard", "dispatch"], { from: "user" })).rejects.toThrow(
|
||||
"ECONNREFUSED",
|
||||
);
|
||||
|
||||
const after = await store.get(card.id);
|
||||
expect(after?.status).toBe("ready");
|
||||
expect(after?.metadata?.automation?.dispatchCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects ambiguous card id prefixes", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const prefix = await createAmbiguousPrefix(store);
|
||||
const program = createProgram(store);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["workboard", "show", prefix], { from: "user" }),
|
||||
).rejects.toThrow("Ambiguous card id prefix");
|
||||
});
|
||||
});
|
||||
251
extensions/workboard/src/cli.ts
Normal file
251
extensions/workboard/src/cli.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// Workboard plugin module implements cli behavior.
|
||||
import type { Command } from "commander";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
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 type { WorkboardCard } from "./types.js";
|
||||
|
||||
type JsonOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type GatewayOptions = JsonOptions & {
|
||||
url?: string;
|
||||
token?: string;
|
||||
timeout?: string;
|
||||
expectFinal?: boolean;
|
||||
board?: string;
|
||||
};
|
||||
|
||||
function writeJson(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function writeLine(value: string): void {
|
||||
process.stdout.write(`${value}\n`);
|
||||
}
|
||||
|
||||
function splitLabels(value: string | undefined): string[] | undefined {
|
||||
return value
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatCardLine(card: WorkboardCard): string {
|
||||
const boardId = card.metadata?.automation?.boardId ?? "default";
|
||||
const agent = card.agentId ? ` ${card.agentId}` : "";
|
||||
return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} ${boardId}${agent} ${card.title}`;
|
||||
}
|
||||
|
||||
function redactClaimToken(card: WorkboardCard): WorkboardCard {
|
||||
const claim = card.metadata?.claim;
|
||||
if (!claim) {
|
||||
return card;
|
||||
}
|
||||
return {
|
||||
...card,
|
||||
metadata: {
|
||||
...card.metadata,
|
||||
claim: {
|
||||
...claim,
|
||||
token: "[redacted]",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function redactDispatchResult(result: WorkboardDispatchResult): WorkboardDispatchResult {
|
||||
return {
|
||||
...result,
|
||||
promoted: result.promoted.map(redactClaimToken),
|
||||
reclaimed: result.reclaimed.map(redactClaimToken),
|
||||
blocked: result.blocked.map(redactClaimToken),
|
||||
orchestrated: result.orchestrated.map(redactClaimToken),
|
||||
};
|
||||
}
|
||||
|
||||
function writeCards(cards: WorkboardCard[], options: JsonOptions): void {
|
||||
if (options.json) {
|
||||
writeJson({ cards: cards.map(redactClaimToken) });
|
||||
return;
|
||||
}
|
||||
for (const card of cards) {
|
||||
writeLine(formatCardLine(card));
|
||||
}
|
||||
}
|
||||
|
||||
async function callWorkboardGateway(
|
||||
method: string,
|
||||
options: GatewayOptions,
|
||||
params?: unknown,
|
||||
): Promise<unknown> {
|
||||
return await callGatewayFromCli(method, options, params, {
|
||||
mode: "cli",
|
||||
scopes: ["operator.write", "operator.read"],
|
||||
});
|
||||
}
|
||||
|
||||
function isGatewayUnavailableError(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error).toLowerCase();
|
||||
return [
|
||||
"econnrefused",
|
||||
"econnreset",
|
||||
"ehostunreach",
|
||||
"enotfound",
|
||||
"gateway not connected",
|
||||
"gateway unavailable",
|
||||
"unknown method: workboard.cards.dispatch",
|
||||
].some((marker) => message.includes(marker));
|
||||
}
|
||||
|
||||
function hasExplicitGatewayTarget(options: GatewayOptions): boolean {
|
||||
return Boolean(options.url?.trim() || options.token?.trim());
|
||||
}
|
||||
|
||||
function hasConfiguredRemoteGatewayTarget(): boolean {
|
||||
if (process.env.OPENCLAW_GATEWAY_URL?.trim()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return getRuntimeConfig().gateway?.mode === "remote";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorkboardCli(params: { program: Command; store: WorkboardStore }): void {
|
||||
const workboard = params.program
|
||||
.command("workboard")
|
||||
.description("Manage Workboard cards and worker dispatch");
|
||||
|
||||
workboard
|
||||
.command("list")
|
||||
.description("List Workboard cards")
|
||||
.option("--board <id>", "Board id")
|
||||
.option("--status <status>", "Filter by status")
|
||||
.option("--include-archived", "Include archived cards (default false)")
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(
|
||||
async (
|
||||
options: JsonOptions & {
|
||||
board?: string;
|
||||
status?: string;
|
||||
includeArchived?: boolean;
|
||||
},
|
||||
) => {
|
||||
// Text output hides archived cards like /workboard list, while --json
|
||||
// keeps the shipped full-card contract for existing scripts.
|
||||
let cards = await params.store.list({ boardId: options.board });
|
||||
if (!options.json && options.includeArchived !== true) {
|
||||
cards = cards.filter((card) => !card.metadata?.archivedAt);
|
||||
}
|
||||
if (options.status) {
|
||||
cards = cards.filter((card) => card.status === options.status);
|
||||
}
|
||||
writeCards(cards, options);
|
||||
},
|
||||
);
|
||||
|
||||
workboard
|
||||
.command("create")
|
||||
.argument("<title...>", "Card title")
|
||||
.description("Create a Workboard card")
|
||||
.option("--notes <text>", "Card notes")
|
||||
.option("--status <status>", "Initial status", "todo")
|
||||
.option("--priority <priority>", "Priority", "normal")
|
||||
.option("--agent <id>", "Assigned agent id")
|
||||
.option("--board <id>", "Board id")
|
||||
.option("--labels <items>", "Comma-separated labels")
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(
|
||||
async (
|
||||
title: string[],
|
||||
options: JsonOptions & {
|
||||
notes?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
agent?: string;
|
||||
board?: string;
|
||||
labels?: string;
|
||||
},
|
||||
) => {
|
||||
const card = await params.store.create({
|
||||
title: title.join(" "),
|
||||
notes: options.notes,
|
||||
status: options.status,
|
||||
priority: options.priority,
|
||||
agentId: options.agent,
|
||||
boardId: options.board,
|
||||
labels: splitLabels(options.labels),
|
||||
});
|
||||
if (options.json) {
|
||||
writeJson({ card: redactClaimToken(card) });
|
||||
} else {
|
||||
writeLine(formatCardLine(card));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
workboard
|
||||
.command("show")
|
||||
.argument("<id>", "Card id or prefix")
|
||||
.description("Show one Workboard card")
|
||||
.option("--json", "Print JSON", false)
|
||||
.action(async (id: string, options: JsonOptions) => {
|
||||
const cards = await params.store.list();
|
||||
const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id);
|
||||
if (!card) {
|
||||
throw new Error(error);
|
||||
}
|
||||
if (options.json) {
|
||||
writeJson({ card: redactClaimToken(card) });
|
||||
} else {
|
||||
writeLine(formatCardLine(card));
|
||||
if (card.notes) {
|
||||
writeLine(card.notes);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
workboard
|
||||
.command("dispatch")
|
||||
.description("Promote ready cards and start worker runs through the Gateway")
|
||||
.option("--board <id>", "Dispatch a single board")
|
||||
.option("--json", "Print JSON", false),
|
||||
).action(async (options: GatewayOptions) => {
|
||||
try {
|
||||
const result = await callWorkboardGateway("workboard.cards.dispatch", options, {
|
||||
boardId: options.board,
|
||||
});
|
||||
if (options.json) {
|
||||
writeJson(result);
|
||||
} else {
|
||||
const record = isRecord(result) ? result : {};
|
||||
const started = Array.isArray(record.started) ? record.started.length : 0;
|
||||
const failures = Array.isArray(record.startFailures) ? record.startFailures.length : 0;
|
||||
writeLine(`dispatch complete: started=${started} failures=${failures}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!isGatewayUnavailableError(error) ||
|
||||
hasExplicitGatewayTarget(options) ||
|
||||
hasConfiguredRemoteGatewayTarget()
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const result = redactDispatchResult(await params.store.dispatch({ boardId: options.board }));
|
||||
if (options.json) {
|
||||
writeJson({ ...result, gatewayUnavailable: true });
|
||||
} else {
|
||||
writeLine(
|
||||
`gateway unavailable; data dispatch only: promoted=${result.promoted.length} blocked=${result.blocked.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
115
extensions/workboard/src/command.test.ts
Normal file
115
extensions/workboard/src/command.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Workboard tests cover command plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleWorkboardCommand } from "./command.js";
|
||||
import type { WorkboardSubagentRuntime } from "./dispatcher.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
||||
|
||||
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
||||
const entries = new Map<string, T>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async lookup(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): {
|
||||
runtime: { subagent: WorkboardSubagentRuntime };
|
||||
} {
|
||||
return {
|
||||
runtime: {
|
||||
subagent: { run },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
|
||||
const seen = new Map<string, string>();
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const card = await store.create({ title: `Card ${index}` });
|
||||
const prefix = card.id.slice(0, 1);
|
||||
if (seen.has(prefix)) {
|
||||
return prefix;
|
||||
}
|
||||
seen.set(prefix, card.id);
|
||||
}
|
||||
throw new Error("could not create cards with a shared prefix");
|
||||
}
|
||||
|
||||
describe("handleWorkboardCommand", () => {
|
||||
it("creates, lists, and dispatches workboard cards", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const api = createApi();
|
||||
|
||||
await expect(
|
||||
handleWorkboardCommand({
|
||||
api,
|
||||
store,
|
||||
args: "create Ship CLI",
|
||||
senderIsOwner: true,
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("Ship CLI") }));
|
||||
const card = (await store.list())[0];
|
||||
expect(card).toMatchObject({ title: "Ship CLI" });
|
||||
|
||||
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("Ship CLI") }),
|
||||
);
|
||||
await store.update(card.id, { status: "ready" });
|
||||
await expect(
|
||||
handleWorkboardCommand({
|
||||
api,
|
||||
store,
|
||||
args: "dispatch",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("started=1") }));
|
||||
expect(api.runtime.subagent.run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires write access for slash mutations", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const api = createApi();
|
||||
const card = await store.create({ title: "Ready worker", status: "ready" });
|
||||
|
||||
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("Ready worker") }),
|
||||
);
|
||||
await expect(handleWorkboardCommand({ api, store, args: "create Blocked" })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isError: true,
|
||||
text: expect.stringContaining("operator.write"),
|
||||
}),
|
||||
);
|
||||
await expect(handleWorkboardCommand({ api, store, args: "dispatch" })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isError: true,
|
||||
text: expect.stringContaining("operator.write"),
|
||||
}),
|
||||
);
|
||||
expect(api.runtime.subagent.run).not.toHaveBeenCalled();
|
||||
await expect(store.get(card.id)).resolves.toMatchObject({ status: "ready" });
|
||||
});
|
||||
|
||||
it("rejects ambiguous card id prefixes", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const api = createApi();
|
||||
const prefix = await createAmbiguousPrefix(store);
|
||||
|
||||
await expect(handleWorkboardCommand({ api, store, args: `show ${prefix}` })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
isError: true,
|
||||
text: expect.stringContaining("Ambiguous card id prefix"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
162
extensions/workboard/src/command.ts
Normal file
162
extensions/workboard/src/command.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Workboard plugin module implements command behavior.
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import { resolveWorkboardCardByIdOrPrefix } from "./card-lookup.js";
|
||||
import { dispatchAndStartWorkboardCards, type WorkboardSubagentRuntime } from "./dispatcher.js";
|
||||
import type { WorkboardStore } from "./store.js";
|
||||
import type { WorkboardCard } from "./types.js";
|
||||
|
||||
const ADMIN_SCOPE = "operator.admin";
|
||||
const WRITE_SCOPE = "operator.write";
|
||||
|
||||
type WorkboardCommandApi = {
|
||||
runtime: {
|
||||
subagent: WorkboardSubagentRuntime;
|
||||
};
|
||||
};
|
||||
|
||||
function splitArgs(input: string | undefined): string[] {
|
||||
return (input ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function formatCardLine(card: WorkboardCard): string {
|
||||
const boardId = card.metadata?.automation?.boardId ?? "default";
|
||||
const agent = card.agentId ? ` @${card.agentId}` : "";
|
||||
return `${card.id.slice(0, 8)} ${card.status.padEnd(8)} ${card.priority.padEnd(6)} [${boardId}]${agent} ${card.title}`;
|
||||
}
|
||||
|
||||
function formatCardDetails(card: WorkboardCard): string {
|
||||
const lines = [
|
||||
card.title,
|
||||
`id: ${card.id}`,
|
||||
`status: ${card.status}`,
|
||||
`priority: ${card.priority}`,
|
||||
`board: ${card.metadata?.automation?.boardId ?? "default"}`,
|
||||
];
|
||||
if (card.agentId) {
|
||||
lines.push(`agent: ${card.agentId}`);
|
||||
}
|
||||
if (card.sessionKey) {
|
||||
lines.push(`session: ${card.sessionKey}`);
|
||||
}
|
||||
if (card.runId) {
|
||||
lines.push(`run: ${card.runId}`);
|
||||
}
|
||||
if (card.notes) {
|
||||
lines.push("", card.notes);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function normalizeTitle(tokens: string[]): string {
|
||||
return tokens.join(" ").trim();
|
||||
}
|
||||
|
||||
function canMutateWorkboard(params: {
|
||||
senderIsOwner?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): boolean {
|
||||
const scopes = params.gatewayClientScopes;
|
||||
if (scopes) {
|
||||
return scopes.includes(ADMIN_SCOPE) || scopes.includes(WRITE_SCOPE);
|
||||
}
|
||||
return params.senderIsOwner === true;
|
||||
}
|
||||
|
||||
function requireWriteAccess(params: {
|
||||
senderIsOwner?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): { text: string; isError: true } | undefined {
|
||||
if (canMutateWorkboard(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
text: `This command requires gateway scope: ${WRITE_SCOPE}.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleWorkboardCommand(params: {
|
||||
api: WorkboardCommandApi;
|
||||
store: WorkboardStore;
|
||||
args?: string;
|
||||
senderIsOwner?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): Promise<{ text: string; isError?: boolean }> {
|
||||
const [action = "list", ...rest] = splitArgs(params.args);
|
||||
if (action === "help") {
|
||||
return {
|
||||
text: [
|
||||
"/workboard list",
|
||||
"/workboard show <card-id>",
|
||||
"/workboard create <title>",
|
||||
"/workboard dispatch",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
if (action === "list") {
|
||||
const cards = (await params.store.list()).filter((card) => !card.metadata?.archivedAt);
|
||||
const rows = cards.slice(0, 12).map(formatCardLine);
|
||||
return { text: rows.length ? rows.join("\n") : "No Workboard cards." };
|
||||
}
|
||||
if (action === "show" || action === "read") {
|
||||
const id = rest[0];
|
||||
if (!id) {
|
||||
return { text: "Usage: /workboard show <card-id>", isError: true };
|
||||
}
|
||||
const cards = await params.store.list();
|
||||
const { card, error } = resolveWorkboardCardByIdOrPrefix(cards, id);
|
||||
return card ? { text: formatCardDetails(card) } : { text: error, isError: true };
|
||||
}
|
||||
if (action === "create") {
|
||||
const accessError = requireWriteAccess(params);
|
||||
if (accessError) {
|
||||
return accessError;
|
||||
}
|
||||
const title = normalizeTitle(rest);
|
||||
if (!title) {
|
||||
return { text: "Usage: /workboard create <title>", isError: true };
|
||||
}
|
||||
const card = await params.store.create({ title });
|
||||
return { text: `Created ${card.id.slice(0, 8)} ${card.title}` };
|
||||
}
|
||||
if (action === "dispatch") {
|
||||
const accessError = requireWriteAccess(params);
|
||||
if (accessError) {
|
||||
return accessError;
|
||||
}
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store: params.store,
|
||||
subagent: params.api.runtime.subagent,
|
||||
});
|
||||
return {
|
||||
text: [
|
||||
`dispatch: started=${result.started.length} failures=${result.startFailures.length} promoted=${result.promoted.length} blocked=${result.blocked.length}`,
|
||||
...result.started.map((run) => `started ${run.cardId.slice(0, 8)} run=${run.runId}`),
|
||||
...result.startFailures.map(
|
||||
(failure) => `failed ${failure.cardId.slice(0, 8)} ${failure.error}`,
|
||||
),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { text: `Unknown Workboard action: ${action}`, isError: true };
|
||||
}
|
||||
|
||||
export function registerWorkboardCommand(params: {
|
||||
api: OpenClawPluginApi;
|
||||
store: WorkboardStore;
|
||||
}): void {
|
||||
params.api.registerCommand({
|
||||
name: "workboard",
|
||||
description: "List, create, inspect, and dispatch Workboard cards.",
|
||||
acceptsArgs: true,
|
||||
exposeSenderIsOwner: true,
|
||||
handler: async (ctx) =>
|
||||
await handleWorkboardCommand({
|
||||
api: params.api,
|
||||
store: params.store,
|
||||
args: ctx.args,
|
||||
senderIsOwner: ctx.senderIsOwner,
|
||||
gatewayClientScopes: ctx.gatewayClientScopes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
208
extensions/workboard/src/dispatcher.test.ts
Normal file
208
extensions/workboard/src/dispatcher.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Workboard tests cover dispatcher plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
||||
|
||||
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
||||
const entries = new Map<string, T>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async lookup(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatchAndStartWorkboardCards", () => {
|
||||
it("claims ready cards and starts bounded subagent worker runs", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const first = await store.create({
|
||||
title: "First worker",
|
||||
status: "ready",
|
||||
priority: "urgent",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
const second = await store.create({
|
||||
title: "Second worker",
|
||||
status: "ready",
|
||||
priority: "normal",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
const otherAgent = await store.create({
|
||||
title: "Other worker",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
agentId: "codex-side",
|
||||
});
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ runId: "run-first" })
|
||||
.mockResolvedValueOnce({ runId: "run-other" });
|
||||
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: { run },
|
||||
options: { now: 10, maxStarts: 3 },
|
||||
});
|
||||
|
||||
expect(result.started.map((entry) => entry.cardId).toSorted()).toEqual(
|
||||
[first.id, otherAgent.id].toSorted(),
|
||||
);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
|
||||
lane: `workboard:default:${first.id}`,
|
||||
deliver: false,
|
||||
});
|
||||
expect(run.mock.calls[0]?.[0]?.message).toContain("Claim token:");
|
||||
expect(run.mock.calls[0]?.[0]?.message).toContain("workboard_complete with the card id");
|
||||
expect(run.mock.calls[0]?.[0]?.message).not.toContain("ownerId and token");
|
||||
await expect(store.get(first.id)).resolves.toMatchObject({
|
||||
status: "running",
|
||||
sessionKey: `agent:codex-main:subagent:workboard-default-${first.id}`,
|
||||
runId: "run-first",
|
||||
execution: { status: "running", runId: "run-first" },
|
||||
metadata: {
|
||||
claim: { ownerId: "codex-main" },
|
||||
workerLogs: [expect.objectContaining({ message: expect.stringContaining("run-first") })],
|
||||
},
|
||||
});
|
||||
await expect(store.get(second.id)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
metadata: { automation: { dispatchCount: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let review cards consume an agent running slot", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
await store.create({
|
||||
title: "Waiting for operator review",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
const ready = await store.create({
|
||||
title: "Next ready card",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
|
||||
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: { run },
|
||||
options: { now: 10, maxStarts: 3 },
|
||||
});
|
||||
|
||||
expect(result.started).toEqual([
|
||||
expect.objectContaining({
|
||||
cardId: ready.id,
|
||||
runId: "run-next",
|
||||
}),
|
||||
]);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts workers only for the selected board", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const ops = await store.create({
|
||||
title: "Ops worker",
|
||||
status: "ready",
|
||||
priority: "urgent",
|
||||
boardId: "ops",
|
||||
});
|
||||
const product = await store.create({
|
||||
title: "Product worker",
|
||||
status: "ready",
|
||||
priority: "urgent",
|
||||
boardId: "product",
|
||||
});
|
||||
const run = vi.fn().mockResolvedValue({ runId: "run-ops" });
|
||||
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: { run },
|
||||
options: { now: 10, maxStarts: 3, boardId: "ops" },
|
||||
});
|
||||
|
||||
expect(result.started).toEqual([expect.objectContaining({ cardId: ops.id })]);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(run.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey: `subagent:workboard-ops-${ops.id}`,
|
||||
lane: `workboard:ops:${ops.id}`,
|
||||
});
|
||||
await expect(store.get(product.id)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
metadata: { automation: { boardId: "product" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps claimed review cards in the owner running slot", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const review = await store.create({
|
||||
title: "Claimed operator review",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
await store.claim(review.id, { ownerId: "codex-main", token: "review-token" });
|
||||
await store.create({
|
||||
title: "Next ready card",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
agentId: "codex-main",
|
||||
});
|
||||
const run = vi.fn().mockResolvedValue({ runId: "run-next" });
|
||||
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: { run },
|
||||
options: { now: 10, maxStarts: 3 },
|
||||
});
|
||||
|
||||
expect(result.started).toEqual([]);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks a card when worker start fails after claim", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Fail worker", status: "ready" });
|
||||
const run = vi.fn().mockRejectedValue(new Error("model unavailable"));
|
||||
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: { run },
|
||||
options: { now: 10, maxStarts: 1 },
|
||||
});
|
||||
|
||||
expect(result.started).toEqual([]);
|
||||
expect(result.startFailures).toEqual([
|
||||
expect.objectContaining({ cardId: card.id, error: "model unavailable" }),
|
||||
]);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: `subagent:workboard-default-${card.id}`,
|
||||
}),
|
||||
);
|
||||
await expect(store.get(card.id)).resolves.toMatchObject({
|
||||
status: "blocked",
|
||||
metadata: {
|
||||
comments: [
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("Dispatcher could not start worker"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect((await store.get(card.id))?.metadata?.claim).toBeUndefined();
|
||||
});
|
||||
});
|
||||
263
extensions/workboard/src/dispatcher.ts
Normal file
263
extensions/workboard/src/dispatcher.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Workboard plugin module implements dispatcher behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { WorkboardStore, type WorkboardDispatchResult } from "./store.js";
|
||||
import type { WorkboardCard, WorkboardExecution } from "./types.js";
|
||||
|
||||
const DEFAULT_DISPATCH_MAX_STARTS = 3;
|
||||
const DEFAULT_DISPATCH_OWNER = "workboard-dispatcher";
|
||||
const DEFAULT_DISPATCH_MODEL = "default";
|
||||
|
||||
export type WorkboardSubagentRuntime = Pick<PluginRuntime["subagent"], "run">;
|
||||
|
||||
export type WorkboardDispatchStartOptions = {
|
||||
maxStarts?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
ownerId?: string;
|
||||
boardId?: string;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type WorkboardStartedRun = {
|
||||
cardId: string;
|
||||
title: string;
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
};
|
||||
|
||||
export type WorkboardStartFailure = {
|
||||
cardId: string;
|
||||
title: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type WorkboardDispatchAndStartResult = WorkboardDispatchResult & {
|
||||
started: WorkboardStartedRun[];
|
||||
startFailures: WorkboardStartFailure[];
|
||||
};
|
||||
|
||||
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, Math.trunc(value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function cardBoardId(card: WorkboardCard): string {
|
||||
return card.metadata?.automation?.boardId ?? "default";
|
||||
}
|
||||
|
||||
function sanitizeSessionSegment(value: string | undefined, fallback: string): string {
|
||||
const sanitized = (value ?? fallback)
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return (sanitized || fallback).slice(0, 96);
|
||||
}
|
||||
|
||||
function cardIsArchived(card: WorkboardCard): boolean {
|
||||
return Boolean(card.metadata?.archivedAt);
|
||||
}
|
||||
|
||||
function buildSessionKey(card: WorkboardCard): string {
|
||||
const boardId = sanitizeSessionSegment(cardBoardId(card), "default");
|
||||
const cardId = sanitizeSessionSegment(card.id, "card");
|
||||
const suffix = `subagent:workboard-${boardId}-${cardId}`;
|
||||
return card.agentId ? `agent:${sanitizeSessionSegment(card.agentId, "agent")}:${suffix}` : suffix;
|
||||
}
|
||||
|
||||
function buildExecution(params: {
|
||||
card: WorkboardCard;
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
model: string;
|
||||
now: number;
|
||||
}): WorkboardExecution {
|
||||
return {
|
||||
id: params.card.execution?.id ?? `${params.card.id}:codex`,
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: params.model,
|
||||
sessionKey: params.sessionKey,
|
||||
runId: params.runId,
|
||||
startedAt: params.now,
|
||||
updatedAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkerPrompt(params: {
|
||||
card: WorkboardCard;
|
||||
context: string;
|
||||
ownerId: string;
|
||||
token: string;
|
||||
}): string {
|
||||
return [
|
||||
`Work on this OpenClaw Workboard card: ${params.card.title}`,
|
||||
"",
|
||||
"## Worker protocol",
|
||||
`Card id: ${params.card.id}`,
|
||||
`Claim ownerId: ${params.ownerId}`,
|
||||
`Claim token: ${params.token}`,
|
||||
"",
|
||||
"Heartbeat with workboard_heartbeat using the card id and token while working.",
|
||||
"When done, call workboard_complete with the card id, token, summary, and proof.",
|
||||
"If blocked, call workboard_block with the card id, token, and reason.",
|
||||
"",
|
||||
params.context,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sortReadyCards(a: WorkboardCard, b: WorkboardCard): number {
|
||||
const priorityRank: Record<WorkboardCard["priority"], number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
};
|
||||
return (
|
||||
priorityRank[a.priority] - priorityRank[b.priority] ||
|
||||
a.position - b.position ||
|
||||
a.createdAt - b.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
function selectStartableCards(
|
||||
cards: WorkboardCard[],
|
||||
limit: number,
|
||||
candidates: WorkboardCard[] = cards,
|
||||
): WorkboardCard[] {
|
||||
if (limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
const runningByOwner = new Map<string, number>();
|
||||
for (const card of cards) {
|
||||
const consumesOwnerSlot =
|
||||
card.status === "running" ||
|
||||
Boolean(card.metadata?.claim) ||
|
||||
card.execution?.status === "running";
|
||||
if (!consumesOwnerSlot || cardIsArchived(card)) {
|
||||
continue;
|
||||
}
|
||||
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
|
||||
runningByOwner.set(owner, (runningByOwner.get(owner) ?? 0) + 1);
|
||||
}
|
||||
const selected: WorkboardCard[] = [];
|
||||
for (const card of candidates
|
||||
.filter((entry) => entry.status === "ready" && !entry.metadata?.claim && !cardIsArchived(entry))
|
||||
.toSorted(sortReadyCards)) {
|
||||
const owner = card.agentId ?? DEFAULT_DISPATCH_OWNER;
|
||||
if ((runningByOwner.get(owner) ?? 0) > 0) {
|
||||
continue;
|
||||
}
|
||||
selected.push(card);
|
||||
runningByOwner.set(owner, 1);
|
||||
if (selected.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function dispatchAndStartWorkboardCards(params: {
|
||||
store: WorkboardStore;
|
||||
subagent: WorkboardSubagentRuntime;
|
||||
options?: WorkboardDispatchStartOptions;
|
||||
}): Promise<WorkboardDispatchAndStartResult> {
|
||||
const now = params.options?.now ?? Date.now();
|
||||
const boardId = params.options?.boardId;
|
||||
const dispatch = await params.store.dispatch({ now, boardId });
|
||||
const maxStarts = normalizePositiveInteger(
|
||||
params.options?.maxStarts,
|
||||
DEFAULT_DISPATCH_MAX_STARTS,
|
||||
);
|
||||
const started: WorkboardStartedRun[] = [];
|
||||
const startFailures: WorkboardStartFailure[] = [];
|
||||
const model = params.options?.model?.trim() || DEFAULT_DISPATCH_MODEL;
|
||||
const cards = await params.store.list();
|
||||
const candidates = await params.store.list({ boardId });
|
||||
|
||||
for (const card of selectStartableCards(cards, maxStarts, candidates)) {
|
||||
const ownerId = params.options?.ownerId?.trim() || card.agentId || DEFAULT_DISPATCH_OWNER;
|
||||
const sessionKey = buildSessionKey(card);
|
||||
let token = "";
|
||||
try {
|
||||
const claimed = await params.store.claim(card.id, {
|
||||
ownerId,
|
||||
ttlSeconds: card.metadata?.automation?.maxRuntimeSeconds,
|
||||
});
|
||||
token = claimed.token;
|
||||
const context = await params.store.buildWorkerContext(card.id);
|
||||
const run = await params.subagent.run({
|
||||
sessionKey,
|
||||
message: buildWorkerPrompt({
|
||||
card: claimed.card,
|
||||
context,
|
||||
ownerId,
|
||||
token,
|
||||
}),
|
||||
...(params.options?.provider ? { provider: params.options.provider } : {}),
|
||||
...(params.options?.model ? { model: params.options.model } : {}),
|
||||
lane: `workboard:${cardBoardId(card)}:${card.id}`,
|
||||
idempotencyKey: `workboard:${card.id}:${claimed.card.updatedAt}`,
|
||||
lightContext: true,
|
||||
deliver: false,
|
||||
});
|
||||
const updated = await params.store.update(card.id, {
|
||||
sessionKey,
|
||||
runId: run.runId,
|
||||
execution: buildExecution({
|
||||
card: claimed.card,
|
||||
sessionKey,
|
||||
runId: run.runId,
|
||||
model,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
await params.store.addWorkerLog(
|
||||
updated.id,
|
||||
{
|
||||
level: "info",
|
||||
message: `Dispatcher started subagent run ${run.runId}.`,
|
||||
sessionKey,
|
||||
runId: run.runId,
|
||||
},
|
||||
{ ownerId, token },
|
||||
);
|
||||
started.push({
|
||||
cardId: updated.id,
|
||||
title: updated.title,
|
||||
sessionKey,
|
||||
runId: run.runId,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
startFailures.push({ cardId: card.id, title: card.title, error: message });
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await params.store.block(
|
||||
card.id,
|
||||
{
|
||||
ownerId,
|
||||
token,
|
||||
reason: `Dispatcher could not start worker: ${message}`,
|
||||
},
|
||||
{ ownerId, token },
|
||||
);
|
||||
} catch {
|
||||
// Leave the original start failure visible; dispatch will diagnose stale claims later.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...dispatch,
|
||||
started,
|
||||
startFailures,
|
||||
count: dispatch.count + started.length + startFailures.length,
|
||||
};
|
||||
}
|
||||
355
extensions/workboard/src/gateway.test.ts
Normal file
355
extensions/workboard/src/gateway.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
// Workboard tests cover gateway plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import { registerWorkboardGatewayMethods } from "./gateway.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
||||
|
||||
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
||||
const entries = new Map<string, T>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async lookup(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workboard gateway methods", () => {
|
||||
it("registers CRUD methods with read/write scopes", async () => {
|
||||
type RegisteredMethod = {
|
||||
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
||||
};
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => createMemoryStore()),
|
||||
},
|
||||
},
|
||||
registerGatewayMethod: vi.fn(
|
||||
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
||||
methods.set(method, { handler, opts });
|
||||
},
|
||||
),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
||||
|
||||
expect([...methods.keys()]).toEqual([
|
||||
"workboard.cards.list",
|
||||
"workboard.cards.create",
|
||||
"workboard.cards.update",
|
||||
"workboard.cards.move",
|
||||
"workboard.cards.delete",
|
||||
"workboard.cards.comment",
|
||||
"workboard.cards.link",
|
||||
"workboard.cards.linkDependency",
|
||||
"workboard.cards.proof",
|
||||
"workboard.cards.artifact",
|
||||
"workboard.cards.claim",
|
||||
"workboard.cards.heartbeat",
|
||||
"workboard.cards.release",
|
||||
"workboard.cards.promote",
|
||||
"workboard.cards.reassign",
|
||||
"workboard.cards.reclaim",
|
||||
"workboard.cards.complete",
|
||||
"workboard.cards.block",
|
||||
"workboard.cards.unblock",
|
||||
"workboard.cards.bulk",
|
||||
"workboard.cards.diagnostics",
|
||||
"workboard.cards.diagnostics.refresh",
|
||||
"workboard.cards.dispatch",
|
||||
"workboard.boards.list",
|
||||
"workboard.boards.upsert",
|
||||
"workboard.boards.archive",
|
||||
"workboard.boards.delete",
|
||||
"workboard.cards.stats",
|
||||
"workboard.cards.runs",
|
||||
"workboard.cards.specify",
|
||||
"workboard.cards.decompose",
|
||||
"workboard.notifications.subscribe",
|
||||
"workboard.notifications.list",
|
||||
"workboard.notifications.delete",
|
||||
"workboard.notifications.events",
|
||||
"workboard.notifications.advance",
|
||||
"workboard.cards.attachments.list",
|
||||
"workboard.cards.attachments.get",
|
||||
"workboard.cards.attachments.add",
|
||||
"workboard.cards.attachments.delete",
|
||||
"workboard.cards.workerLog",
|
||||
"workboard.cards.protocolViolation",
|
||||
"workboard.cards.archive",
|
||||
"workboard.cards.export",
|
||||
]);
|
||||
expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" });
|
||||
expect(methods.get("workboard.cards.diagnostics")?.opts).toEqual({ scope: "operator.read" });
|
||||
expect(methods.get("workboard.cards.diagnostics.refresh")?.opts).toEqual({
|
||||
scope: "operator.write",
|
||||
});
|
||||
expect(methods.get("workboard.cards.export")?.opts).toEqual({ scope: "operator.read" });
|
||||
expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" });
|
||||
expect(methods.get("workboard.cards.runs")?.opts).toEqual({ scope: "operator.read" });
|
||||
expect(methods.get("workboard.cards.attachments.get")?.opts).toEqual({
|
||||
scope: "operator.read",
|
||||
});
|
||||
expect(methods.get("workboard.cards.attachments.add")?.opts).toEqual({
|
||||
scope: "operator.write",
|
||||
});
|
||||
expect(methods.get("workboard.boards.upsert")?.opts).toEqual({ scope: "operator.write" });
|
||||
expect(methods.get("workboard.notifications.list")?.opts).toEqual({
|
||||
scope: "operator.read",
|
||||
});
|
||||
expect(methods.get("workboard.notifications.events")?.opts).toEqual({
|
||||
scope: "operator.read",
|
||||
});
|
||||
expect(methods.get("workboard.notifications.advance")?.opts).toEqual({
|
||||
scope: "operator.write",
|
||||
});
|
||||
|
||||
const createHandler = methods.get("workboard.cards.create")?.handler;
|
||||
const listHandler = methods.get("workboard.cards.list")?.handler;
|
||||
const createRespond = vi.fn();
|
||||
await createHandler?.({
|
||||
params: { title: "Investigate queue drift", priority: "urgent" },
|
||||
respond: createRespond,
|
||||
} as never);
|
||||
expect(createRespond.mock.calls[0]?.[0]).toBe(true);
|
||||
|
||||
const listRespond = vi.fn();
|
||||
await listHandler?.({ params: {}, respond: listRespond } as never);
|
||||
expect(listRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
cards: [expect.objectContaining({ title: "Investigate queue drift" })],
|
||||
});
|
||||
|
||||
const eventsRespond = vi.fn();
|
||||
await methods.get("workboard.notifications.events")?.handler({
|
||||
params: { advance: true },
|
||||
respond: eventsRespond,
|
||||
} as never);
|
||||
expect(eventsRespond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(eventsRespond.mock.calls[0]?.[2]?.message).toContain("workboard.notifications.advance");
|
||||
});
|
||||
|
||||
it("stores metadata updates through dedicated card methods", async () => {
|
||||
type RegisteredMethod = {
|
||||
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
||||
};
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => createMemoryStore()),
|
||||
},
|
||||
},
|
||||
registerGatewayMethod: vi.fn(
|
||||
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
||||
methods.set(method, { handler, opts });
|
||||
},
|
||||
),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
||||
|
||||
const createRespond = vi.fn();
|
||||
await methods.get("workboard.cards.create")?.handler({
|
||||
params: { title: "Carry metadata" },
|
||||
respond: createRespond,
|
||||
} as never);
|
||||
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
|
||||
|
||||
const commentRespond = vi.fn();
|
||||
await methods.get("workboard.cards.comment")?.handler({
|
||||
params: { id: cardId, body: "Waiting on CI" },
|
||||
respond: commentRespond,
|
||||
} as never);
|
||||
|
||||
expect(commentRespond.mock.calls[0]?.[0]).toBe(true);
|
||||
expect(commentRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
card: {
|
||||
metadata: {
|
||||
comments: [expect.objectContaining({ body: "Waiting on CI" })],
|
||||
},
|
||||
events: expect.arrayContaining([expect.objectContaining({ kind: "comment_added" })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates labels from comma-separated gateway input", async () => {
|
||||
type RegisteredMethod = {
|
||||
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
||||
};
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => createMemoryStore()),
|
||||
},
|
||||
},
|
||||
registerGatewayMethod: vi.fn(
|
||||
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
||||
methods.set(method, { handler, opts });
|
||||
},
|
||||
),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
||||
|
||||
const createHandler = methods.get("workboard.cards.create")?.handler;
|
||||
const respond = vi.fn();
|
||||
await createHandler?.({
|
||||
params: { title: "Check labels", labels: `valid, ${"x".repeat(41)}` },
|
||||
respond,
|
||||
} as never);
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(respond.mock.calls[0]?.[2]).toMatchObject({
|
||||
message: "labels must be 40 characters or fewer.",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches workboard cards when gateway params are omitted", async () => {
|
||||
type RegisteredMethod = {
|
||||
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
||||
};
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const run = vi.fn().mockResolvedValue({ runId: "run-card" });
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => createMemoryStore()),
|
||||
},
|
||||
subagent: { run },
|
||||
},
|
||||
registerGatewayMethod: vi.fn(
|
||||
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
||||
methods.set(method, { handler, opts });
|
||||
},
|
||||
),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({
|
||||
title: "Ready worker",
|
||||
status: "ready",
|
||||
priority: "urgent",
|
||||
});
|
||||
|
||||
registerWorkboardGatewayMethods({ api, store });
|
||||
|
||||
const respond = vi.fn();
|
||||
await methods.get("workboard.cards.dispatch")?.handler({ respond } as never);
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
expect(respond.mock.calls[0]?.[1]).toMatchObject({
|
||||
started: [expect.objectContaining({ cardId: card.id, runId: "run-card" })],
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: `subagent:workboard-default-${card.id}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("claims, heartbeats, and bulk-updates cards through gateway methods", async () => {
|
||||
type RegisteredMethod = {
|
||||
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
||||
};
|
||||
const methods = new Map<string, RegisteredMethod>();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => createMemoryStore()),
|
||||
},
|
||||
},
|
||||
registerGatewayMethod: vi.fn(
|
||||
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
||||
methods.set(method, { handler, opts });
|
||||
},
|
||||
),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
||||
|
||||
const createRespond = vi.fn();
|
||||
await methods.get("workboard.cards.create")?.handler({
|
||||
params: { title: "Claim me" },
|
||||
respond: createRespond,
|
||||
} as never);
|
||||
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
|
||||
|
||||
const claimRespond = vi.fn();
|
||||
await methods.get("workboard.cards.claim")?.handler({
|
||||
params: { id: cardId, ownerId: "main" },
|
||||
respond: claimRespond,
|
||||
} as never);
|
||||
expect(claimRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
card: { status: "running", metadata: { claim: { ownerId: "main" } } },
|
||||
token: expect.any(String),
|
||||
});
|
||||
|
||||
const heartbeatRespond = vi.fn();
|
||||
await methods.get("workboard.cards.heartbeat")?.handler({
|
||||
params: { id: cardId, ownerId: "main", note: "alive" },
|
||||
respond: heartbeatRespond,
|
||||
} as never);
|
||||
expect(heartbeatRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
card: { metadata: { comments: [expect.objectContaining({ body: "alive" })] } },
|
||||
});
|
||||
|
||||
const bulkRespond = vi.fn();
|
||||
await methods.get("workboard.cards.bulk")?.handler({
|
||||
params: { ids: [cardId], patch: { priority: "urgent" } },
|
||||
respond: bulkRespond,
|
||||
} as never);
|
||||
expect(bulkRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
cards: [expect.objectContaining({ priority: "urgent" })],
|
||||
});
|
||||
|
||||
const completeRespond = vi.fn();
|
||||
await methods.get("workboard.cards.complete")?.handler({
|
||||
params: { id: cardId, summary: "Operator closed it." },
|
||||
respond: completeRespond,
|
||||
} as never);
|
||||
expect(completeRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
card: {
|
||||
status: "done",
|
||||
metadata: {
|
||||
comments: expect.arrayContaining([
|
||||
expect.objectContaining({ body: "Operator closed it." }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const blockedCreateRespond = vi.fn();
|
||||
await methods.get("workboard.cards.create")?.handler({
|
||||
params: { title: "Block me" },
|
||||
respond: blockedCreateRespond,
|
||||
} as never);
|
||||
const blockedCardId = blockedCreateRespond.mock.calls[0]?.[1]?.card.id;
|
||||
await methods.get("workboard.cards.claim")?.handler({
|
||||
params: { id: blockedCardId, ownerId: "main" },
|
||||
respond: vi.fn(),
|
||||
} as never);
|
||||
const blockRespond = vi.fn();
|
||||
await methods.get("workboard.cards.block")?.handler({
|
||||
params: { id: blockedCardId, reason: "Operator blocked it." },
|
||||
respond: blockRespond,
|
||||
} as never);
|
||||
expect(blockRespond.mock.calls[0]?.[1]).toMatchObject({
|
||||
card: { status: "blocked" },
|
||||
});
|
||||
});
|
||||
});
|
||||
700
extensions/workboard/src/gateway.ts
Normal file
700
extensions/workboard/src/gateway.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
// Workboard plugin module implements gateway behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import { dispatchAndStartWorkboardCards } from "./dispatcher.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;
|
||||
|
||||
type GatewayMethodContext = Parameters<
|
||||
Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]
|
||||
>[0];
|
||||
type GatewayRespond = GatewayMethodContext["respond"];
|
||||
|
||||
function respondError(respond: GatewayRespond, error: unknown) {
|
||||
respond(false, undefined, {
|
||||
code: "workboard_error",
|
||||
message: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
function readId(params: Record<string, unknown>): string {
|
||||
const value = params.id;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
throw new Error("id is required.");
|
||||
}
|
||||
|
||||
function readPatch(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const patch = params.patch;
|
||||
if (patch && typeof patch === "object" && !Array.isArray(patch)) {
|
||||
return patch as Record<string, unknown>;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function assertNoCursorAdvance(params: Record<string, unknown>) {
|
||||
if (params.advance === true) {
|
||||
throw new Error("notification cursor advancement requires workboard.notifications.advance.");
|
||||
}
|
||||
}
|
||||
|
||||
function redactClaimToken(card: WorkboardCard): WorkboardCard {
|
||||
const claim = card.metadata?.claim;
|
||||
if (!claim) {
|
||||
return card;
|
||||
}
|
||||
return {
|
||||
...card,
|
||||
metadata: {
|
||||
...card.metadata,
|
||||
claim: { ...claim, token: "[redacted]" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function redactDiagnosticsRows(result: Awaited<ReturnType<WorkboardStore["diagnostics"]>>) {
|
||||
return {
|
||||
...result,
|
||||
diagnostics: result.diagnostics.map((row) => ({
|
||||
...row,
|
||||
card: redactClaimToken(row.card),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWorkboardGatewayMethods(params: {
|
||||
api: OpenClawPluginApi;
|
||||
store?: WorkboardStore;
|
||||
}) {
|
||||
const { api } = params;
|
||||
const store = params.store ?? WorkboardStore.openSqlite();
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.list",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
cards: (await store.list({ boardId: requestParams.boardId })).map(redactClaimToken),
|
||||
statuses: WORKBOARD_STATUSES,
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.create",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, { card: redactClaimToken(await store.create(requestParams)) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.update",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(
|
||||
await store.update(readId(requestParams), readPatch(requestParams)),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.move",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(
|
||||
await store.move(readId(requestParams), requestParams.status, requestParams.position),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.delete",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.delete(readId(requestParams)));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.comment",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addComment(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.link",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addLink(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.linkDependency",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const parentId = requestParams.parentId;
|
||||
const childId = requestParams.childId;
|
||||
if (typeof parentId !== "string" || typeof childId !== "string") {
|
||||
throw new Error("parentId and childId are required.");
|
||||
}
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.linkCards(parentId, childId)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.proof",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addProof(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.artifact",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addArtifact(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.claim",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const claimed = await store.claim(readId(requestParams), requestParams);
|
||||
respond(true, { ...claimed, card: redactClaimToken(claimed.card) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.heartbeat",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.heartbeat(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.release",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.releaseClaim(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.promote",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.promote(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.reassign",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.reassign(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.reclaim",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.reclaim(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.complete",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.complete(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.block",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.block(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.unblock",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.unblock(readId(requestParams))),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.bulk",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const result = await store.bulkUpdate(requestParams);
|
||||
respond(true, { cards: result.cards.map(redactClaimToken) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.diagnostics",
|
||||
async ({ respond }) => {
|
||||
try {
|
||||
respond(true, redactDiagnosticsRows(await store.diagnostics()));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.diagnostics.refresh",
|
||||
async ({ respond }) => {
|
||||
try {
|
||||
respond(true, redactDiagnosticsRows(await store.refreshDiagnostics()));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.dispatch",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const boardId =
|
||||
requestParams && typeof requestParams === "object" && "boardId" in requestParams
|
||||
? requestParams.boardId
|
||||
: undefined;
|
||||
const result = await dispatchAndStartWorkboardCards({
|
||||
store,
|
||||
subagent: api.runtime.subagent,
|
||||
options: {
|
||||
boardId: typeof boardId === "string" ? boardId : undefined,
|
||||
},
|
||||
});
|
||||
respond(true, {
|
||||
...result,
|
||||
promoted: result.promoted.map(redactClaimToken),
|
||||
reclaimed: result.reclaimed.map(redactClaimToken),
|
||||
blocked: result.blocked.map(redactClaimToken),
|
||||
orchestrated: result.orchestrated.map(redactClaimToken),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.boards.list",
|
||||
async ({ respond }) => {
|
||||
try {
|
||||
respond(true, await store.listBoards());
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.boards.upsert",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, { board: await store.upsertBoard(requestParams) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.boards.archive",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
board: await store.archiveBoard(requestParams.id, requestParams.archived),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.boards.delete",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.deleteBoard(requestParams.id));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.stats",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.stats({ boardId: requestParams.boardId }));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.runs",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const result = await store.runs(readId(requestParams));
|
||||
respond(true, { ...result, card: redactClaimToken(result.card) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.specify",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.specify(readId(requestParams), requestParams, null)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.decompose",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const result = await store.decompose(readId(requestParams), requestParams, null);
|
||||
respond(true, {
|
||||
parent: redactClaimToken(result.parent),
|
||||
children: result.children.map(redactClaimToken),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.notifications.subscribe",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, { subscription: await store.subscribeNotifications(requestParams) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.notifications.list",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.listNotificationSubscriptions(requestParams));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.notifications.delete",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.deleteNotificationSubscription(readId(requestParams)));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.notifications.events",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
assertNoCursorAdvance(requestParams);
|
||||
respond(true, await store.notificationEvents(requestParams));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.notifications.advance",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, await store.advanceNotificationEvents(requestParams));
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.attachments.list",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const result = await store.listAttachments(readId(requestParams));
|
||||
respond(true, { ...result, card: redactClaimToken(result.card) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.attachments.get",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const attachment = await store.getAttachment(readId(requestParams));
|
||||
if (!attachment) {
|
||||
throw new Error(`attachment not found: ${readId(requestParams)}`);
|
||||
}
|
||||
respond(true, attachment);
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.attachments.add",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addAttachment(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.attachments.delete",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
const attachmentId = requestParams.attachmentId;
|
||||
if (typeof attachmentId !== "string" || !attachmentId.trim()) {
|
||||
throw new Error("attachmentId is required.");
|
||||
}
|
||||
respond(true, {
|
||||
card: redactClaimToken(
|
||||
await store.deleteAttachment(readId(requestParams), attachmentId.trim()),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.workerLog",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(await store.addWorkerLog(readId(requestParams), requestParams)),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.protocolViolation",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(
|
||||
await store.recordProtocolViolation(readId(requestParams), requestParams),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.archive",
|
||||
async ({ params: requestParams, respond }) => {
|
||||
try {
|
||||
respond(true, {
|
||||
card: redactClaimToken(
|
||||
await store.archive(readId(requestParams), requestParams.archived),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: WRITE_SCOPE },
|
||||
);
|
||||
|
||||
api.registerGatewayMethod(
|
||||
"workboard.cards.export",
|
||||
async ({ respond }) => {
|
||||
try {
|
||||
const exported = await store.exportCards();
|
||||
respond(true, { ...exported, cards: exported.cards.map(redactClaimToken) });
|
||||
} catch (error) {
|
||||
respondError(respond, error);
|
||||
}
|
||||
},
|
||||
{ scope: READ_SCOPE },
|
||||
);
|
||||
}
|
||||
35
extensions/workboard/src/persistence-types.ts
Normal file
35
extensions/workboard/src/persistence-types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Workboard plugin module implements persistence types behavior.
|
||||
import type {
|
||||
WorkboardAttachment,
|
||||
WorkboardBoardMetadata,
|
||||
WorkboardCard,
|
||||
WorkboardNotificationSubscription,
|
||||
} from "./types.js";
|
||||
|
||||
export type PersistedWorkboardCard = {
|
||||
version: 1;
|
||||
card: WorkboardCard;
|
||||
};
|
||||
|
||||
export type PersistedWorkboardBoard = {
|
||||
version: 1;
|
||||
board: WorkboardBoardMetadata;
|
||||
};
|
||||
|
||||
export type PersistedWorkboardNotificationSubscription = {
|
||||
version: 1;
|
||||
subscription: WorkboardNotificationSubscription;
|
||||
};
|
||||
|
||||
export type PersistedWorkboardAttachment = {
|
||||
version: 1;
|
||||
attachment: WorkboardAttachment;
|
||||
contentBase64: string;
|
||||
};
|
||||
|
||||
export type WorkboardKeyedStore<T = PersistedWorkboardCard> = {
|
||||
register(key: string, value: T): Promise<void>;
|
||||
lookup(key: string): Promise<T | undefined>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
entries(): Promise<Array<{ key: string; value: T }>>;
|
||||
};
|
||||
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
|
||||
close: vi.fn(),
|
||||
configureSqliteConnectionPragmas: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:sqlite", () => ({
|
||||
DatabaseSync: vi.fn(function DatabaseSync() {
|
||||
return { close };
|
||||
}),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
|
||||
configureSqliteConnectionPragmas,
|
||||
}));
|
||||
|
||||
import { createWorkboardSqliteStores } from "./sqlite-store.js";
|
||||
|
||||
describe("Workboard SQLite policy", () => {
|
||||
beforeEach(() => {
|
||||
close.mockClear();
|
||||
configureSqliteConnectionPragmas.mockReset();
|
||||
});
|
||||
|
||||
it("closes a newly opened database when filesystem policy refuses it", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
configureSqliteConnectionPragmas.mockImplementation(() => {
|
||||
throw new Error("SSHFS is unsupported");
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
1430
extensions/workboard/src/sqlite-store.ts
Normal file
1430
extensions/workboard/src/sqlite-store.ts
Normal file
File diff suppressed because it is too large
Load Diff
2720
extensions/workboard/src/store.test.ts
Normal file
2720
extensions/workboard/src/store.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
4341
extensions/workboard/src/store.ts
Normal file
4341
extensions/workboard/src/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
465
extensions/workboard/src/tools.test.ts
Normal file
465
extensions/workboard/src/tools.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
// Workboard tests cover tools plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
||||
import { createWorkboardTools } from "./tools.js";
|
||||
|
||||
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
||||
const entries = new Map<string, T>();
|
||||
return {
|
||||
async register(key, value) {
|
||||
entries.set(key, value);
|
||||
},
|
||||
async lookup(key) {
|
||||
return entries.get(key);
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readPayload(result: unknown): Record<string, unknown> {
|
||||
return (result as { details?: Record<string, unknown> }).details ?? {};
|
||||
}
|
||||
|
||||
describe("workboard tools", () => {
|
||||
it("lists, claims, heartbeats, and reads worker context", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const workboardStore = new WorkboardStore(keyed);
|
||||
const tools = createWorkboardTools({
|
||||
api,
|
||||
store: workboardStore,
|
||||
context: { agentId: "main", sessionKey: "session-1" } as never,
|
||||
});
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
|
||||
const store = keyed;
|
||||
await store.register("card-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "card-1",
|
||||
title: "Ship coordination",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
agentId: "main",
|
||||
position: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
await store.register("archived-1", {
|
||||
version: 1,
|
||||
card: {
|
||||
id: "archived-1",
|
||||
title: "Closed work",
|
||||
status: "done",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 2000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata: { archivedAt: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
const claimed = readPayload(
|
||||
await byName.get("workboard_claim")?.execute("call-1", { id: "card-1" }),
|
||||
);
|
||||
expect(claimed.card).toMatchObject({
|
||||
status: "running",
|
||||
metadata: { claim: { ownerId: "main", token: "[redacted]" } },
|
||||
});
|
||||
const token = (claimed.token as string | undefined) ?? "";
|
||||
|
||||
const heartbeat = readPayload(
|
||||
await byName
|
||||
.get("workboard_heartbeat")
|
||||
?.execute("call-2", { id: "card-1", token, note: "alive" }),
|
||||
);
|
||||
expect(heartbeat).toMatchObject({
|
||||
metadata: { comments: [expect.objectContaining({ body: "alive" })] },
|
||||
});
|
||||
|
||||
const read = readPayload(
|
||||
await byName.get("workboard_read")?.execute("call-3", { id: "card-1" }),
|
||||
);
|
||||
expect(read.workerContext).toContain("Ship coordination");
|
||||
expect(read.card).toMatchObject({ metadata: { claim: { token: "[redacted]" } } });
|
||||
|
||||
const released = readPayload(
|
||||
await byName
|
||||
.get("workboard_release")
|
||||
?.execute("call-4", { id: "card-1", token, status: "review" }),
|
||||
);
|
||||
expect(released).toMatchObject({ status: "review" });
|
||||
expect((released.metadata as { claim?: unknown } | undefined)?.claim).toBeUndefined();
|
||||
|
||||
const list = readPayload(await byName.get("workboard_list")?.execute("call-5", {}));
|
||||
expect(list.cards).toEqual([expect.objectContaining({ id: "card-1" })]);
|
||||
const archivedList = readPayload(
|
||||
await byName.get("workboard_list")?.execute("call-6", { includeArchived: true }),
|
||||
);
|
||||
expect(archivedList.cards).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "archived-1", archivedAt: 2 })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("can share one store across tool instances for claim coordination", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(keyed);
|
||||
const mainTools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "main" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const otherTools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "other" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const card = await store.create({ title: "Single owner" });
|
||||
|
||||
await mainTools.get("workboard_claim")?.execute("call-1", { id: card.id });
|
||||
|
||||
await expect(
|
||||
otherTools.get("workboard_claim")?.execute("call-2", { id: card.id }),
|
||||
).rejects.toThrow(/already claimed/);
|
||||
});
|
||||
|
||||
it("requires claim scope before creating or linking dependencies against claimed cards", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(keyed);
|
||||
const mainTools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "main" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const otherTools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "other" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const parent = await store.create({ title: "Claimed parent" });
|
||||
const claimed = await store.claim(parent.id, { ownerId: "main", token: "parent-token" });
|
||||
|
||||
await expect(
|
||||
otherTools.get("workboard_create")?.execute("call-1", {
|
||||
title: "Blocked child",
|
||||
parents: [parent.id],
|
||||
}),
|
||||
).rejects.toThrow(/claimed by main/);
|
||||
await expect(
|
||||
otherTools.get("workboard_create")?.execute("call-1b", {
|
||||
title: "Blocked child",
|
||||
parents: parent.id,
|
||||
}),
|
||||
).rejects.toThrow(/claimed by main/);
|
||||
expect(await store.list()).toHaveLength(1);
|
||||
|
||||
await otherTools.get("workboard_create")?.execute("call-2", {
|
||||
title: "Scoped child",
|
||||
parents: [parent.id],
|
||||
token: claimed.token,
|
||||
});
|
||||
const child = await store.create({ title: "Claimed child" });
|
||||
await store.claim(child.id, { ownerId: "main", token: "child-token" });
|
||||
await expect(
|
||||
otherTools.get("workboard_link")?.execute("call-3", {
|
||||
parentId: parent.id,
|
||||
childId: child.id,
|
||||
}),
|
||||
).rejects.toThrow(/claimed by main/);
|
||||
|
||||
const linked = readPayload(
|
||||
await otherTools.get("workboard_link")?.execute("call-4", {
|
||||
parentId: parent.id,
|
||||
childId: (await store.create({ title: "Idle child" })).id,
|
||||
token: claimed.token,
|
||||
}),
|
||||
);
|
||||
expect(linked.card).toMatchObject({ status: "todo" });
|
||||
|
||||
await expect(
|
||||
mainTools.get("workboard_link")?.execute("call-5", {
|
||||
parentId: parent.id,
|
||||
childId: child.id,
|
||||
token: "child-token",
|
||||
}),
|
||||
).rejects.toThrow(/active child/);
|
||||
});
|
||||
|
||||
it("creates dependent cards and completes claimed work through tools", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(keyed);
|
||||
const tools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "main" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
|
||||
const parentPayload = readPayload(
|
||||
await tools.get("workboard_create")?.execute("call-1", {
|
||||
title: "Parent",
|
||||
status: "running",
|
||||
}),
|
||||
);
|
||||
const parent = parentPayload.card as { id: string };
|
||||
const childPayload = readPayload(
|
||||
await tools.get("workboard_create")?.execute("call-2", {
|
||||
title: "Child",
|
||||
parents: [parent.id],
|
||||
tenant: "qa",
|
||||
skills: ["testing"],
|
||||
}),
|
||||
);
|
||||
const child = childPayload.card as { id: string; status: string };
|
||||
expect(child.status).toBe("todo");
|
||||
|
||||
await expect(
|
||||
tools.get("workboard_complete")?.execute("call-unclaimed-complete", {
|
||||
id: parent.id,
|
||||
summary: "Too early.",
|
||||
}),
|
||||
).rejects.toThrow(/claimed/);
|
||||
await expect(
|
||||
tools.get("workboard_block")?.execute("call-unclaimed-block", {
|
||||
id: child.id,
|
||||
reason: "Too early.",
|
||||
}),
|
||||
).rejects.toThrow(/claimed/);
|
||||
await expect(
|
||||
tools.get("workboard_protocol_violation")?.execute("call-unclaimed-violation", {
|
||||
id: child.id,
|
||||
detail: "Too early.",
|
||||
}),
|
||||
).rejects.toThrow(/claimed/);
|
||||
|
||||
const claimed = readPayload(
|
||||
await tools.get("workboard_claim")?.execute("call-3", { id: parent.id }),
|
||||
);
|
||||
const token = claimed.token as string;
|
||||
const completed = readPayload(
|
||||
await tools.get("workboard_complete")?.execute("call-4", {
|
||||
id: parent.id,
|
||||
token,
|
||||
summary: "Done.",
|
||||
createdCardIds: [child.id],
|
||||
proof: { status: "passed", command: "pnpm test extensions/workboard" },
|
||||
}),
|
||||
);
|
||||
expect(completed.card).toMatchObject({ status: "done" });
|
||||
|
||||
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-5", {}));
|
||||
expect(dispatch.promoted).toEqual([expect.objectContaining({ id: child.id, status: "ready" })]);
|
||||
});
|
||||
|
||||
it("redacts claim tokens from dispatch tool results", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(keyed);
|
||||
const tools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "main" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const card = await store.create({
|
||||
title: "Scheduled",
|
||||
status: "scheduled",
|
||||
scheduledAt: 1,
|
||||
});
|
||||
await store.update(card.id, {
|
||||
metadata: {
|
||||
...card.metadata,
|
||||
claim: {
|
||||
ownerId: "main",
|
||||
token: "secret-token",
|
||||
claimedAt: 1,
|
||||
lastHeartbeatAt: 1,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dispatch = readPayload(await tools.get("workboard_dispatch")?.execute("call-1", {}));
|
||||
|
||||
const promoted = dispatch.promoted as Array<{
|
||||
metadata?: { claim?: { token?: string } };
|
||||
}>;
|
||||
expect(promoted).toEqual([expect.objectContaining({ id: card.id })]);
|
||||
expect(promoted[0]?.metadata?.claim?.token).toBe("[redacted]");
|
||||
});
|
||||
|
||||
it("exposes board lifecycle, decomposition, runs, and notification tools", async () => {
|
||||
const keyed = createMemoryStore();
|
||||
const api = {
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => keyed),
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
const store = new WorkboardStore(keyed);
|
||||
const tools = new Map(
|
||||
createWorkboardTools({
|
||||
api,
|
||||
store,
|
||||
context: { agentId: "main" } as never,
|
||||
}).map((tool) => [tool.name, tool]),
|
||||
);
|
||||
|
||||
const boardPayload = readPayload(
|
||||
await tools.get("workboard_board_create")?.execute("call-board", {
|
||||
id: "planning",
|
||||
name: "Planning",
|
||||
orchestration: {
|
||||
autoDecompose: true,
|
||||
autoDecomposePerDispatch: 2,
|
||||
orchestratorProfile: "planner",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(boardPayload.board).toMatchObject({
|
||||
id: "planning",
|
||||
name: "Planning",
|
||||
orchestration: {
|
||||
autoDecompose: true,
|
||||
autoDecomposePerDispatch: 2,
|
||||
orchestratorProfile: "planner",
|
||||
},
|
||||
});
|
||||
|
||||
const parent = await store.create({
|
||||
title: "Rough",
|
||||
status: "triage",
|
||||
boardId: "planning",
|
||||
idempotencyKey: "planning:rough",
|
||||
});
|
||||
const specified = readPayload(
|
||||
await tools.get("workboard_specify")?.execute("call-specify", {
|
||||
id: parent.id,
|
||||
title: "Specified",
|
||||
summary: "Ready to split.",
|
||||
}),
|
||||
);
|
||||
expect(specified.card).toMatchObject({ title: "Specified", status: "todo" });
|
||||
|
||||
const decomposed = readPayload(
|
||||
await tools.get("workboard_decompose")?.execute("call-decompose", {
|
||||
id: parent.id,
|
||||
summary: "Split.",
|
||||
children: [{ title: "Child A" }, { title: "Child B" }],
|
||||
}),
|
||||
);
|
||||
expect(decomposed.parent).toMatchObject({ status: "done" });
|
||||
expect(decomposed.children).toEqual([
|
||||
expect.objectContaining({ title: "Child A" }),
|
||||
expect.objectContaining({ title: "Child B" }),
|
||||
]);
|
||||
|
||||
const runs = readPayload(
|
||||
await tools.get("workboard_runs")?.execute("call-runs", { id: parent.id }),
|
||||
);
|
||||
expect(runs.attempts).toEqual([]);
|
||||
|
||||
const subscription = readPayload(
|
||||
await tools.get("workboard_notify_subscribe")?.execute("call-subscribe", {
|
||||
boardId: "planning",
|
||||
cardId: parent.id,
|
||||
target: "session:operator",
|
||||
eventKinds: ["completed"],
|
||||
}),
|
||||
);
|
||||
expect(subscription.subscription).toMatchObject({
|
||||
boardId: "planning",
|
||||
cardId: parent.id,
|
||||
target: "session:operator",
|
||||
eventKinds: ["completed"],
|
||||
});
|
||||
|
||||
const list = readPayload(
|
||||
await tools.get("workboard_notify_list")?.execute("call-notify-list", {
|
||||
boardId: "planning",
|
||||
}),
|
||||
);
|
||||
expect(list.subscriptions).toEqual([
|
||||
expect.objectContaining({ cardId: parent.id, target: "session:operator" }),
|
||||
]);
|
||||
|
||||
const events = readPayload(
|
||||
await tools.get("workboard_notify_advance")?.execute("call-notify-events", {
|
||||
subscriptionId: (subscription.subscription as { id: string }).id,
|
||||
}),
|
||||
);
|
||||
expect(events.events).toEqual([expect.objectContaining({ kind: "completed" })]);
|
||||
|
||||
const attached = readPayload(
|
||||
await tools.get("workboard_attachment_add")?.execute("call-attach", {
|
||||
id: parent.id,
|
||||
fileName: "result.txt",
|
||||
contentBase64: Buffer.from("done").toString("base64"),
|
||||
}),
|
||||
);
|
||||
expect(attached.card).toMatchObject({
|
||||
metadata: { attachments: [expect.objectContaining({ fileName: "result.txt" })] },
|
||||
});
|
||||
const attachmentId = (attached.card as { metadata: { attachments: Array<{ id: string }> } })
|
||||
.metadata.attachments[0].id;
|
||||
const attachment = readPayload(
|
||||
await tools.get("workboard_attachment_read")?.execute("call-attachment-read", {
|
||||
id: attachmentId,
|
||||
}),
|
||||
);
|
||||
expect(Buffer.from(attachment.contentBase64 as string, "base64").toString("utf8")).toBe("done");
|
||||
});
|
||||
});
|
||||
1044
extensions/workboard/src/tools.ts
Normal file
1044
extensions/workboard/src/tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
330
extensions/workboard/src/types.ts
Normal file
330
extensions/workboard/src/types.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
// Workboard type declarations define plugin contracts.
|
||||
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_KINDS = [
|
||||
"stranded_ready",
|
||||
"running_without_heartbeat",
|
||||
"blocked_too_long",
|
||||
"repeated_failures",
|
||||
"missing_proof",
|
||||
"orphaned_session",
|
||||
] as const;
|
||||
export const WORKBOARD_DIAGNOSTIC_SEVERITIES = ["warning", "error", "critical"] as const;
|
||||
export const WORKBOARD_NOTIFICATION_KINDS = ["completed", "failed", "stale"] 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 WorkboardDiagnosticKind = (typeof WORKBOARD_DIAGNOSTIC_KINDS)[number];
|
||||
export type WorkboardDiagnosticSeverity = (typeof WORKBOARD_DIAGNOSTIC_SEVERITIES)[number];
|
||||
export type WorkboardNotificationKind = (typeof WORKBOARD_NOTIFICATION_KINDS)[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 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 WorkboardStaleState = {
|
||||
detectedAt: number;
|
||||
lastSessionUpdatedAt?: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type WorkboardClaim = {
|
||||
ownerId: string;
|
||||
token: string;
|
||||
claimedAt: number;
|
||||
lastHeartbeatAt: number;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type WorkboardDiagnosticAction = {
|
||||
kind: "claim" | "unblock" | "promote" | "reclaim" | "reassign" | "add_proof" | "open_session";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type WorkboardDiagnostic = {
|
||||
kind: WorkboardDiagnosticKind;
|
||||
severity: WorkboardDiagnosticSeverity;
|
||||
title: string;
|
||||
detail: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
count: number;
|
||||
actions: WorkboardDiagnosticAction[];
|
||||
};
|
||||
|
||||
export type WorkboardNotification = {
|
||||
id: string;
|
||||
kind: WorkboardNotificationKind;
|
||||
createdAt: number;
|
||||
sequence?: 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 WorkboardBoardMetadata = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
defaultWorkspace?: WorkboardWorkspace;
|
||||
orchestration?: WorkboardOrchestrationSettings;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
archivedAt?: number;
|
||||
};
|
||||
|
||||
export type WorkboardOrchestrationSettings = {
|
||||
autoDecompose?: boolean;
|
||||
autoDecomposePerDispatch?: number;
|
||||
defaultAssignee?: string;
|
||||
orchestratorProfile?: string;
|
||||
};
|
||||
|
||||
export type WorkboardNotificationSubscription = {
|
||||
id: string;
|
||||
boardId: string;
|
||||
cardId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
target?: string;
|
||||
eventKinds?: WorkboardNotificationKind[];
|
||||
lastEventAt?: number;
|
||||
lastEventId?: string;
|
||||
lastEventSequence?: number;
|
||||
deliveredEventIds?: string[];
|
||||
createdAt: number;
|
||||
updatedAt: 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 type WorkboardListResult = {
|
||||
cards: WorkboardCard[];
|
||||
statuses: readonly WorkboardStatus[];
|
||||
};
|
||||
Reference in New Issue
Block a user