feat(workboard): refresh Control UI from live changes (#99051)

* feat(workboard): refresh control ui from live changes

* refactor(workboard): tighten live invalidation path

* chore(workboard): preserve locale catalog state

* refactor(gateway): keep broadcaster runtime object

* refactor(plugin-sdk): own gateway event types

* test(workboard): bind reconciliation mocks

* test(release): declare recovery helpers

* fix(workboard): cancel direct loads on teardown
This commit is contained in:
Peter Steinberger
2026-07-14 05:17:23 -07:00
committed by GitHub
parent 2444013175
commit 9d79d85f46
43 changed files with 1347 additions and 768 deletions
@@ -1,2 +1,2 @@
291c9d0687821d30cee5d8f58c48bfa09c8dad4cd9899ba2db34da305649098c plugin-sdk-api-baseline.json
88fb3c123a521054f8c2219b71088aebf953f48976daa8d81ed04223afecd9eb plugin-sdk-api-baseline.jsonl
19c1ee1197669b1fb0524afd2c0856b8f95df19b7228d737e0fb0626951157d3 plugin-sdk-api-baseline.json
db26abd6ea9373ead3c0a0cd3542f4441c326a21c959ecbbd234c65b054ce5d0 plugin-sdk-api-baseline.jsonl
+19
View File
@@ -340,6 +340,25 @@ register(api) {
}
```
Long-lived services may emit small invalidation or lifecycle events through
their service context:
```typescript
api.registerService({
id: "index-events",
start(ctx) {
ctx.gatewayEvents?.emit("changed", { revision: 1 }, { scope: "operator.read" });
},
});
```
OpenClaw namespaces this as `plugin.<plugin-id>.changed`. Event names are one
lowercase segment, payloads must be bounded JSON, and the scope must be
`operator.read`, `operator.write`, or `operator.admin`. The emitter exists only
for the service lifetime and is revoked after stop or failed start. Prefer
version or invalidation payloads over full records so authorized clients reread
canonical state through the plugin's scoped Gateway methods.
Discovery mode builds a non-activating registry snapshot. It may still
evaluate the plugin entry and the channel plugin object so OpenClaw can
register channel capabilities and static CLI descriptors. Treat module
+8
View File
@@ -94,6 +94,14 @@ UI-only copy of the card. Unknown diagnostic kinds, diagnostic severities, and
notification kinds are ignored until both surfaces support them; they are never
rewritten into another valid state.
The open dashboard updates from `plugin.workboard.changed` invalidations. Each
event contains only a store epoch and revision; the UI then rereads canonical
cards through the normal `operator.read` RPC. Multiple revisions coalesce into
one follow-up read. Workboard defers that read while a card is being dragged,
edited, or written, then resumes after the local interaction finishes. A
reconnect always performs a canonical reload. There is no routine full-card
poll, and **Refresh** remains available as manual recovery.
Cards are stored in the plugin's own Gateway state and move with the rest of
that Gateway's OpenClaw state (see [Storage](#storage)).
+5 -1
View File
@@ -1,2 +1,6 @@
// Workboard API module exposes the plugin public contract.
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export {
definePluginEntry,
type OpenClawPluginApi,
type OpenClawPluginService,
} from "openclaw/plugin-sdk/plugin-entry";
+2
View File
@@ -1,6 +1,7 @@
// Workboard plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "./api.js";
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
import { createWorkboardChangeEventService } from "./src/change-events.js";
import { registerWorkboardCommand } from "./src/command.js";
import { cleanupWorkboardRunWorktree } from "./src/dispatcher-workspace.js";
import { WorkboardStore } from "./src/store.js";
@@ -18,6 +19,7 @@ export default definePluginEntry({
const store = WorkboardStore.openSqlite();
registerWorkboardGatewayMethods({ api, store });
registerWorkboardCommand({ api, store });
api.registerService(createWorkboardChangeEventService(store));
api.on("subagent_ended", async (event) => {
if (event.runId) {
await cleanupWorkboardRunWorktree({
@@ -0,0 +1,73 @@
import type { WorkboardChange } from "@openclaw/workboard-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkboardChangeEventService } from "./change-events.js";
import type { WorkboardStore } from "./store.js";
afterEach(() => vi.useRealTimers());
describe("createWorkboardChangeEventService", () => {
it("announces its epoch, forwards changes, and reconciles external commits", async () => {
vi.useFakeTimers();
let listener: ((change: WorkboardChange) => void) | undefined;
const unsubscribe = vi.fn();
const reconcileExternalChanges = vi.fn();
const store = {
subscribeChanges: vi.fn((next) => {
listener = next;
return unsubscribe;
}),
announceChangeEpoch: vi.fn(() => listener?.({ epoch: "epoch-a", revision: 1 })),
reconcileExternalChanges,
} as unknown as WorkboardStore;
const emit = vi.fn();
const warn = vi.fn();
const service = createWorkboardChangeEventService(store);
const context = {
config: {},
stateDir: "/tmp/workboard-change-events-test",
gatewayEvents: { emit },
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
} satisfies Parameters<typeof service.start>[0];
await service.start(context);
listener?.({ epoch: "epoch-a", revision: 2 });
await vi.advanceTimersByTimeAsync(1000);
expect(emit.mock.calls).toEqual([
["changed", { epoch: "epoch-a", revision: 1 }, { scope: "operator.read" }],
["changed", { epoch: "epoch-a", revision: 2 }, { scope: "operator.read" }],
]);
expect(reconcileExternalChanges).toHaveBeenCalledOnce();
await service.stop?.(context);
await vi.advanceTimersByTimeAsync(1000);
expect(reconcileExternalChanges).toHaveBeenCalledOnce();
expect(unsubscribe).toHaveBeenCalledOnce();
expect(warn).not.toHaveBeenCalled();
});
it("logs external reconciliation failures without stopping the service", async () => {
vi.useFakeTimers();
const reconcileExternalChanges = vi.fn(() => {
throw new Error("database unavailable");
});
const store = {
subscribeChanges: vi.fn(() => vi.fn()),
announceChangeEpoch: vi.fn(),
reconcileExternalChanges,
} as unknown as WorkboardStore;
const warn = vi.fn();
const service = createWorkboardChangeEventService(store);
const context = {
config: {},
stateDir: "/tmp/workboard-change-events-test",
gatewayEvents: { emit: vi.fn() },
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
} satisfies Parameters<typeof service.start>[0];
await service.start(context);
await vi.advanceTimersByTimeAsync(2000);
expect(reconcileExternalChanges).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledTimes(2);
await service.stop?.(context);
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { WorkboardChange } from "@openclaw/workboard-contract";
import type { OpenClawPluginService } from "../api.js";
import type { WorkboardStore } from "./store.js";
const WORKBOARD_EXTERNAL_CHANGE_CHECK_MS = 1000;
export function createWorkboardChangeEventService(store: WorkboardStore): OpenClawPluginService {
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setInterval> | undefined;
return {
id: "workboard-change-events",
start(ctx) {
const gatewayEvents = ctx.gatewayEvents;
if (!gatewayEvents) {
return;
}
const emit = (change: WorkboardChange) => {
gatewayEvents.emit("changed", change, {
scope: "operator.read",
});
};
unsubscribe = store.subscribeChanges(emit);
store.announceChangeEpoch();
timer = setInterval(() => {
try {
store.reconcileExternalChanges();
} catch (error) {
ctx.logger.warn(`workboard external change check failed: ${String(error)}`);
}
}, WORKBOARD_EXTERNAL_CHANGE_CHECK_MS);
timer.unref?.();
},
stop() {
unsubscribe?.();
unsubscribe = undefined;
if (timer) {
clearInterval(timer);
timer = undefined;
}
},
};
}
+4 -4
View File
@@ -1,4 +1,3 @@
// Workboard plugin module implements sqlite store behavior.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
@@ -26,20 +25,18 @@ import type {
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
const SCHEMA_VERSION = 2;
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
type Row = Record<string, unknown>;
type WorkboardSqliteStores = {
cards: WorkboardKeyedStore;
boards: WorkboardKeyedStore<PersistedWorkboardBoard>;
subscriptions: WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>;
attachments: WorkboardKeyedStore<PersistedWorkboardAttachment>;
dataVersion: () => number;
close: () => void;
};
@@ -1422,6 +1419,9 @@ export function createWorkboardSqliteStores(
boards: new WorkboardSqliteBoardStore(db),
subscriptions: new WorkboardSqliteSubscriptionStore(db),
attachments: new WorkboardSqliteAttachmentStore(db),
// This connection-local primitive changes only after another connection commits.
dataVersion: () =>
requiredNumber(db.prepare("PRAGMA data_version").get() as Row, "data_version"),
close: () => {
maintenance.close();
db.close();
@@ -0,0 +1,77 @@
import { randomUUID } from "node:crypto";
import type { WorkboardChange } from "@openclaw/workboard-contract";
import type { WorkboardKeyedStore } from "./persistence-types.js";
export class WorkboardChangeTracker {
private readonly epoch = randomUUID();
private revision = 0;
private mutationRevision = 0;
private externalDataVersion: number | undefined;
private readonly listeners = new Set<(change: WorkboardChange) => void>();
constructor(private readonly readDataVersion?: () => number) {
this.externalDataVersion = readDataVersion?.();
}
track<T>(store: WorkboardKeyedStore<T>): WorkboardKeyedStore<T> {
return {
register: async (key, value) => {
await store.register(key, value);
this.mutationRevision += 1;
},
lookup: async (key) => await store.lookup(key),
delete: async (key) => {
const deleted = await store.delete(key);
if (deleted) {
this.mutationRevision += 1;
}
return deleted;
},
entries: async () => await store.entries(),
};
}
subscribe(listener: (change: WorkboardChange) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
announceEpoch(): void {
this.emit();
}
reconcileExternalChanges(): boolean {
if (!this.readDataVersion) {
return false;
}
const current = this.readDataVersion();
if (current === this.externalDataVersion) {
return false;
}
this.externalDataVersion = current;
this.emit();
return true;
}
async runMutation<T>(run: () => Promise<T>): Promise<T> {
const initialRevision = this.mutationRevision;
try {
return await run();
} finally {
if (this.mutationRevision !== initialRevision) {
this.emit();
}
}
}
private emit(): void {
const change = { epoch: this.epoch, revision: ++this.revision };
for (const listener of this.listeners) {
try {
listener(change);
} catch {
// Persistence already succeeded. Observers cannot turn it into a reported failure.
}
}
}
}
+25 -29
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type {
WorkboardBoardMetadata,
WorkboardChange,
WorkboardCard,
WorkboardLink,
WorkboardMetadata,
@@ -28,6 +29,7 @@ import {
updateEvent,
appendEvent,
} from "./store-card-helpers.js";
import { WorkboardChangeTracker } from "./store-change-tracker.js";
import { MAX_CARD_COMMENTS, MAX_CARD_WORKER_LOGS, POSITION_STEP } from "./store-constants.js";
import type {
WorkboardBoardInput,
@@ -68,20 +70,26 @@ import {
export class WorkboardCoreStore {
private mutationQueue: Promise<unknown> = Promise.resolve();
private lastNotificationSequence = 0;
private readonly changes: WorkboardChangeTracker;
protected readonly store: WorkboardKeyedStore;
protected readonly boardStore: WorkboardKeyedStore<PersistedWorkboardBoard>;
protected readonly subscriptionStore: WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>;
protected readonly attachmentStore: WorkboardKeyedStore<PersistedWorkboardAttachment>;
constructor(
protected readonly store: WorkboardKeyedStore,
store: WorkboardKeyedStore,
stores: {
boards?: WorkboardKeyedStore<PersistedWorkboardBoard>;
subscriptions?: WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>;
attachments?: WorkboardKeyedStore<PersistedWorkboardAttachment>;
dataVersion?: () => number;
} = {},
) {
this.boardStore =
stores.boards ?? (store as unknown as WorkboardKeyedStore<PersistedWorkboardBoard>);
this.changes = new WorkboardChangeTracker(stores.dataVersion);
this.store = this.changes.track(store);
this.boardStore = this.changes.track(
stores.boards ?? (store as unknown as WorkboardKeyedStore<PersistedWorkboardBoard>),
);
this.subscriptionStore =
stores.subscriptions ??
(store as unknown as WorkboardKeyedStore<PersistedWorkboardNotificationSubscription>);
@@ -89,8 +97,21 @@ export class WorkboardCoreStore {
stores.attachments ?? (store as unknown as WorkboardKeyedStore<PersistedWorkboardAttachment>);
}
subscribeChanges(listener: (change: WorkboardChange) => void): () => void {
return this.changes.subscribe(listener);
}
announceChangeEpoch(): void {
this.changes.announceEpoch();
}
reconcileExternalChanges(): boolean {
return this.changes.reconcileExternalChanges();
}
protected async enqueueMutation<T>(run: () => Promise<T>): Promise<T> {
const result = this.mutationQueue.then(run, run);
const runAndNotify = async () => await this.changes.runMutation(run);
const result = this.mutationQueue.then(runAndNotify, runAndNotify);
this.mutationQueue = result.then(
() => undefined,
() => undefined,
@@ -881,18 +902,6 @@ export class WorkboardCoreStore {
return next;
}
protected async shouldAutoOrchestrate(card: WorkboardCard): Promise<boolean> {
if (
card.status !== "triage" ||
card.metadata?.archivedAt ||
card.metadata?.workerProtocol?.state === "idle"
) {
return false;
}
const board = await this.boardStore.lookup(cardBoardId(card));
return board?.version === 1 && board.board.orchestration?.autoDecompose === true;
}
protected async promoteDependencyReady(id: string, now = Date.now()): Promise<WorkboardCard> {
const card = await this.get(id);
if (!card) {
@@ -904,17 +913,4 @@ export class WorkboardCoreStore {
}
return await this.updateCard(card.id, { status: target });
}
async promoteReady(now = Date.now()): Promise<{ cards: WorkboardCard[]; count: number }> {
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
for (const card of await this.list()) {
const next = await this.promoteDependencyReady(card.id, now);
if (next.status !== card.status) {
promoted.push(next);
}
}
return { cards: promoted, count: promoted.length };
});
}
}
+13
View File
@@ -7,6 +7,19 @@ import type { WorkboardMutationScope, WorkboardPromoteInput } from "./store-inpu
import { clearDiagnostics, normalizeBoundedString } from "./store-normalizers.js";
export class WorkboardPromoteStore extends WorkboardEnrichmentStore {
async promoteReady(now = Date.now()): Promise<{ cards: WorkboardCard[]; count: number }> {
return await this.enqueueMutation(async () => {
const promoted: WorkboardCard[] = [];
for (const card of await this.list()) {
const next = await this.promoteDependencyReady(card.id, now);
if (next.status !== card.status) {
promoted.push(next);
}
}
return { cards: promoted, count: promoted.length };
});
}
async move(
id: string,
status: unknown,
+82
View File
@@ -50,6 +50,88 @@ function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
}
describe("WorkboardStore", () => {
it("emits one monotonic change after each visible mutation", async () => {
const store = new WorkboardStore(createMemoryStore());
const changes = vi.fn();
store.subscribeChanges(changes);
const card = await store.create({ title: "Track changes" });
await store.update(card.id, { notes: "updated" });
await store.list();
await expect(store.update("missing", { notes: "failed" })).rejects.toThrow("card not found");
expect(changes.mock.calls.map(([change]) => change.revision)).toEqual([1, 2]);
expect(changes.mock.calls[1]?.[0].epoch).toBe(changes.mock.calls[0]?.[0].epoch);
});
it("does not emit for no-op commands and isolates listener failures", async () => {
const store = new WorkboardStore(createMemoryStore());
const changes = vi.fn(() => {
throw new Error("listener failed");
});
store.subscribeChanges(changes);
const card = await store.create({ title: "Idempotent", idempotencyKey: "same" });
await store.create({ title: "Duplicate", idempotencyKey: "same" });
await store.delete("missing");
expect(card.title).toBe("Idempotent");
expect(changes).toHaveBeenCalledOnce();
});
it("announces an epoch and reports failed commands that partially committed", async () => {
const subscriptions = createMemoryStore<PersistedWorkboardNotificationSubscription>();
subscriptions.entries = async () => {
throw new Error("subscription cleanup failed");
};
const store = new WorkboardStore(createMemoryStore(), { subscriptions });
const changes = vi.fn();
store.subscribeChanges(changes);
store.announceChangeEpoch();
const card = await store.create({ title: "Partial delete" });
await expect(store.delete(card.id)).rejects.toThrow("subscription cleanup failed");
expect(changes.mock.calls.map(([change]) => change.revision)).toEqual([1, 2, 3]);
await expect(store.get(card.id)).resolves.toBeUndefined();
});
it("emits when another sqlite connection commits", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-change-"));
const dbPath = path.join(dir, "workboard.sqlite");
const readerStores = createWorkboardSqliteStores({ dbPath });
const writerStores = createWorkboardSqliteStores({ dbPath });
try {
const reader = new WorkboardStore(readerStores.cards, {
boards: readerStores.boards,
subscriptions: readerStores.subscriptions,
attachments: readerStores.attachments,
dataVersion: readerStores.dataVersion,
});
const writer = new WorkboardStore(writerStores.cards, {
boards: writerStores.boards,
subscriptions: writerStores.subscriptions,
attachments: writerStores.attachments,
dataVersion: writerStores.dataVersion,
});
const changes = vi.fn();
reader.subscribeChanges(changes);
expect(reader.reconcileExternalChanges()).toBe(false);
await writer.create({ title: "External" });
expect(reader.reconcileExternalChanges()).toBe(true);
expect(reader.reconcileExternalChanges()).toBe(false);
expect(changes).toHaveBeenCalledOnce();
await expect(reader.list()).resolves.toEqual([
expect.objectContaining({ title: "External" }),
]);
} finally {
writerStores.close();
readerStores.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
const dbPath = path.join(dir, "workboard.sqlite");
+13
View File
@@ -52,6 +52,18 @@ export type { WorkboardDispatchResult } from "./store-inputs.js";
// Capability layers split review boundaries only; the core still owns persistence and mutation order.
export class WorkboardStore extends WorkboardNotificationStore {
private async shouldAutoOrchestrate(card: WorkboardCard): Promise<boolean> {
if (
card.status !== "triage" ||
card.metadata?.archivedAt ||
card.metadata?.workerProtocol?.state === "idle"
) {
return false;
}
const board = await this.boardStore.lookup(cardBoardId(card));
return board?.version === 1 && board.board.orchestration?.autoDecompose === true;
}
async dispatch(
input: number | WorkboardDispatchOptions = Date.now(),
): Promise<WorkboardDispatchResult> {
@@ -288,6 +300,7 @@ export class WorkboardStore extends WorkboardNotificationStore {
boards: stores.boards,
subscriptions: stores.subscriptions,
attachments: stores.attachments,
dataVersion: stores.dataVersion,
});
}
}
+7
View File
@@ -224,6 +224,13 @@ export type WorkboardNotification = {
runId?: string;
};
export const WORKBOARD_CHANGED_EVENT = "plugin.workboard.changed";
export type WorkboardChange = {
epoch: string;
revision: number;
};
export type WorkboardWorkspace = {
kind: "scratch" | "dir" | "worktree";
path?: string;
+2 -1
View File
@@ -206,11 +206,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// Its flat channel-groups builder adds one function, also mirrored by compat.
// Its case-insensitive scope-key resolver adds one function, also mirrored by compat.
// The focused HTML entity runtime and quote-aware HTML tokenizer add one public function each.
// Plugin service Gateway event scope and emitter types add four facade exports.
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
// +4: registerMcpServerConnectionResolver context/result/resolver/registration types (#106229).
// +2: materializeRequesterScopedMcpToolsForHarnessRun (agent-harness-runtime + compat mirror).
10690,
10694,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+38
View File
@@ -511,6 +511,44 @@ describe("gateway broadcaster", () => {
expectSentEvents(adminSocket, expectedEvents);
});
it("honors explicit read scope for plugin lifecycle events", () => {
const {
pairingSocket,
nodeSocket,
readSocket,
writeSocket,
adminSocket,
broadcastPluginEvent,
} = makeScopedBroadcastContext();
broadcastPluginEvent("plugin.workboard.changed", { revision: 1 }, "operator.read");
expect(pairingSocket.send).not.toHaveBeenCalled();
expect(nodeSocket.send).not.toHaveBeenCalled();
expectSentEvents(readSocket, ["plugin.workboard.changed"]);
expectSentEvents(writeSocket, ["plugin.workboard.changed"]);
expectSentEvents(adminSocket, ["plugin.workboard.changed"]);
});
it("rejects invalid or reserved plugin event broadcasts", () => {
const { broadcastPluginEvent } = makeScopedBroadcastContext();
const unsafe = broadcastPluginEvent as unknown as (
event: string,
payload: unknown,
scope: string,
) => void;
expect(() => unsafe("workboard.changed", {}, "operator.read")).toThrow(
"invalid plugin gateway event",
);
expect(() => unsafe("plugin.approval.requested", {}, "operator.read")).toThrow(
"invalid plugin gateway event",
);
expect(() => unsafe("plugin.workboard.changed", {}, "operator.approvals")).toThrow(
"invalid plugin gateway event scope",
);
});
it("defaults unknown events to deny and classifies remaining gateway broadcast events", () => {
const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } =
makeScopedBroadcastContext();
+9
View File
@@ -28,3 +28,12 @@ export type GatewayBroadcastToConnIdsFn = (
/** Current queued outbound bytes for one live gateway connection. */
export type GatewayBufferedAmountFn = (connId: string) => number | undefined;
export type GatewayPluginEventScope = "operator.read" | "operator.write" | "operator.admin";
/** Broadcasts a namespaced plugin event under an explicit operator scope. */
export type GatewayPluginEventBroadcastFn = (
event: string,
payload: unknown,
scope: GatewayPluginEventScope,
) => void;
+32 -3
View File
@@ -13,6 +13,8 @@ import type {
GatewayBroadcastOpts,
GatewayBroadcastToConnIdsFn,
GatewayBufferedAmountFn,
GatewayPluginEventBroadcastFn,
GatewayPluginEventScope,
} from "./server-broadcast-types.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -73,10 +75,26 @@ function serializeFrameField(name: "payload" | "stateVersion", value: unknown):
return fieldJSON.startsWith(prefix) ? `,${keyJSON}:${fieldJSON.slice(prefix.length, -1)}` : "";
}
function hasEventScope(client: GatewayWsClient, event: string): boolean {
function hasEventScope(
client: GatewayWsClient,
event: string,
explicitPluginScope?: GatewayPluginEventScope,
): boolean {
if (client.connectionKind === "worker") {
return false;
}
if (explicitPluginScope) {
if ((client.connect.role ?? "operator") !== "operator") {
return false;
}
const scopes = Array.isArray(client.connect.scopes) ? client.connect.scopes : [];
if (scopes.includes(ADMIN_SCOPE)) {
return true;
}
return explicitPluginScope === READ_SCOPE
? scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE)
: explicitPluginScope === WRITE_SCOPE && scopes.includes(WRITE_SCOPE);
}
const required = EVENT_SCOPE_GUARDS[event];
// Plugin-defined gateway broadcast events (plugin.* namespace) are allowed
// for operator.write and operator.admin scopes. Explicit plugin.* entries
@@ -118,6 +136,7 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
payload: unknown,
opts?: GatewayBroadcastOpts,
targetConnIds?: ReadonlySet<string>,
explicitPluginScope?: GatewayPluginEventScope,
) => {
if (params.clients.size === 0) {
return;
@@ -162,7 +181,7 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
if (targetConnIds && !targetConnIds.has(c.connId)) {
continue;
}
if (!hasEventScope(c, event)) {
if (!hasEventScope(c, event, explicitPluginScope)) {
continue;
}
const nextSeq = (clientSeq.get(c) ?? 0) + 1;
@@ -226,5 +245,15 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
return undefined;
};
return { broadcast, broadcastToConnIds, getBufferedAmount };
const broadcastPluginEvent: GatewayPluginEventBroadcastFn = (event, payload, scope) => {
if (!event.startsWith("plugin.") || event.startsWith("plugin.approval.")) {
throw new Error(`invalid plugin gateway event: ${event}`);
}
if (scope !== READ_SCOPE && scope !== WRITE_SCOPE && scope !== ADMIN_SCOPE) {
throw new Error("invalid plugin gateway event scope");
}
broadcastInternal(event, payload, undefined, undefined, scope);
};
return { broadcast, broadcastToConnIds, broadcastPluginEvent, getBufferedAmount };
}
+4 -6
View File
@@ -33,6 +33,7 @@ import type {
GatewayBroadcastFn,
GatewayBroadcastToConnIdsFn,
GatewayBufferedAmountFn,
GatewayPluginEventBroadcastFn,
} from "./server-broadcast-types.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import {
@@ -132,6 +133,7 @@ export async function createGatewayRuntimeState(params: {
broadcast: GatewayBroadcastFn;
broadcastToConnIds: GatewayBroadcastToConnIdsFn;
getBufferedAmount: GatewayBufferedAmountFn;
broadcastPluginEvent: GatewayPluginEventBroadcastFn;
agentRunSeq: Map<string, number>;
dedupe: Map<string, DedupeEntry>;
chatRunState: ReturnType<typeof createChatRunState>;
@@ -161,9 +163,7 @@ export async function createGatewayRuntimeState(params: {
const resolvePluginRouteRegistry = () =>
params.getPluginRouteRegistry?.() ?? params.pluginRegistry;
const clients = new Set<GatewayWsClient>();
const { broadcast, broadcastToConnIds, getBufferedAmount } = createGatewayBroadcaster({
clients,
});
const gatewayBroadcaster = createGatewayBroadcaster({ clients });
let loadedHooksRequestHandler: HooksRequestHandler | null = null;
const handleHooksRequest: HooksRequestHandler = async (req, res) => {
@@ -466,9 +466,7 @@ export async function createGatewayRuntimeState(params: {
wss,
preauthConnectionBudget,
clients,
broadcast,
broadcastToConnIds,
getBufferedAmount,
...gatewayBroadcaster,
agentRunSeq,
dedupe,
chatRunState,
+4 -4
View File
@@ -1,5 +1,3 @@
// Gateway post-attach startup sidecars.
// Schedules warmups, sentinels, update checks, memory backend, and plugin services.
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { setTimeout as sleep } from "node:timers/promises";
import pMap from "p-map";
@@ -35,7 +33,6 @@ import {
type GatewayStartupOutcomeRecorder,
} from "./server-startup-outcomes.js";
import type { startGatewayTailscaleExposure } from "./server-tailscale.js";
const ACP_BACKEND_READY_TIMEOUT_MS = 5_000;
const ACP_BACKEND_READY_POLL_MS = 50;
const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000;
@@ -48,7 +45,6 @@ const SESSION_LOCK_CLEANUP_CONCURRENCY = 4;
const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM";
const QMD_STARTUP_IDLE_DELAY_MS = 120_000;
type Awaitable<T> = T | Promise<T>;
type GatewayStartupTrace = {
detail: (name: string, metrics: ReadonlyArray<readonly [string, number | string]>) => void;
mark: (name: string) => void;
@@ -695,6 +691,7 @@ export async function startGatewaySidecars(params: {
prewarmPrimaryModel?: typeof prewarmConfiguredPrimaryModel;
onPluginServices?: (pluginServices: PluginServicesHandle | null) => void;
shouldStartPluginServices?: () => boolean;
broadcastPluginEvent?: import("./server-broadcast-types.js").GatewayPluginEventBroadcastFn;
log: { warn: (msg: string) => void };
logHooks: {
info: (msg: string) => void;
@@ -795,6 +792,7 @@ export async function startGatewaySidecars(params: {
config: params.cfg,
workspaceDir: params.defaultWorkspaceDir,
startupTrace: params.startupTrace,
broadcastPluginEvent: params.broadcastPluginEvent,
});
} catch (err) {
params.log.warn(`plugin services failed to start: ${String(err)}`);
@@ -1109,6 +1107,7 @@ export async function startGatewayPostAttachRuntime(
isNixMode: boolean;
startupStartedAt?: number;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
broadcastPluginEvent?: import("./server-broadcast-types.js").GatewayPluginEventBroadcastFn;
tailscaleMode: GatewayTailscaleMode;
resetOnExit: boolean;
serviceName?: string;
@@ -1294,6 +1293,7 @@ export async function startGatewayPostAttachRuntime(
onChannelsStarted: params.onChannelsStarted,
onPluginServices: reportPluginServices,
shouldStartPluginServices: () => params.isClosing?.() !== true,
broadcastPluginEvent: params.broadcastPluginEvent,
startupOutcomes,
}),
);
+3 -3
View File
@@ -1,6 +1,4 @@
import type { IncomingMessage, ServerResponse } from "node:http";
// Gateway server implementation builds runtime state, method registries, HTTP
// and WebSocket surfaces, config reload hooks, and graceful restart/shutdown.
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { getActiveBackgroundExecSessionCount } from "../agents/bash-process-registry.js";
@@ -154,7 +152,6 @@ import { loadGatewayTlsRuntime } from "./server/tls.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
import { mergeGatewayAuthConfig, mergeGatewayTailscaleConfig } from "./startup-auth.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
type LoadGatewayModelCatalog = typeof import("./server-model-catalog.js").loadGatewayModelCatalog;
type LoadGatewayModelCatalogSnapshot =
typeof import("./server-model-catalog.js").loadGatewayModelCatalogSnapshot;
@@ -1132,6 +1129,7 @@ export async function startGatewayServer(
clients,
broadcast,
broadcastToConnIds,
broadcastPluginEvent,
getBufferedAmount,
agentRunSeq,
dedupe,
@@ -1801,6 +1799,7 @@ export async function startGatewayServer(
registry: loaded.pluginRegistry,
config: params.nextConfig,
workspaceDir: defaultWorkspaceDir,
broadcastPluginEvent,
});
const afterChannelTargets = listAttachedChannelConfigTargets();
const afterChannelIds = new Set(afterChannelTargets.keys());
@@ -2049,6 +2048,7 @@ export async function startGatewayServer(
isNixMode,
startupStartedAt: opts.startupStartedAt,
broadcast,
broadcastPluginEvent,
tailscaleMode,
resetOnExit: tailscaleConfig.resetOnExit ?? false,
serviceName: tailscaleConfig.serviceName,
+2 -2
View File
@@ -1,4 +1,3 @@
// Core SDK contracts expose stable identifiers, manifests, and shared plugin metadata types.
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import type { ResolvedConfiguredAcpBinding } from "../acp/persistent-bindings.types.js";
@@ -37,7 +36,6 @@ import {
normalizeSessionKeyPreservingOpaquePeerIds,
parseThreadSessionSuffix,
} from "../sessions/session-key-utils.js";
export type {
AgentPromptGuidance,
AgentPromptGuidanceEntry,
@@ -142,6 +140,8 @@ export type {
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "../plugins/types.js";
export type { OpenClawPluginGatewayEventScope } from "../plugins/gateway-events.js";
export type { OpenClawPluginGatewayEvents } from "../plugins/gateway-events.js";
export type {
MemoryPluginCapability,
MemoryPluginPublicArtifact,
+4
View File
@@ -25,6 +25,10 @@ export type OpenClawPluginConfigSchema = import("../plugins/types.js").OpenClawP
export type OpenClawPluginDefinition = import("../plugins/types.js").OpenClawPluginDefinition;
export type OpenClawPluginHttpRouteHandler =
import("../plugins/types.js").OpenClawPluginHttpRouteHandler;
export type OpenClawPluginGatewayEventScope =
import("../plugins/gateway-events.js").OpenClawPluginGatewayEventScope;
export type OpenClawPluginGatewayEvents =
import("../plugins/gateway-events.js").OpenClawPluginGatewayEvents;
export type OpenClawPluginNodeHostCommand =
import("../plugins/types.js").OpenClawPluginNodeHostCommand;
export type OpenClawPluginNodeHostCommandAvailabilityContext =
+11
View File
@@ -0,0 +1,11 @@
import type { PluginJsonValue } from "./host-hook-json.js";
export type OpenClawPluginGatewayEventScope = "operator.read" | "operator.write" | "operator.admin";
export type OpenClawPluginGatewayEvents = {
emit: (
event: string,
payload: PluginJsonValue,
opts: { scope: OpenClawPluginGatewayEventScope },
) => void;
};
+97
View File
@@ -168,6 +168,103 @@ describe("startPluginServices", () => {
expectServiceLifecycleState({ starts, stops, contexts, config });
});
it("binds gateway events to the owning plugin namespace and scope", async () => {
const broadcastPluginEvent = vi.fn();
await startPluginServices({
registry: createRegistry(
[
{
id: "events",
start: (ctx) => {
ctx.gatewayEvents?.emit("changed", { revision: 1 }, { scope: "operator.read" });
},
},
],
"workboard",
),
config: createServiceConfig(),
broadcastPluginEvent,
});
expect(broadcastPluginEvent).toHaveBeenCalledWith(
"plugin.workboard.changed",
{ revision: 1 },
"operator.read",
);
});
it("rejects unsafe event names, scopes, and payloads", async () => {
let context: OpenClawPluginServiceContext | undefined;
const broadcastPluginEvent = vi.fn();
await startPluginServices({
registry: createRegistry([
{
id: "events",
start: (ctx) => {
context = ctx;
},
},
]),
config: createServiceConfig(),
broadcastPluginEvent,
});
const emit = context?.gatewayEvents?.emit as unknown as (
event: string,
payload: unknown,
opts: { scope: string },
) => void;
expect(() => emit("other.changed", {}, { scope: "operator.read" })).toThrow(
"invalid plugin gateway event name",
);
expect(() => emit("changed", { value: Number.NaN }, { scope: "operator.read" })).toThrow(
"bounded JSON",
);
expect(() => emit("changed", {}, { scope: "operator.approvals" })).toThrow("operator scope");
expect(broadcastPluginEvent).not.toHaveBeenCalled();
});
it("revokes gateway event emitters after failed start and stop", async () => {
const contexts: OpenClawPluginServiceContext[] = [];
const broadcastPluginEvent = vi.fn();
const handle = await startPluginServices({
registry: createRegistry([
{
id: "events",
start: (ctx) => {
contexts.push(ctx);
},
stop: (ctx) => {
ctx.gatewayEvents?.emit("stopping", {}, { scope: "operator.read" });
},
},
{
id: "failed-events",
start: (ctx) => {
contexts.push(ctx);
throw new Error("start failed");
},
},
]),
config: createServiceConfig(),
broadcastPluginEvent,
});
expect(() =>
contexts[1]?.gatewayEvents?.emit("changed", {}, { scope: "operator.read" }),
).toThrow("no longer active");
await handle.stop();
expect(() =>
contexts[0]?.gatewayEvents?.emit("changed", {}, { scope: "operator.read" }),
).toThrow("no longer active");
expect(broadcastPluginEvent).toHaveBeenCalledOnce();
expect(broadcastPluginEvent).toHaveBeenCalledWith(
"plugin.plugin:test.stopping",
{},
"operator.read",
);
});
it("registers dynamic HTTP routes into the service registry scope", async () => {
const serviceRegistry = createRegistry([
{
+57 -4
View File
@@ -1,11 +1,13 @@
/** Starts, stops, and inspects plugin service registrations. */
import { STATE_DIR } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayPluginEventBroadcastFn } from "../gateway/server-broadcast-types.js";
import {
emitTrustedDiagnosticEventWithPrivateData,
onTrustedInternalDiagnosticEvent,
} from "../infra/diagnostic-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPluginJsonValue, type PluginJsonValue } from "./host-hook-json.js";
import { withPluginHttpRouteRegistry } from "./http-registry.js";
import type { PluginServiceRegistration } from "./registry-types.js";
import type { PluginRegistry } from "./registry.js";
@@ -27,6 +29,7 @@ function createServiceContext(params: {
startupTrace?: PluginServiceStartupTrace;
workspaceDir?: string;
service: PluginServiceRegistration;
gatewayEvents?: OpenClawPluginServiceContext["gatewayEvents"];
}): OpenClawPluginServiceContext {
const isDiagnosticsExporter =
params.service?.pluginId === params.service?.service.id &&
@@ -41,6 +44,7 @@ function createServiceContext(params: {
workspaceDir: params.workspaceDir,
stateDir: STATE_DIR,
logger: createPluginLogger(),
...(params.gatewayEvents ? { gatewayEvents: params.gatewayEvents } : {}),
...(params.startupTrace
? {
startupTrace: createScopedPluginServiceStartupTrace(
@@ -60,6 +64,45 @@ function createServiceContext(params: {
};
}
function createScopedGatewayEvents(params: {
pluginId: string;
broadcast?: GatewayPluginEventBroadcastFn;
}): {
gatewayEvents?: OpenClawPluginServiceContext["gatewayEvents"];
revoke: () => void;
} {
if (!params.broadcast) {
return { revoke: () => undefined };
}
let active = true;
return {
gatewayEvents: {
emit: (event, payload: PluginJsonValue, opts) => {
if (!active) {
throw new Error("plugin service gateway event emitter is no longer active");
}
if (!/^[a-z][a-z0-9_-]*$/u.test(event)) {
throw new Error(`invalid plugin gateway event name: ${event}`);
}
if (!isPluginJsonValue(payload)) {
throw new Error("plugin gateway event payload must be bounded JSON");
}
if (
opts?.scope !== "operator.read" &&
opts?.scope !== "operator.write" &&
opts?.scope !== "operator.admin"
) {
throw new Error("plugin gateway event scope must be an operator scope");
}
params.broadcast?.(`plugin.${params.pluginId}.${event}`, payload, opts.scope);
},
},
revoke: () => {
active = false;
},
};
}
function createPluginServiceTraceName(entry: PluginServiceRegistration): string {
return `sidecars.plugin-services.${encodeStartupTraceSegment(entry.pluginId)}.${encodeStartupTraceSegment(entry.service.id)}`;
}
@@ -97,20 +140,27 @@ export async function startPluginServices(params: {
config: OpenClawConfig;
workspaceDir?: string;
startupTrace?: PluginServiceStartupTrace;
broadcastPluginEvent?: GatewayPluginEventBroadcastFn;
}): Promise<PluginServicesHandle> {
const running: Array<{
id: string;
stop?: () => void | Promise<void>;
revokeGatewayEvents: () => void;
}> = [];
let failedCount = 0;
for (const entry of params.registry.services) {
const service = entry.service;
const traceName = createPluginServiceTraceName(entry);
const scopedGatewayEvents = createScopedGatewayEvents({
pluginId: entry.pluginId,
broadcast: params.broadcastPluginEvent,
});
const serviceContext = createServiceContext({
config: params.config,
startupTrace: params.startupTrace,
workspaceDir: params.workspaceDir,
service: entry,
gatewayEvents: scopedGatewayEvents.gatewayEvents,
});
try {
const startService = () =>
@@ -123,8 +173,10 @@ export async function startPluginServices(params: {
running.push({
id: service.id,
stop: service.stop ? () => service.stop?.(serviceContext) : undefined,
revokeGatewayEvents: scopedGatewayEvents.revoke,
});
} catch (err) {
scopedGatewayEvents.revoke();
failedCount += 1;
const error = err as Error;
log.error(
@@ -141,13 +193,14 @@ export async function startPluginServices(params: {
return {
stop: async () => {
for (const entry of running.toReversed()) {
if (!entry.stop) {
continue;
}
try {
await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.());
if (entry.stop) {
await withPluginHttpRouteRegistry(params.registry, () => entry.stop?.());
}
} catch (err) {
log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`);
} finally {
entry.revokeGatewayEvents();
}
}
},
+1 -1
View File
@@ -1,4 +1,3 @@
// Defines the public plugin API and runtime extension contracts.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import type {
@@ -2441,6 +2440,7 @@ export type OpenClawPluginServiceContext = {
workspaceDir?: string;
stateDir: string;
logger: PluginLogger;
gatewayEvents?: import("./gateway-events.js").OpenClawPluginGatewayEvents;
startupTrace?: {
detail?: (name: string, metrics: ReadonlyArray<readonly [string, number | string]>) => void;
measure: <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
+26
View File
@@ -79,6 +79,32 @@ declare module "*openclaw-changelog-update/scripts/verify-release-notes.mjs" {
legacyIssues: Map<number, unknown>;
pullRequests: Map<number, ContributionRecord>;
};
export function contributionRecordTarget(section: { source: string }): string | undefined;
export function pullRequestTitleFromCommitSubject(
subject: string,
number: number,
): string | undefined;
export function recoverUnavailablePullRequests(params: {
numbers: Iterable<number>;
nodes: Map<number, unknown>;
record: { pullRequests: Map<number, ContributionRecord> };
recordTarget?: string;
source: {
activeCommits: Array<{
authorHandle?: string;
closingReferences?: number[];
committedAt: string;
hash: string;
pullRequests: number[];
references: number[];
subject: string;
}>;
coauthorsByReference: Map<number, Set<string>>;
pullRequests: Set<number>;
target: string;
};
isAncestor?: (ancestor: string, descendant: string) => boolean;
}): Map<number, unknown>;
export function cumulativeShippedPullRequests(changelog: unknown, label: string): Set<number>;
export function subtractShippedPullRequests(
source: unknown,
+2 -2
View File
@@ -1,7 +1,7 @@
import {
getWorkboardState,
stopWorkboardLifecycleRefresh,
stopWorkboardPolling,
stopWorkboardLiveRefresh,
type WorkboardUiState,
} from "./index.ts";
@@ -33,7 +33,7 @@ export function createWorkboardCapability(): WorkboardCapability {
},
dispose() {
disposed = true;
stopWorkboardPolling(capability);
stopWorkboardLiveRefresh(capability);
stopWorkboardLifecycleRefresh(capability);
listeners.clear();
},
+21
View File
@@ -0,0 +1,21 @@
import type { WorkboardChange } from "@openclaw/workboard-contract";
export function normalizeWorkboardChange(payload: unknown): WorkboardChange | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
const epoch = (payload as { epoch?: unknown }).epoch;
const revision = (payload as { revision?: unknown }).revision;
const keys = Object.keys(payload);
return keys.length === 2 &&
keys.includes("epoch") &&
keys.includes("revision") &&
typeof epoch === "string" &&
epoch.length > 0 &&
epoch.length <= 128 &&
typeof revision === "number" &&
Number.isSafeInteger(revision) &&
revision > 0
? { epoch, revision }
: null;
}
+35 -389
View File
@@ -7,7 +7,6 @@ import {
addWorkboardCardComment,
archiveWorkboardCard,
captureSessionToWorkboard,
configureWorkboardPolling,
deleteWorkboardCard,
dispatchWorkboard,
filterWorkboardCardsForPreset,
@@ -20,7 +19,6 @@ import {
saveWorkboardCardDraft,
startWorkboardCard,
stopWorkboardLifecycleRefresh,
stopWorkboardPolling,
stopWorkboardCard,
summarizeWorkboardHealth,
syncWorkboardLifecycle,
@@ -155,40 +153,6 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).cards).toEqual([currentCard]);
});
it("cleans up only the stopped host polling timer", async () => {
vi.useFakeTimers();
const stoppedHost = {};
const activeHost = {};
const stoppedClient = createClient({
"workboard.cards.list": { cards: [], statuses: ["todo", "done"] },
});
const activeClient = createClient({
"workboard.cards.list": { cards: [], statuses: ["todo", "done"] },
});
getWorkboardState(stoppedHost).autoRefreshIntervalMs = 5000;
getWorkboardState(activeHost).autoRefreshIntervalMs = 5000;
configureWorkboardPolling({
host: stoppedHost,
client: stoppedClient as never,
enabled: true,
});
configureWorkboardPolling({
host: activeHost,
client: activeClient as never,
enabled: true,
});
expect(vi.getTimerCount()).toBe(2);
stopWorkboardPolling(stoppedHost);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(5000);
expect(stoppedClient.request).not.toHaveBeenCalled();
expect(activeClient.request).toHaveBeenCalledWith("workboard.cards.list", {});
stopWorkboardPolling(activeHost);
});
it("tracks lifecycle writes until a same-host reload can proceed", async () => {
const host = {};
const linkedCard = { ...sampleCard, sessionKey: sampleSession.key };
@@ -616,33 +580,27 @@ describe("workboard controller", () => {
expect(state.tasksByCardId.get(sampleCard.id)).toEqual(sampleTask);
});
it("records poll refresh state until the final reconciliation render", async () => {
it("records live refresh metadata after reconciliation", async () => {
const host = {};
const client = createClient({
"workboard.cards.list": { cards: [sampleCard], statuses: ["todo", "done"] },
"tasks.list": { tasks: [] },
});
const pollStates: boolean[] = [];
await refreshWorkboard({
host,
client: client as never,
source: "poll",
requestUpdate: () => pollStates.push(getWorkboardState(host).pollRefreshInProgress),
source: "live",
});
const state = getWorkboardState(host);
expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {});
expect(pollStates.slice(0, -1).every(Boolean)).toBe(true);
expect(pollStates.at(-1)).toBe(false);
expect(state.pollRefreshInProgress).toBe(false);
expect(state.lastRefreshSource).toBe("poll");
expect(state.lastRefreshSource).toBe("live");
expect(state.lastRefreshAt).toEqual(expect.any(Number));
expect(state.lastRefreshError).toBeNull();
expect(state.lifecycleTasksPrepared).toBe(true);
});
it("preserves mutation errors during successful passive poll refreshes", async () => {
it("preserves mutation errors during successful live refreshes", async () => {
const host = {};
const state = getWorkboardState(host);
state.error = "move denied";
@@ -654,7 +612,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.error).toBe("move denied");
@@ -662,7 +620,7 @@ describe("workboard controller", () => {
expect(state.lastRefreshAt).toEqual(expect.any(Number));
});
it("clears a recovered load error during successful passive poll refreshes", async () => {
it("clears a recovered load error during successful live refreshes", async () => {
const host = {};
let cardsAvailable = false;
const client = createClient((method) => {
@@ -691,7 +649,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.loaded).toBe(true);
@@ -726,7 +684,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.loaded).toBe(true);
@@ -735,7 +693,7 @@ describe("workboard controller", () => {
expect(state.lastRefreshError).toBeNull();
});
it("records passive poll failures without replacing mutation errors", async () => {
it("records live refresh failures without replacing mutation errors", async () => {
const host = {};
const state = getWorkboardState(host);
state.error = "move denied";
@@ -746,7 +704,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.error).toBe("move denied");
@@ -885,7 +843,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.tasksByCardId.get(sampleCard.id)).toEqual(sampleTask);
@@ -919,7 +877,7 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(client.request).toHaveBeenCalledWith("tasks.get", { taskId: sampleTask.taskId });
@@ -932,13 +890,13 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(client.request).not.toHaveBeenCalledWith("tasks.get", { taskId: sampleTask.taskId });
});
it("keeps canonical task unlinks during bounded poll refreshes", async () => {
it("keeps canonical task unlinks during bounded live refreshes", async () => {
const host = {};
const state = getWorkboardState(host);
state.tasksByCardId.set(sampleCard.id, sampleTask);
@@ -949,15 +907,14 @@ describe("workboard controller", () => {
await refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
expect(state.cards[0]).not.toHaveProperty("taskId");
expect(state.tasksByCardId.has(sampleCard.id)).toBe(false);
});
it("polls through the read refresh path without write methods", async () => {
vi.useFakeTimers();
it("refreshes live state through the read path without write methods", async () => {
const host = {};
const linkedCard = {
...sampleCard,
@@ -994,16 +951,14 @@ describe("workboard controller", () => {
return {};
});
const state = getWorkboardState(host);
state.autoRefreshIntervalMs = 5000;
state.tasksByCardId.set(sampleCard.id, sampleTask);
state.tasksByCardId.set(olderCard.id, olderTask);
configureWorkboardPolling({
await refreshWorkboard({
host,
client: client as never,
enabled: true,
source: "live",
});
await vi.advanceTimersByTimeAsync(5000);
expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {});
expect(client.request).not.toHaveBeenCalledWith(
@@ -1016,113 +971,6 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenCalledWith("tasks.get", { taskId: olderTask.taskId });
expect(state.tasksByCardId.get(sampleCard.id)).toEqual(completedTask);
expect(state.tasksByCardId.get(olderCard.id)).toEqual(olderTask);
vi.clearAllMocks();
stopWorkboardPolling(host);
await vi.advanceTimersByTimeAsync(5000);
expect(client.request).not.toHaveBeenCalled();
});
it("does not rearm polling while a poll is in flight", async () => {
vi.useFakeTimers();
const host = {};
const listResponse = createDeferred<unknown>();
const client = createClient((method) => {
if (method === "workboard.cards.list") {
return listResponse.promise;
}
return {};
});
const state = getWorkboardState(host);
state.autoRefreshIntervalMs = 5000;
const configure = () =>
configureWorkboardPolling({
host,
client: client as never,
enabled: true,
});
configure();
await vi.advanceTimersByTimeAsync(5000);
expect(state.pollRefreshInProgress).toBe(true);
configure();
await vi.advanceTimersByTimeAsync(20_000);
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(1);
stopWorkboardPolling(host);
listResponse.resolve({ cards: [sampleCard], statuses: ["todo", "done"] });
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(20_000);
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(1);
});
it("rearms polling after teardown while the previous poll is still in flight", async () => {
vi.useFakeTimers();
const host = {};
const firstListResponse = createDeferred<unknown>();
const secondListResponse = createDeferred<unknown>();
const firstClient = createClient((method) => {
if (method === "workboard.cards.list") {
return firstListResponse.promise;
}
return {};
});
const secondClient = createClient((method) => {
if (method === "workboard.cards.list") {
return secondListResponse.promise;
}
return {};
});
const state = getWorkboardState(host);
state.autoRefreshIntervalMs = 5000;
configureWorkboardPolling({
host,
client: firstClient as never,
enabled: true,
});
await vi.advanceTimersByTimeAsync(5000);
expect(state.pollRefreshInProgress).toBe(true);
stopWorkboardPolling(host);
expect(state.pollRefreshInProgress).toBe(false);
expect(state.loading).toBe(false);
expect(state.loadAttempted).toBe(false);
configureWorkboardPolling({
host,
client: secondClient as never,
enabled: true,
});
await vi.advanceTimersByTimeAsync(5000);
expect(secondClient.request).toHaveBeenCalledWith("workboard.cards.list", {});
expect(state.pollRefreshInProgress).toBe(true);
firstListResponse.resolve({
cards: [{ ...sampleCard, title: "Stale poll" }],
statuses: ["todo", "done"],
});
await Promise.resolve();
await Promise.resolve();
expect(state.pollRefreshInProgress).toBe(true);
secondListResponse.resolve({
cards: [{ ...sampleCard, title: "Current poll" }],
statuses: ["todo", "done"],
});
await vi.waitFor(() => {
expect(state.pollRefreshInProgress).toBe(false);
});
expect(state.cards).toMatchObject([{ title: "Current poll" }]);
stopWorkboardPolling(host);
});
it("polls a canonical replacement task instead of a stale session-matched task", async () => {
@@ -1152,7 +1000,7 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(client.request).toHaveBeenCalledWith("tasks.get", { taskId: "task-2" });
expect(client.request).not.toHaveBeenCalledWith("tasks.get", { taskId: "task-1" });
@@ -1182,12 +1030,12 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
const firstBatch = client.request.mock.calls
.filter(([method]) => method === "tasks.get")
.map(([, params]) => (params as { taskId: string }).taskId);
vi.clearAllMocks();
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
const secondBatch = client.request.mock.calls
.filter(([method]) => method === "tasks.get")
.map(([, params]) => (params as { taskId: string }).taskId);
@@ -1224,7 +1072,7 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(getWorkboardState(host).lifecycleTasksPrepared).toBe(false);
vi.clearAllMocks();
@@ -1265,7 +1113,7 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
const firstDiscoveryCalls = client.request.mock.calls.filter(
([method]) => method === "tasks.list",
);
@@ -1277,7 +1125,7 @@ describe("workboard controller", () => {
expect(getWorkboardState(host).lifecycleTasksPrepared).toBe(false);
vi.clearAllMocks();
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
const secondDiscoveryCalls = client.request.mock.calls.filter(
([method]) => method === "tasks.list",
);
@@ -1305,7 +1153,7 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(client.request).toHaveBeenCalledWith("tasks.list", { limit: 500 });
expect(getWorkboardState(host).cards[0]).toMatchObject({ taskId: sampleTask.taskId });
@@ -1350,7 +1198,7 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(client.request).not.toHaveBeenCalledWith("tasks.get", { taskId: missingTaskId });
expect(client.request).toHaveBeenCalledWith("tasks.list", { limit: 500 });
@@ -1359,7 +1207,7 @@ describe("workboard controller", () => {
expect(state.missingTaskIds).toEqual(new Set([missingTaskId]));
vi.clearAllMocks();
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(client.request).toHaveBeenCalledWith("tasks.get", { taskId: replacementTaskId });
expect(client.request).not.toHaveBeenCalledWith("tasks.get", { taskId: missingTaskId });
@@ -1389,11 +1237,11 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(getWorkboardState(host).cards[0]).not.toHaveProperty("taskId");
vi.clearAllMocks();
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
expect(client.request).toHaveBeenCalledWith("tasks.list", {
limit: 500,
@@ -1422,9 +1270,9 @@ describe("workboard controller", () => {
return {};
});
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "poll" });
await refreshWorkboard({ host, client: client as never, source: "live" });
await refreshWorkboard({ host, client: client as never, source: "live" });
await refreshWorkboard({ host, client: client as never, source: "live" });
const discoveryCalls = client.request.mock.calls.filter(([method]) => method === "tasks.list");
expect(discoveryCalls.map(([, params]) => params)).toEqual([
@@ -1434,33 +1282,6 @@ describe("workboard controller", () => {
]);
});
it("defers polling while a card is being dragged", async () => {
vi.useFakeTimers();
const host = {};
const client = createClient({
"workboard.cards.list": { cards: [sampleCard], statuses: ["todo", "done"] },
"tasks.list": { tasks: [] },
});
const state = getWorkboardState(host);
state.autoRefreshIntervalMs = 5000;
state.draggedCardId = sampleCard.id;
configureWorkboardPolling({
host,
client: client as never,
enabled: true,
});
await vi.advanceTimersByTimeAsync(5000);
expect(client.request).not.toHaveBeenCalled();
state.draggedCardId = null;
await vi.advanceTimersByTimeAsync(5000);
expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {});
stopWorkboardPolling(host);
});
it("discards an in-flight poll when a card drag starts", async () => {
const host = {};
const state = getWorkboardState(host);
@@ -1479,7 +1300,7 @@ describe("workboard controller", () => {
const refresh = refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
await Promise.resolve();
state.draggedCardId = initialCard.id;
@@ -1509,7 +1330,7 @@ describe("workboard controller", () => {
const refresh = refreshWorkboard({
host,
client: client as never,
source: "poll",
source: "live",
});
await Promise.resolve();
state.draftOpen = true;
@@ -5594,12 +5415,6 @@ describe("workboard controller", () => {
expect(state.lifecycleTasksPrepared).toBe(false);
vi.clearAllMocks();
configureWorkboardPolling({
host,
client: client as never,
enabled: false,
requestUpdate,
});
await vi.advanceTimersByTimeAsync(100);
expect(requestUpdate).toHaveBeenCalledOnce();
vi.clearAllMocks();
@@ -5660,7 +5475,7 @@ describe("workboard controller", () => {
vi.clearAllMocks();
await vi.advanceTimersByTimeAsync(5000);
expect(requestUpdate).not.toHaveBeenCalled();
expect(requestUpdate).toHaveBeenCalledOnce();
});
it("stops bounded exact confirmations after a transient batch failure", async () => {
@@ -5906,7 +5721,6 @@ describe("workboard controller", () => {
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
state.autoRefreshIntervalMs = 5000;
state.lifecycleTasksPrepared = true;
state.lifecycleTasksPreparedAt = Date.now();
const completedTask = { ...sampleTask, status: "completed" as const };
@@ -5928,173 +5742,6 @@ describe("workboard controller", () => {
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", expect.anything());
});
it("uses the selected auto-refresh interval for prepared lifecycle tasks", async () => {
vi.useFakeTimers();
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
state.autoRefreshIntervalMs = 15000;
state.lifecycleTasksPrepared = true;
state.lifecycleTasksPreparedAt = Date.now();
const completedTask = { ...sampleTask, status: "completed" as const };
const client = createClient({
"tasks.list": { tasks: [completedTask] },
"workboard.cards.update": { card: { ...linked, status: "review" } },
});
const requestUpdate = vi.fn();
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
await vi.advanceTimersByTimeAsync(5000);
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
expect(requestUpdate).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(10000);
expect(requestUpdate).toHaveBeenCalledOnce();
vi.clearAllMocks();
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.list", { limit: 500 });
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", expect.anything());
});
it("does not refresh prepared task lifecycle state while auto-refresh is off", async () => {
vi.useFakeTimers();
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
state.lifecycleTasksPrepared = true;
state.lifecycleTasksPreparedAt = Date.now();
const client = createClient({
"tasks.list": { tasks: [{ ...sampleTask, status: "completed" }] },
});
const requestUpdate = vi.fn();
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
await vi.advanceTimersByTimeAsync(5000);
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
expect(requestUpdate).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalled();
});
it("cancels prepared task lifecycle refresh when auto-refresh is turned off", async () => {
vi.useFakeTimers();
const host = {};
const state = getWorkboardState(host);
const linked = {
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
runId: "run-1",
taskId: "task-1",
} satisfies WorkboardCard;
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
state.autoRefreshIntervalMs = 5000;
state.lifecycleTasksPrepared = true;
state.lifecycleTasksPreparedAt = Date.now();
const client = createClient({
"tasks.list": { tasks: [{ ...sampleTask, status: "completed" }] },
});
const requestUpdate = vi.fn();
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
state.autoRefreshIntervalMs = 0;
configureWorkboardPolling({
host,
client: client as never,
enabled: false,
requestUpdate,
});
await vi.advanceTimersByTimeAsync(5000);
expect(requestUpdate).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalled();
});
it("does not schedule failed task lifecycle retries while auto-refresh is off", async () => {
vi.useFakeTimers();
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
taskId: sampleTask.taskId,
},
];
state.tasksByCardId.set(sampleCard.id, sampleTask);
const requestUpdate = vi.fn();
const client = createClient(() => {
throw new Error("tasks unavailable");
});
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
vi.clearAllMocks();
await vi.advanceTimersByTimeAsync(5000);
expect(requestUpdate).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalled();
});
it("cancels failed task lifecycle retries when auto-refresh is turned off", async () => {
vi.useFakeTimers();
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.cards = [
{
...sampleCard,
status: "running",
sessionKey: sampleTaskSessionKey,
taskId: sampleTask.taskId,
},
];
state.tasksByCardId.set(sampleCard.id, sampleTask);
state.autoRefreshIntervalMs = 5000;
const requestUpdate = vi.fn();
const client = createClient(() => {
throw new Error("tasks unavailable");
});
await syncWorkboardLifecycle({ host, client: client as never, sessions: [], requestUpdate });
vi.clearAllMocks();
state.autoRefreshIntervalMs = 0;
configureWorkboardPolling({
host,
client: client as never,
enabled: false,
requestUpdate,
});
await vi.advanceTimersByTimeAsync(5000);
expect(requestUpdate).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalled();
});
it("retries a failed lifecycle task refresh after backoff", async () => {
vi.useFakeTimers();
const host = {};
@@ -6109,7 +5756,6 @@ describe("workboard controller", () => {
state.loaded = true;
state.cards = [linked];
state.tasksByCardId.set("card-1", sampleTask);
state.autoRefreshIntervalMs = 5000;
const requestUpdate = vi.fn();
let tasksAvailable = false;
const client = createClient((method) => {
+7 -6
View File
@@ -1,7 +1,7 @@
// Control UI Workboard public surface.
export {
WORKBOARD_PRIORITIES,
type WorkboardAutoRefreshIntervalMs,
WORKBOARD_CHANGED_EVENT,
type WorkboardCard,
type WorkboardDependencyState,
type WorkboardEvent,
@@ -23,12 +23,13 @@ export {
} from "./derived.ts";
export { captureSessionToWorkboard } from "./session-capture.ts";
export { getWorkboardDependencyState } from "./card-state.ts";
export { loadWorkboard, refreshWorkboard } from "./loading.ts";
export {
configureWorkboardPolling,
loadWorkboard,
refreshWorkboard,
stopWorkboardPolling,
} from "./loading.ts";
configureWorkboardLiveRefresh,
handleWorkboardChanged,
resumeWorkboardLiveRefresh,
stopWorkboardLiveRefresh,
} from "./live-refresh.ts";
export { findWorkboardSession, getWorkboardLifecycle } from "./lifecycle.ts";
export { syncWorkboardLifecycle } from "./lifecycle-reconciliation.ts";
export {
+236
View File
@@ -0,0 +1,236 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeWorkboardChange } from "./change-payload.ts";
import {
configureWorkboardLiveRefresh,
handleWorkboardChanged,
resumeWorkboardLiveRefresh,
stopWorkboardLiveRefresh,
} from "./live-refresh.ts";
import { loadWorkboard } from "./loading.ts";
import { getWorkboardState } from "./runtime.ts";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function createClient(run: (method: string) => unknown) {
return { request: vi.fn(async (method: string) => run(method)) };
}
afterEach(() => {
vi.useRealTimers();
});
describe("Workboard live refresh", () => {
it("validates the bounded invalidation payload", () => {
expect(normalizeWorkboardChange({ epoch: "epoch-a", revision: 1 })).toEqual({
epoch: "epoch-a",
revision: 1,
});
expect(normalizeWorkboardChange({ epoch: "", revision: 1 })).toBeNull();
expect(normalizeWorkboardChange({ epoch: "epoch-a", revision: 0 })).toBeNull();
expect(normalizeWorkboardChange({ epoch: "epoch-a", revision: Number.NaN })).toBeNull();
expect(normalizeWorkboardChange({ epoch: "epoch-a", revision: 1, cards: [] })).toBeNull();
});
it("rereads canonical cards and ignores stale revisions", async () => {
const host = {};
const client = createClient((method) =>
method === "workboard.cards.list"
? {
cards: [
{
id: "card-1",
title: "Updated elsewhere",
status: "todo",
priority: "normal",
labels: [],
position: 1,
createdAt: 1,
updatedAt: 2,
},
],
statuses: ["todo", "done"],
}
: { tasks: [] },
);
configureWorkboardLiveRefresh({ host, client: client as never });
expect(handleWorkboardChanged(host, { epoch: "epoch-a", revision: 2 })).toBe(true);
await vi.waitFor(() =>
expect(getWorkboardState(host).cards[0]?.title).toBe("Updated elsewhere"),
);
expect(handleWorkboardChanged(host, { epoch: "epoch-a", revision: 1 })).toBe(false);
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(1);
expect(client.request).not.toHaveBeenCalledWith(
"workboard.cards.diagnostics.refresh",
expect.anything(),
);
});
it("requests one canonical reload for each newly installed client", () => {
const host = {};
const first = createClient(() => ({ cards: [], statuses: ["todo", "done"] }));
const second = createClient(() => ({ cards: [], statuses: ["todo", "done"] }));
expect(configureWorkboardLiveRefresh({ host, client: first as never })).toBe(true);
expect(configureWorkboardLiveRefresh({ host, client: first as never })).toBe(false);
expect(configureWorkboardLiveRefresh({ host, client: second as never })).toBe(true);
});
it("coalesces revisions that arrive during a canonical read", async () => {
const host = {};
const firstList = createDeferred<unknown>();
let listCalls = 0;
const client = createClient((method) => {
if (method === "workboard.cards.list") {
listCalls += 1;
return listCalls === 1 ? firstList.promise : { cards: [], statuses: ["todo", "done"] };
}
return { tasks: [] };
});
configureWorkboardLiveRefresh({ host, client: client as never });
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 1 });
await vi.waitFor(() => expect(listCalls).toBe(1));
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 2 });
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 3 });
firstList.resolve({ cards: [], statuses: ["todo", "done"] });
await vi.waitFor(() => expect(listCalls).toBe(2));
await Promise.resolve();
expect(listCalls).toBe(2);
});
it("defers during edits and resumes after the local draft closes", async () => {
const host = {};
const requestUpdate = vi.fn();
const client = createClient((method) =>
method === "workboard.cards.list" ? { cards: [], statuses: ["todo", "done"] } : { tasks: [] },
);
const state = getWorkboardState(host);
state.draftOpen = true;
state.editingCardId = "card-1";
configureWorkboardLiveRefresh({ host, client: client as never, requestUpdate });
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 1 });
await Promise.resolve();
expect(client.request).not.toHaveBeenCalled();
expect(requestUpdate).not.toHaveBeenCalled();
state.draftOpen = false;
state.editingCardId = null;
resumeWorkboardLiveRefresh(host);
await vi.waitFor(() => expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {}));
});
it("retries transient failures and treats a new epoch as authoritative", async () => {
vi.useFakeTimers();
const host = {};
let fail = true;
const client = createClient((method) => {
if (method !== "workboard.cards.list") {
return { tasks: [] };
}
if (fail) {
throw new Error("temporarily unavailable");
}
return { cards: [], statuses: ["todo", "done"] };
});
configureWorkboardLiveRefresh({ host, client: client as never });
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 9 });
await vi.waitFor(() => expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {}));
resumeWorkboardLiveRefresh(host);
configureWorkboardLiveRefresh({ host, client: client as never });
await Promise.resolve();
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(1);
fail = false;
await vi.advanceTimersByTimeAsync(1000);
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(2);
expect(handleWorkboardChanged(host, { epoch: "epoch-b", revision: 1 })).toBe(true);
await vi.waitFor(() =>
expect(
client.request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
).toHaveLength(3),
);
stopWorkboardLiveRefresh(host);
});
it("discards a live read that completes after teardown", async () => {
const host = {};
const list = createDeferred<unknown>();
const client = createClient((method) =>
method === "workboard.cards.list" ? list.promise : { tasks: [] },
);
configureWorkboardLiveRefresh({ host, client: client as never });
handleWorkboardChanged(host, { epoch: "epoch-a", revision: 1 });
await vi.waitFor(() => expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {}));
stopWorkboardLiveRefresh(host);
list.resolve({
cards: [
{
id: "stale-card",
title: "Must not apply",
status: "todo",
priority: "normal",
labels: [],
position: 1,
createdAt: 1,
updatedAt: 2,
},
],
statuses: ["todo", "done"],
});
await list.promise;
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
expect(getWorkboardState(host).cards).toEqual([]);
});
it("discards a direct canonical read that completes after teardown", async () => {
const host = {};
const list = createDeferred<unknown>();
const client = createClient((method) =>
method === "workboard.cards.list" ? list.promise : { tasks: [] },
);
configureWorkboardLiveRefresh({ host, client: client as never });
const loading = loadWorkboard({ host, client: client as never, force: true });
await vi.waitFor(() => expect(client.request).toHaveBeenCalledWith("workboard.cards.list", {}));
stopWorkboardLiveRefresh(host);
list.resolve({
cards: [
{
id: "stale-card",
title: "Must not apply",
status: "todo",
priority: "normal",
labels: [],
position: 1,
createdAt: 1,
updatedAt: 2,
},
],
statuses: ["todo", "done"],
});
await expect(loading).resolves.toBe(false);
expect(getWorkboardState(host).cards).toEqual([]);
expect(getWorkboardState(host).loading).toBe(false);
});
});
+157
View File
@@ -0,0 +1,157 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { normalizeWorkboardChange } from "./change-payload.ts";
import { refreshWorkboard, shouldDeferWorkboardLiveRefresh } from "./loading.ts";
import {
getWorkboardRuntime,
getWorkboardState,
invalidateWorkboardLoads,
type WorkboardHost,
} from "./runtime.ts";
const WORKBOARD_LIVE_REFRESH_RETRY_MS = 1000;
function documentHidden(): boolean {
return typeof document !== "undefined" && document.visibilityState === "hidden";
}
function clearRetry(host: WorkboardHost): void {
const runtime = getWorkboardRuntime(host);
if (runtime.liveRefreshRetryTimer) {
clearTimeout(runtime.liveRefreshRetryTimer);
delete runtime.liveRefreshRetryTimer;
}
}
function scheduleRetry(host: WorkboardHost, generation: number): void {
const runtime = getWorkboardRuntime(host);
if (runtime.liveRefreshRetryTimer) {
return;
}
runtime.liveRefreshRetryTimer = setTimeout(() => {
delete runtime.liveRefreshRetryTimer;
if ((runtime.liveRefreshGeneration ?? 0) === generation) {
void runPendingRefresh(host);
}
}, WORKBOARD_LIVE_REFRESH_RETRY_MS);
}
async function runPendingRefresh(host: WorkboardHost): Promise<void> {
const runtime = getWorkboardRuntime(host);
if (runtime.liveRefreshPromise) {
return await runtime.liveRefreshPromise;
}
const generation = runtime.liveRefreshGeneration ?? 0;
const promise = (async () => {
while (runtime.liveRefreshPending && (runtime.liveRefreshGeneration ?? 0) === generation) {
const entry = runtime.liveRefreshEntry;
const state = getWorkboardState(host);
if (!entry?.client || documentHidden() || shouldDeferWorkboardLiveRefresh(state)) {
return;
}
runtime.liveRefreshPending = false;
const targetEpoch = runtime.liveChangeEpoch;
const targetRevision = runtime.liveHighestSeenRevision ?? 0;
const refreshed = await refreshWorkboard({
host,
client: entry.client,
requestUpdate: entry.requestUpdate,
source: "live",
});
if ((runtime.liveRefreshGeneration ?? 0) !== generation) {
return;
}
if (!refreshed) {
runtime.liveRefreshPending = true;
scheduleRetry(host, generation);
return;
}
if (runtime.liveChangeEpoch === targetEpoch) {
runtime.liveAppliedRevision = Math.max(runtime.liveAppliedRevision ?? 0, targetRevision);
}
runtime.liveRefreshPending =
runtime.liveChangeEpoch !== targetEpoch ||
(runtime.liveHighestSeenRevision ?? 0) > (runtime.liveAppliedRevision ?? 0);
}
})();
runtime.liveRefreshPromise = promise;
try {
await promise;
} finally {
if (runtime.liveRefreshPromise === promise) {
delete runtime.liveRefreshPromise;
}
const state = getWorkboardState(host);
if (
runtime.liveRefreshPending &&
!runtime.liveRefreshRetryTimer &&
runtime.liveRefreshEntry?.client &&
!documentHidden() &&
!shouldDeferWorkboardLiveRefresh(state)
) {
void runPendingRefresh(host);
}
}
}
export function configureWorkboardLiveRefresh(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
requestUpdate?: () => void;
}): boolean {
const runtime = getWorkboardRuntime(params.host);
const requiresCanonicalReload = Boolean(
params.client && runtime.liveRefreshEntry?.client !== params.client,
);
runtime.liveRefreshEntry = {
client: params.client,
requestUpdate: params.requestUpdate,
};
if (runtime.liveRefreshPending && !runtime.liveRefreshRetryTimer) {
void runPendingRefresh(params.host);
}
return requiresCanonicalReload;
}
export function handleWorkboardChanged(host: WorkboardHost, payload: unknown): boolean {
const change = normalizeWorkboardChange(payload);
if (!change) {
return false;
}
const runtime = getWorkboardRuntime(host);
if (runtime.liveChangeEpoch !== change.epoch) {
runtime.liveChangeEpoch = change.epoch;
runtime.liveHighestSeenRevision = change.revision;
runtime.liveAppliedRevision = 0;
} else if (change.revision <= (runtime.liveHighestSeenRevision ?? 0)) {
return false;
} else {
runtime.liveHighestSeenRevision = change.revision;
}
runtime.liveRefreshPending = true;
clearRetry(host);
void runPendingRefresh(host);
return true;
}
export function resumeWorkboardLiveRefresh(host: WorkboardHost): void {
const runtime = getWorkboardRuntime(host);
if (runtime.liveRefreshPending && !runtime.liveRefreshRetryTimer) {
void runPendingRefresh(host);
}
}
export function stopWorkboardLiveRefresh(host: WorkboardHost): void {
const runtime = getWorkboardRuntime(host);
const loadInFlight = Boolean(runtime.loadPromise);
runtime.liveRefreshGeneration = (runtime.liveRefreshGeneration ?? 0) + 1;
clearRetry(host);
delete runtime.liveRefreshEntry;
delete runtime.liveRefreshPromise;
delete runtime.liveChangeEpoch;
delete runtime.liveHighestSeenRevision;
delete runtime.liveAppliedRevision;
delete runtime.liveRefreshPending;
if (loadInFlight) {
invalidateWorkboardLoads(host);
}
}
+23 -158
View File
@@ -2,15 +2,10 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { formatError } from "./normalization-utils.ts";
import { normalizeCardsPayload } from "./normalization.ts";
import {
currentWorkboardPollingGeneration,
clearWorkboardLifecycleTaskPreparedTimer,
clearWorkboardLifecycleTaskRetryTimer,
getWorkboardRuntime,
getWorkboardState,
isCurrentWorkboardLoadGeneration,
isCurrentWorkboardPollingGeneration,
nextWorkboardLoadGeneration,
nextWorkboardPollingGeneration,
resetWorkboardLifecycleTaskConfirmations,
setWorkboardLifecycleTaskRefreshFailed,
setWorkboardLifecycleTasksPrepared,
@@ -217,7 +212,7 @@ async function loadWorkboardInternal(
if (!isCurrentWorkboardLoadGeneration(params.host, generation)) {
return false;
}
if (params.taskRefresh === "linked" && shouldDeferWorkboardPoll(state)) {
if (params.taskRefresh === "linked" && shouldDeferWorkboardLiveRefresh(state)) {
return false;
}
if (nextUnfilteredCursor !== undefined) {
@@ -308,75 +303,44 @@ export async function refreshWorkboard(params: {
requestUpdate?: () => void;
source: WorkboardRefreshSource;
refreshDiagnostics?: boolean;
pollGeneration?: number;
}) {
}): Promise<boolean> {
const state = getWorkboardState(params.host);
const pollGeneration =
params.source === "poll"
? (params.pollGeneration ?? currentWorkboardPollingGeneration(params.host))
: null;
if (
pollGeneration !== null &&
!isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
return;
}
const passive = params.source === "live";
if (state.dispatching || workboardHasActiveWrites(state)) {
return;
return false;
}
const startedAt = Date.now();
state.lastRefreshStartedAt = startedAt;
state.lastRefreshSource = params.source;
if (params.source !== "poll" || !state.lifecycleTaskRefreshFailed) {
if (!passive || !state.lifecycleTaskRefreshFailed) {
state.lastRefreshError = null;
}
if (params.source === "poll") {
state.pollRefreshInProgress = true;
}
params.requestUpdate?.();
if (!params.client) {
state.lastRefreshError = "Gateway client unavailable";
if (
pollGeneration !== null &&
isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
state.pollRefreshInProgress = false;
}
params.requestUpdate?.();
return;
return false;
}
try {
const refreshed = await loadWorkboard({
host: params.host,
client: params.client,
requestUpdate: params.requestUpdate,
force: true,
refreshDiagnostics: params.refreshDiagnostics,
taskRefresh: params.source === "poll" ? "linked" : "all",
preserveError: params.source === "poll",
});
state.lastRefreshSource = params.source;
if (params.source !== "poll" && state.error) {
state.lastRefreshError = state.error;
} else if (refreshed) {
state.lastRefreshAt = Date.now();
}
} finally {
if (
pollGeneration !== null &&
isCurrentWorkboardPollingGeneration(params.host, pollGeneration)
) {
state.pollRefreshInProgress = false;
}
params.requestUpdate?.();
const refreshed = await loadWorkboard({
host: params.host,
client: params.client,
requestUpdate: params.requestUpdate,
force: true,
refreshDiagnostics: params.refreshDiagnostics,
taskRefresh: passive ? "linked" : "all",
preserveError: passive,
});
state.lastRefreshSource = params.source;
if (!passive && state.error) {
state.lastRefreshError = state.error;
} else if (refreshed) {
state.lastRefreshAt = Date.now();
}
params.requestUpdate?.();
return refreshed;
}
function workboardDocumentHidden(): boolean {
return typeof document !== "undefined" && document.visibilityState === "hidden";
}
function shouldDeferWorkboardPoll(state: WorkboardUiState): boolean {
export function shouldDeferWorkboardLiveRefresh(state: WorkboardUiState): boolean {
return Boolean(
state.draftOpen ||
state.editingCardId ||
@@ -387,102 +351,3 @@ function shouldDeferWorkboardPoll(state: WorkboardUiState): boolean {
state.draftCommentBody.trim(),
);
}
function clearWorkboardPolling(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.pollingTimer;
if (timer) {
clearTimeout(timer);
delete runtime.pollingTimer;
}
}
function scheduleWorkboardPoll(host: WorkboardHost) {
clearWorkboardPolling(host);
const runtime = getWorkboardRuntime(host);
const entry = runtime.pollingEntry;
if (!entry?.enabled || !entry.client || entry.intervalMs <= 0) {
return;
}
const pollingGeneration = currentWorkboardPollingGeneration(host);
const timer = setTimeout(() => {
delete runtime.pollingTimer;
if (!isCurrentWorkboardPollingGeneration(host, pollingGeneration)) {
return;
}
const current = runtime.pollingEntry;
const state = getWorkboardState(host);
if (!current?.enabled || !current.client || current.intervalMs <= 0) {
return;
}
const run = async () => {
if (!workboardDocumentHidden() && !shouldDeferWorkboardPoll(state)) {
await refreshWorkboard({
host,
client: current.client,
requestUpdate: current.requestUpdate,
source: "poll",
pollGeneration: pollingGeneration,
});
}
};
void run().finally(() => {
if (isCurrentWorkboardPollingGeneration(host, pollingGeneration)) {
scheduleWorkboardPoll(host);
}
});
}, entry.intervalMs);
runtime.pollingTimer = timer;
}
export function configureWorkboardPolling(params: {
host: WorkboardHost;
client: GatewayBrowserClient | null;
enabled: boolean;
requestUpdate?: () => void;
}) {
const runtime = getWorkboardRuntime(params.host);
const state = getWorkboardState(params.host);
const intervalMs = state.autoRefreshIntervalMs;
const previous = runtime.pollingEntry;
const enabled = params.enabled && intervalMs > 0;
runtime.pollingEntry = {
client: params.client,
enabled,
intervalMs,
requestUpdate: params.requestUpdate,
};
if (!enabled) {
clearWorkboardPolling(params.host);
clearWorkboardLifecycleTaskPreparedTimer(params.host);
clearWorkboardLifecycleTaskRetryTimer(params.host);
return;
}
const configChanged =
!previous ||
previous.enabled !== enabled ||
previous.intervalMs !== intervalMs ||
previous.client !== params.client;
if (!state.pollRefreshInProgress && (configChanged || !runtime.pollingTimer)) {
scheduleWorkboardPoll(params.host);
}
}
export function stopWorkboardPolling(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
nextWorkboardPollingGeneration(host);
clearWorkboardPolling(host);
delete runtime.pollingEntry;
const state = runtime.state;
if (!state?.pollRefreshInProgress) {
return;
}
state.pollRefreshInProgress = false;
state.loading = false;
if (!state.loaded) {
state.loadAttempted = false;
}
nextWorkboardLoadGeneration(host);
delete runtime.loadPromise;
delete runtime.loadToken;
}
+18 -48
View File
@@ -1,11 +1,6 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { normalizeString, workboardCardSessionKey } from "./card-state.ts";
import {
WORKBOARD_STATUSES,
type WorkboardAutoRefreshIntervalMs,
type WorkboardTaskLinkState,
type WorkboardUiState,
} from "./types.ts";
import { WORKBOARD_STATUSES, type WorkboardTaskLinkState, type WorkboardUiState } from "./types.ts";
export type WorkboardHost = object;
@@ -13,10 +8,8 @@ export type WorkboardLoadToken = {
queuedAfterGeneration?: number;
};
type WorkboardPollingEntry = {
type WorkboardLiveRefreshEntry = {
client: GatewayBrowserClient | null;
enabled: boolean;
intervalMs: WorkboardAutoRefreshIntervalMs;
requestUpdate?: () => void;
};
@@ -29,15 +22,20 @@ type WorkboardRuntime = {
lifecycleWrites: Set<Promise<unknown>>;
loadGeneration?: number;
lifecycleReconciliationEpoch?: number;
pollingGeneration?: number;
liveRefreshGeneration?: number;
liveChangeEpoch?: string;
liveHighestSeenRevision?: number;
liveAppliedRevision?: number;
liveRefreshPending?: boolean;
liveRefreshPromise?: Promise<void>;
taskPollOffset?: number;
taskDiscoveryOffset?: number;
defaultTaskDiscoveryCursor?: string;
pollingTimer?: ReturnType<typeof setTimeout>;
liveRefreshRetryTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskPreparedTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskRetryTimer?: ReturnType<typeof setTimeout>;
lifecycleTaskContinuationTimer?: ReturnType<typeof setTimeout>;
pollingEntry?: WorkboardPollingEntry;
liveRefreshEntry?: WorkboardLiveRefreshEntry;
pendingStatusTransitions: Set<string>;
lifecycleSyncKeys: Map<string, string>;
};
@@ -48,6 +46,7 @@ export const WORKBOARD_LIFECYCLE_TASK_CONFIRMATION_TIMEOUT_ERROR =
"Task confirmation exceeded its freshness window.";
const WORKBOARD_LIFECYCLE_TASK_RETRY_MS = 5000;
const WORKBOARD_LIFECYCLE_TASK_CONTINUE_MS = 100;
const WORKBOARD_LIFECYCLE_TASK_RECONCILE_MS = 5000;
export function nextWorkboardLoadGeneration(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
@@ -60,24 +59,6 @@ export function isCurrentWorkboardLoadGeneration(host: WorkboardHost, generation
return getWorkboardRuntime(host).loadGeneration === generation;
}
export function nextWorkboardPollingGeneration(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
const generation = (runtime.pollingGeneration ?? 0) + 1;
runtime.pollingGeneration = generation;
return generation;
}
export function currentWorkboardPollingGeneration(host: WorkboardHost): number {
return getWorkboardRuntime(host).pollingGeneration ?? 0;
}
export function isCurrentWorkboardPollingGeneration(
host: WorkboardHost,
generation: number,
): boolean {
return currentWorkboardPollingGeneration(host) === generation;
}
function nextWorkboardLifecycleReconciliationEpoch(host: WorkboardHost): number {
const runtime = getWorkboardRuntime(host);
const epoch = (runtime.lifecycleReconciliationEpoch ?? 0) + 1;
@@ -117,7 +98,7 @@ export function invalidateWorkboardLoads(host: WorkboardHost) {
nextWorkboardLifecycleReconciliationEpoch(host);
}
export function clearWorkboardLifecycleTaskPreparedTimer(host: WorkboardHost) {
function clearWorkboardLifecycleTaskPreparedTimer(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.lifecycleTaskPreparedTimer;
if (timer) {
@@ -126,7 +107,7 @@ export function clearWorkboardLifecycleTaskPreparedTimer(host: WorkboardHost) {
}
}
export function clearWorkboardLifecycleTaskRetryTimer(host: WorkboardHost) {
function clearWorkboardLifecycleTaskRetryTimer(host: WorkboardHost) {
const runtime = getWorkboardRuntime(host);
const timer = runtime.lifecycleTaskRetryTimer;
if (timer) {
@@ -219,12 +200,7 @@ export function setWorkboardLifecycleTasksPrepared(
return;
}
clearWorkboardLifecycleTaskPreparedTimer(host);
if (
!prepared ||
!options.requestUpdate ||
state.autoRefreshIntervalMs === 0 ||
!shouldRefreshWorkboardTasksForLifecycle(state)
) {
if (!prepared || !options.requestUpdate || !shouldRefreshWorkboardTasksForLifecycle(state)) {
return;
}
const nextTimer = setTimeout(
@@ -232,7 +208,7 @@ export function setWorkboardLifecycleTasksPrepared(
delete getWorkboardRuntime(host).lifecycleTaskPreparedTimer;
options.requestUpdate?.();
},
Math.max(0, preparedAt + state.autoRefreshIntervalMs - Date.now()),
Math.max(0, preparedAt + WORKBOARD_LIFECYCLE_TASK_RECONCILE_MS - Date.now()),
);
getWorkboardRuntime(host).lifecycleTaskPreparedTimer = nextTimer;
}
@@ -241,10 +217,7 @@ export function workboardLifecycleTasksPreparedAt(state: WorkboardUiState, now =
if (!state.lifecycleTasksPrepared || state.lifecycleTasksPreparedAt === null) {
return null;
}
if (
state.autoRefreshIntervalMs > 0 &&
now - state.lifecycleTasksPreparedAt >= state.autoRefreshIntervalMs
) {
if (now - state.lifecycleTasksPreparedAt >= WORKBOARD_LIFECYCLE_TASK_RECONCILE_MS) {
return null;
}
return state.lifecycleTasksPreparedAt;
@@ -267,7 +240,7 @@ export function setWorkboardLifecycleTaskRefreshFailed(
return;
}
clearWorkboardLifecycleTaskRetryTimer(host);
if (!failed || !options.requestUpdate || state.autoRefreshIntervalMs === 0) {
if (!failed || !options.requestUpdate) {
return;
}
const nextTimer = setTimeout(() => {
@@ -296,8 +269,7 @@ export function setWorkboardLifecycleTaskRefreshContinuation(
if (!pending || !options.requestUpdate) {
return;
}
// Continue bounded exact-confirmation even when routine polling is off.
// Keep this separate so render polling cannot cancel the freshness-bounded sequence.
// Continue bounded exact-confirmation independently from live card invalidation.
const nextTimer = setTimeout(() => {
delete getWorkboardRuntime(host).lifecycleTaskContinuationTimer;
options.requestUpdate?.();
@@ -346,12 +318,10 @@ function createDefaultState(): WorkboardUiState {
showArchived: false,
layout: "compact",
hideEmptyColumns: false,
autoRefreshIntervalMs: 0,
lastRefreshAt: null,
lastRefreshStartedAt: null,
lastRefreshError: null,
lastRefreshSource: null,
pollRefreshInProgress: false,
lifecycleTasksPrepared: false,
lifecycleTasksPreparedAt: null,
lifecycleTaskRefreshFailed: false,
+1 -5
View File
@@ -71,9 +71,7 @@ export type WorkboardDispatchSummary = {
orchestrated: number;
};
export type WorkboardAutoRefreshIntervalMs = 0 | 5000 | 15000 | 30000 | 60000;
export type WorkboardRefreshSource = "initial" | "manual" | "poll";
export type WorkboardRefreshSource = "initial" | "manual" | "live";
export type WorkboardViewPresetId =
| "all"
@@ -117,12 +115,10 @@ export type WorkboardUiState = {
showArchived: boolean;
layout: "comfortable" | "compact";
hideEmptyColumns: boolean;
autoRefreshIntervalMs: WorkboardAutoRefreshIntervalMs;
lastRefreshAt: number | null;
lastRefreshStartedAt: number | null;
lastRefreshError: string | null;
lastRefreshSource: WorkboardRefreshSource | null;
pollRefreshInProgress: boolean;
lifecycleTasksPrepared: boolean;
lifecycleTasksPreparedAt: number | null;
lifecycleTaskRefreshFailed: boolean;
+2 -8
View File
@@ -56,12 +56,11 @@ function changeWorkboardSelect(select: Element | null | undefined, value: string
}
describe("renderWorkboard", () => {
it("hides the manual refresh button while auto-refresh is enabled", () => {
it("keeps manual recovery refresh visible while data is loading", () => {
const host = {};
const state = getWorkboardState(host);
state.loaded = true;
state.loading = true;
state.autoRefreshIntervalMs = 5000;
const container = document.createElement("div");
render(
@@ -77,10 +76,7 @@ describe("renderWorkboard", () => {
container,
);
expect(buttonByText(container, "Refresh")).toBeNull();
expect(container.querySelector(".workboard-toolbar__actions")?.textContent).not.toContain(
"Refreshing",
);
expect(buttonByText(container, "Refreshing")?.disabled).toBe(true);
});
it("renders lifecycle refresh errors without replacing generic errors", () => {
@@ -114,7 +110,6 @@ describe("renderWorkboard", () => {
const state = getWorkboardState(host);
state.loaded = true;
state.loading = true;
state.autoRefreshIntervalMs = 5000;
const container = document.createElement("div");
const props: WorkboardRenderProps = {
host,
@@ -137,7 +132,6 @@ describe("renderWorkboard", () => {
expect(buttonByText(container, "Dispatch ready work")?.disabled).toBe(true);
state.loading = false;
state.autoRefreshIntervalMs = 0;
render(renderWorkboard(props), container);
expect(buttonByText(container, "Refresh")?.disabled).toBe(true);
+15 -60
View File
@@ -14,7 +14,6 @@ import "../../styles/workboard.css";
import {
addWorkboardCardComment,
archiveWorkboardCard,
configureWorkboardPolling,
deleteWorkboardCard,
dispatchWorkboard,
filterWorkboardCardsForPreset,
@@ -32,7 +31,6 @@ import {
workboardHasActiveWrites,
workboardMutationsReady,
WORKBOARD_PRIORITIES,
type WorkboardAutoRefreshIntervalMs,
type WorkboardDependencyState,
type WorkboardExecutionEngine,
type WorkboardExecutionMode,
@@ -1823,14 +1821,6 @@ function renderWorkboardEmptyState() {
`;
}
const autoRefreshOptions: Array<{ value: WorkboardAutoRefreshIntervalMs; labelKey: string }> = [
{ value: 0, labelKey: "workboard.autoRefreshOff" },
{ value: 5000, labelKey: "workboard.autoRefresh5s" },
{ value: 15000, labelKey: "workboard.autoRefresh15s" },
{ value: 30000, labelKey: "workboard.autoRefresh30s" },
{ value: 60000, labelKey: "workboard.autoRefresh60s" },
];
const viewPresetOptions: Array<{ value: WorkboardUiState["viewPreset"]; labelKey: string }> = [
{ value: "all", labelKey: "workboard.viewAll" },
{ value: "default_agent", labelKey: "workboard.viewDefaultAgent" },
@@ -2116,7 +2106,6 @@ export function renderWorkboard(props: WorkboardProps) {
state.agentFilter !== "all" ||
archivedCardsHidden;
const showEmptyState = filtered.length === 0 && activeFiltering;
const autoRefreshEnabled = state.autoRefreshIntervalMs > 0;
const viewOptions: Array<WorkboardSelectOption<WorkboardUiState["viewPreset"]>> =
viewPresetOptions.map((option) => {
const count = cardsForPreset(option.value).length;
@@ -2261,55 +2250,21 @@ export function renderWorkboard(props: WorkboardProps) {
</label>
</div>
<div class="workboard-toolbar__actions">
${autoRefreshEnabled
? nothing
: html`
<button
class="btn"
type="button"
?disabled=${state.loading ||
state.dispatching ||
workboardHasActiveWrites(state)}
@click=${() =>
refreshWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
source: "manual",
refreshDiagnostics: canWrite(props),
})}
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
`}
<label class="workboard-auto-refresh">
<span>${t("workboard.autoRefresh")}</span>
<select
class="input"
title=${t("workboard.autoRefresh")}
.value=${String(state.autoRefreshIntervalMs)}
@change=${(event: Event) => {
state.autoRefreshIntervalMs = Number(
(event.currentTarget as HTMLSelectElement).value,
) as WorkboardAutoRefreshIntervalMs;
configureWorkboardPolling({
host: props.host,
client: props.client,
enabled:
props.connected &&
props.pluginEnabled === true &&
state.autoRefreshIntervalMs > 0,
requestUpdate: props.onRequestUpdate,
});
props.onRequestUpdate?.();
}}
>
${autoRefreshOptions.map(
(option) =>
html`<option value=${String(option.value)}>${t(option.labelKey)}</option>`,
)}
</select>
</label>
<button
class="btn"
type="button"
?disabled=${state.loading || state.dispatching || workboardHasActiveWrites(state)}
@click=${() =>
refreshWorkboard({
host: props.host,
client: props.client,
requestUpdate: props.onRequestUpdate,
source: "manual",
refreshDiagnostics: canWrite(props),
})}
>
${state.loading ? t("common.refreshing") : t("common.refresh")}
</button>
${writable
? html`
<button
+88 -9
View File
@@ -3,17 +3,22 @@ import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/c
import { createWorkboardCapability } from "../../lib/workboard/capability.ts";
import type { WorkboardCapability } from "../../lib/workboard/capability.ts";
const { stopPolling, stopLifecycleRefresh } = vi.hoisted(() => ({
stopPolling: vi.fn(),
stopLifecycleRefresh: vi.fn(),
}));
const { configureLiveRefresh, handleChanged, loadBoard, stopLiveRefresh, stopLifecycleRefresh } =
vi.hoisted(() => ({
configureLiveRefresh: vi.fn((): boolean => false),
handleChanged: vi.fn(),
loadBoard: vi.fn(async () => true),
stopLiveRefresh: vi.fn(),
stopLifecycleRefresh: vi.fn(),
}));
vi.mock("../../lib/workboard/index.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/workboard/index.ts")>()),
configureWorkboardPolling: vi.fn(),
loadWorkboard: vi.fn(async () => true),
configureWorkboardLiveRefresh: configureLiveRefresh,
handleWorkboardChanged: handleChanged,
loadWorkboard: loadBoard,
stopWorkboardLifecycleRefresh: stopLifecycleRefresh,
stopWorkboardPolling: stopPolling,
stopWorkboardLiveRefresh: stopLiveRefresh,
syncWorkboardLifecycle: vi.fn(async () => undefined),
}));
@@ -39,10 +44,15 @@ function contextWithWorkboard(workboard: WorkboardCapability): ApplicationContex
const subscribe = () => () => undefined;
return {
basePath: "",
gateway: { snapshot, subscribe } as unknown as ApplicationContext["gateway"],
gateway: {
snapshot,
subscribe,
subscribeEvents: subscribe,
} as unknown as ApplicationContext["gateway"],
agents: {
state: { agentsList: null, agentsLoading: false },
subscribe,
ensureList: vi.fn(async () => undefined),
} as unknown as ApplicationContext["agents"],
runtimeConfig: {
state: {
@@ -52,10 +62,12 @@ function contextWithWorkboard(workboard: WorkboardCapability): ApplicationContex
configLoading: false,
},
subscribe,
ensureLoaded: vi.fn(async () => undefined),
} as unknown as ApplicationContext["runtimeConfig"],
sessions: {
state: { result: null, loading: false },
subscribe,
refresh: vi.fn(async () => undefined),
} as unknown as ApplicationContext["sessions"],
agentSelection: {
state: { selectedId: "main", scopeId: "main" },
@@ -71,10 +83,77 @@ function contextWithWorkboard(workboard: WorkboardCapability): ApplicationContex
afterEach(() => {
document.body.replaceChildren();
configureLiveRefresh.mockReset().mockReturnValue(false);
loadBoard.mockClear();
vi.clearAllMocks();
});
describe("WorkboardPage lifecycle", () => {
it("routes Workboard invalidation events to the active capability", async () => {
const workboard = createWorkboardCapability();
const context = contextWithWorkboard(workboard);
let eventListener: Parameters<typeof context.gateway.subscribeEvents>[0] | undefined;
context.gateway.subscribeEvents = (listener) => {
eventListener = listener;
return () => undefined;
};
context.gateway.snapshot.connected = true;
context.gateway.snapshot.client = { request: vi.fn() } as never;
const page = document.createElement("openclaw-workboard-page") as WorkboardPageTestElement;
page.context = context;
document.body.append(page);
await page.updateComplete;
eventListener?.({
type: "event",
event: "plugin.workboard.changed",
payload: { epoch: "epoch-a", revision: 1 },
});
expect(handleChanged).toHaveBeenCalledWith(workboard, {
epoch: "epoch-a",
revision: 1,
});
});
it("forces one canonical reload when the live client is newly installed", async () => {
const workboard = createWorkboardCapability();
const context = contextWithWorkboard(workboard);
context.gateway.snapshot.connected = true;
context.gateway.snapshot.client = { request: vi.fn() } as never;
configureLiveRefresh.mockReturnValueOnce(true);
const page = document.createElement("openclaw-workboard-page") as WorkboardPageTestElement;
page.context = context;
document.body.append(page);
await page.updateComplete;
expect(loadBoard).toHaveBeenCalledWith(
expect.objectContaining({ host: workboard, force: true }),
);
});
it("tears down immediately when the Gateway disconnects", async () => {
const workboard = createWorkboardCapability();
const context = contextWithWorkboard(workboard);
let snapshotListener: Parameters<typeof context.gateway.subscribe>[0] | undefined;
context.gateway.subscribe = (listener) => {
snapshotListener = listener;
return () => undefined;
};
context.gateway.snapshot.connected = true;
context.gateway.snapshot.client = { request: vi.fn() } as never;
const page = document.createElement("openclaw-workboard-page") as WorkboardPageTestElement;
page.context = context;
document.body.append(page);
await page.updateComplete;
vi.clearAllMocks();
snapshotListener?.({ ...context.gateway.snapshot, connected: false, client: null });
expect(stopLiveRefresh).toHaveBeenCalledWith(workboard);
expect(stopLifecycleRefresh).toHaveBeenCalledWith(workboard);
});
it("stops the previous capability runtime when the workboard source changes", async () => {
const first = createWorkboardCapability();
const second = createWorkboardCapability();
@@ -88,7 +167,7 @@ describe("WorkboardPage lifecycle", () => {
(page as unknown as { requestUpdate: () => void }).requestUpdate();
await page.updateComplete;
expect(stopPolling).toHaveBeenCalledWith(first);
expect(stopLiveRefresh).toHaveBeenCalledWith(first);
expect(stopLifecycleRefresh).toHaveBeenCalledWith(first);
});
+35 -7
View File
@@ -8,11 +8,14 @@ import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.
import { searchForSession } from "../../lib/sessions/index.ts";
import { resetDraftState } from "../../lib/workboard/card-state.ts";
import {
configureWorkboardPolling,
configureWorkboardLiveRefresh,
handleWorkboardChanged,
loadWorkboard,
resumeWorkboardLiveRefresh,
stopWorkboardLifecycleRefresh,
stopWorkboardPolling,
stopWorkboardLiveRefresh,
syncWorkboardLifecycle,
WORKBOARD_CHANGED_EVENT,
} from "../../lib/workboard/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
@@ -60,7 +63,7 @@ class WorkboardPage extends OpenClawLightDomElement {
const unsubscribe = workboard.subscribe(() => this.requestUpdate());
return () => {
unsubscribe();
stopWorkboardPolling(workboard);
stopWorkboardLiveRefresh(workboard);
stopWorkboardLifecycleRefresh(workboard);
};
},
@@ -71,25 +74,50 @@ class WorkboardPage extends OpenClawLightDomElement {
const handleSnapshot = (snapshot: ApplicationContext["gateway"]["snapshot"]) => {
if (snapshot.connected && snapshot.client) {
this.ensureInitialData();
} else if (this.context?.workboard) {
// Teardown at the observed disconnect, not a later render that a fast reconnect may skip.
stopWorkboardLiveRefresh(this.context.workboard);
stopWorkboardLifecycleRefresh(this.context.workboard);
}
this.requestUpdate();
};
handleSnapshot(gateway.snapshot);
return gateway.subscribe(handleSnapshot);
},
)
.effect(
() => this.context?.gateway,
(gateway) =>
gateway.subscribeEvents((event) => {
const workboard = this.context?.workboard;
if (workboard && gateway.snapshot.connected && event.event === WORKBOARD_CHANGED_EVENT) {
handleWorkboardChanged(workboard, event.payload);
}
}),
);
private readonly handleVisibilityChange = () => {
if (document.visibilityState === "visible" && this.context?.workboard) {
resumeWorkboardLiveRefresh(this.context.workboard);
}
};
override connectedCallback() {
super.connectedCallback();
this.ensureInitialData();
this.syncWorkboardRuntime();
document.addEventListener("visibilitychange", this.handleVisibilityChange);
}
override updated() {
this.syncWorkboardRuntime();
if (this.context?.workboard) {
resumeWorkboardLiveRefresh(this.context.workboard);
}
}
override disconnectedCallback() {
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.subscriptions.clear();
super.disconnectedCallback();
}
@@ -122,25 +150,25 @@ class WorkboardPage extends OpenClawLightDomElement {
const pluginEnabled = this.pluginEnabled();
if (!context || !gateway?.connected || !gateway.client || pluginEnabled !== true) {
if (context) {
stopWorkboardPolling(context.workboard);
stopWorkboardLiveRefresh(context.workboard);
stopWorkboardLifecycleRefresh(context.workboard);
}
return;
}
const state = context.workboard.state;
configureWorkboardPolling({
const requiresCanonicalReload = configureWorkboardLiveRefresh({
host: context.workboard,
client: gateway.client,
enabled: state.autoRefreshIntervalMs > 0,
requestUpdate: this.requestPageUpdate,
});
void loadWorkboard({
host: context.workboard,
client: gateway.client,
requestUpdate: this.requestPageUpdate,
force: requiresCanonicalReload,
refreshDiagnostics: hasOperatorWriteAccess(gateway.hello?.auth ?? null),
});
if (!state.pollRefreshInProgress && !state.dispatching) {
if (!state.dispatching) {
void syncWorkboardLifecycle({
host: context.workboard,
client: gateway.client,
+44 -4
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Locator, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/version.js";
import { WORKBOARD_CHANGED_EVENT } from "../../../../packages/workboard-contract/src/index.js";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { WorkboardCard, WorkboardStatus } from "../../lib/workboard/index.ts";
import {
@@ -343,6 +344,11 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
status: "review",
updatedAt: baseTime + 4,
});
const liveRefreshedCard = card({
...reviewedCard,
notes: "Acceptance: live Gateway invalidation refreshed this card",
updatedAt: baseTime + 5,
});
const writable = await newRecordedPage("workboard-writable");
try {
@@ -563,6 +569,40 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
await details.locator('button[aria-label="Cancel"]').click();
await details.waitFor({ state: "hidden" });
await cardInColumn(writable.page, "Review", editedCard.title)
.locator('button[aria-label="Edit card"]')
.click();
await expect.poll(() => editDialog.isVisible()).toBe(true);
const listBeforeLiveRefresh = (await writableGateway.getRequests("workboard.cards.list"))
.length;
await writableGateway.deferNext("workboard.cards.list");
await writableGateway.emitGatewayEvent(WORKBOARD_CHANGED_EVENT, {
epoch: "workboard-e2e",
revision: 1,
});
await writable.page.waitForTimeout(250);
expect(await writableGateway.getRequests("workboard.cards.list")).toHaveLength(
listBeforeLiveRefresh,
);
await editForm
.locator("form > .workboard-modal__actions")
.getByRole("button", { name: "Cancel", exact: true })
.click();
await waitForNextRequest(writableGateway, "workboard.cards.list", listBeforeLiveRefresh);
await writableGateway.resolveDeferred("workboard.cards.list", {
cards: [liveRefreshedCard],
statuses: WORKBOARD_STATUSES,
});
await writable.page
.getByText("Acceptance: live Gateway invalidation refreshed this card")
.waitFor({ state: "visible" });
const listAfterLiveRefresh = (await writableGateway.getRequests("workboard.cards.list"))
.length;
await writable.page.waitForTimeout(1_250);
expect(await writableGateway.getRequests("workboard.cards.list")).toHaveLength(
listAfterLiveRefresh,
);
await writableGateway.deferNext("workboard.cards.list");
const listBeforeReload = (await writableGateway.getRequests("workboard.cards.list")).length;
await writable.page
@@ -571,13 +611,13 @@ describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
.click();
await waitForNextRequest(writableGateway, "workboard.cards.list", listBeforeReload);
await writableGateway.resolveDeferred("workboard.cards.list", {
cards: [reviewedCard],
cards: [liveRefreshedCard],
statuses: WORKBOARD_STATUSES,
});
await cardInColumn(writable.page, "Review", editedCard.title).waitFor({ state: "visible" });
await writable.page.getByText("Acceptance: mocked Gateway browser proof").waitFor({
state: "visible",
});
await writable.page
.getByText("Acceptance: live Gateway invalidation refreshed this card")
.waitFor({ state: "visible" });
await captureScreenshot(writable.page, artifacts, "08-reloaded-review");
} finally {
await closeRecordedPage(writable, artifacts, "workboard-writable");
-12
View File
@@ -50,7 +50,6 @@
.workboard-toolbar__filters,
.workboard-toolbar__actions,
.workboard-auto-refresh,
.workboard-draft__meta,
.workboard-template-strip,
.workboard-card__actions,
@@ -73,17 +72,6 @@
min-width: 260px;
}
.workboard-auto-refresh {
gap: 6px;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
}
.workboard-auto-refresh select.input {
width: 82px;
}
.workboard-refresh-status {
color: var(--muted);
font-size: 0.76rem;