mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
2444013175
commit
9d79d85f46
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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,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>;
|
||||
|
||||
Vendored
+26
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user