mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Fix Workboard status persistence
Summary: - Persist Workboard lifecycle status provenance so stale linked session/task lifecycle updates cannot overwrite newer manual or non-default creation status. - Add focused Workboard store/controller regressions for lifecycle-vs-manual precedence and creation-status precedence. - Add mocked Control UI browser E2E proof for create/edit/reopen, running move, lifecycle sync, reload persistence, and read-only operator behavior. Verification: - `node scripts/run-vitest.mjs extensions/workboard/src/store.test.ts extensions/workboard/src/gateway.test.ts --reporter=verbose` - `node scripts/run-vitest.mjs ui/src/ui/controllers/workboard.test.ts ui/src/ui/views/workboard.test.ts --reporter=verbose` - `node scripts/run-vitest.mjs --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts ui/src/ui/e2e/workboard.e2e.test.ts --reporter=verbose` - `corepack pnpm tsgo:core:test` - `corepack pnpm tsgo:extensions:test` - `node scripts/run-oxlint.mjs extensions/workboard/src/sqlite-store.ts extensions/workboard/src/store.test.ts extensions/workboard/src/store.ts extensions/workboard/src/types.ts ui/src/ui/controllers/workboard.test.ts ui/src/ui/controllers/workboard.ts ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts ui/src/ui/e2e/workboard.e2e.test.ts ui/src/ui/views/workboard.test.ts ui/src/ui/views/workboard.ts` - `git diff --check` - `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` clean - GitHub PR checks green on head `6d05d6edd5ca6cbb2e625f3e478e973feba5e4cf` Proof: - E2E manifest: `/Users/buns/.codex/worktrees/74e7/openclaw/.artifacts/control-ui-e2e/workboard/manifest.json` - Live Gateway success proof: `/Users/buns/.codex/worktrees/74e7/openclaw/.artifacts/live-workboard/proof/12-live-review-success.png` - Remaining gap: read-only operator behavior is covered by mocked browser E2E, not live Gateway.
This commit is contained in:
@@ -26,7 +26,7 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
|
||||
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
|
||||
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
|
||||
@@ -118,6 +118,21 @@ function runTransaction<T>(db: DatabaseSync, run: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
function tableColumns(db: DatabaseSync, tableName: string): Set<string> {
|
||||
return new Set(
|
||||
(db.prepare(`PRAGMA table_info(${tableName})`).all() as Row[]).flatMap((row) =>
|
||||
typeof row.name === "string" ? [row.name] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureColumn(db: DatabaseSync, tableName: string, columnName: string, definition: string) {
|
||||
if (tableColumns(db, tableName).has(columnName)) {
|
||||
return;
|
||||
}
|
||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
|
||||
}
|
||||
|
||||
function ensureWorkboardSchema(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -171,6 +186,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
|
||||
template_id TEXT,
|
||||
archived_at INTEGER,
|
||||
stale_json TEXT,
|
||||
lifecycle_status_source_updated_at INTEGER,
|
||||
failure_count INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS workboard_cards_board_status_idx
|
||||
@@ -333,6 +349,12 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumn(
|
||||
db,
|
||||
"workboard_cards",
|
||||
"lifecycle_status_source_updated_at",
|
||||
"lifecycle_status_source_updated_at INTEGER",
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
|
||||
).run(`schema-${SCHEMA_VERSION}`, Date.now());
|
||||
@@ -630,6 +652,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
|
||||
const automation = parseJson(row.automation_json) as WorkboardMetadata["automation"] | undefined;
|
||||
const claim = parseJson(row.claim_json) as WorkboardMetadata["claim"] | undefined;
|
||||
const stale = parseJson(row.stale_json) as WorkboardMetadata["stale"] | undefined;
|
||||
const lifecycleStatusSourceUpdatedAt = numberValue(row, "lifecycle_status_source_updated_at");
|
||||
return optional({
|
||||
...(attempts.length > 0 ? { attempts } : {}),
|
||||
...(comments.length > 0 ? { comments } : {}),
|
||||
@@ -660,6 +683,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
|
||||
? { archivedAt: numberValue(row, "archived_at") }
|
||||
: {}),
|
||||
...(stale ? { stale } : {}),
|
||||
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
|
||||
...(numberValue(row, "failure_count") !== undefined
|
||||
? { failureCount: numberValue(row, "failure_count") }
|
||||
: {}),
|
||||
@@ -738,14 +762,14 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
|
||||
execution_id, execution_kind, execution_engine, execution_mode, execution_status,
|
||||
execution_model, execution_session_key, execution_run_id, execution_started_at,
|
||||
execution_updated_at, automation_json, claim_json, template_id, archived_at, stale_json,
|
||||
failure_count
|
||||
lifecycle_status_source_updated_at, failure_count
|
||||
) VALUES (
|
||||
@id, @board_id, @title, @notes, @status, @priority, @agent_id, @session_key, @run_id,
|
||||
@task_id, @source_url, @position, @created_at, @updated_at, @started_at, @completed_at,
|
||||
@execution_id, @execution_kind, @execution_engine, @execution_mode, @execution_status,
|
||||
@execution_model, @execution_session_key, @execution_run_id, @execution_started_at,
|
||||
@execution_updated_at, @automation_json, @claim_json, @template_id, @archived_at,
|
||||
@stale_json, @failure_count
|
||||
@stale_json, @lifecycle_status_source_updated_at, @failure_count
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board_id = excluded.board_id,
|
||||
@@ -778,6 +802,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
|
||||
template_id = excluded.template_id,
|
||||
archived_at = excluded.archived_at,
|
||||
stale_json = excluded.stale_json,
|
||||
lifecycle_status_source_updated_at = excluded.lifecycle_status_source_updated_at,
|
||||
failure_count = excluded.failure_count
|
||||
`,
|
||||
).run({
|
||||
@@ -812,6 +837,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
|
||||
template_id: bindNull(metadata?.templateId),
|
||||
archived_at: bindNull(metadata?.archivedAt),
|
||||
stale_json: jsonValue(metadata?.stale),
|
||||
lifecycle_status_source_updated_at: bindNull(metadata?.lifecycleStatusSourceUpdatedAt),
|
||||
failure_count: bindNull(metadata?.failureCount),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ describe("WorkboardStore", () => {
|
||||
fileName: "large-proof.bin",
|
||||
contentBase64: Buffer.alloc(70 * 1024).toString("base64"),
|
||||
});
|
||||
await store.update(card.id, {
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1234 },
|
||||
});
|
||||
const attachmentId = attached.metadata?.attachments?.[0]?.id;
|
||||
const subscription = await store.subscribeNotifications({
|
||||
boardId: board.id,
|
||||
@@ -118,6 +121,7 @@ describe("WorkboardStore", () => {
|
||||
labels: ["sqlite", "doctor"],
|
||||
metadata: {
|
||||
automation: { boardId: "planning" },
|
||||
lifecycleStatusSourceUpdatedAt: 1234,
|
||||
comments: [expect.objectContaining({ body: "round trip" })],
|
||||
attachments: expect.arrayContaining([
|
||||
expect.objectContaining({ fileName: "proof.txt" }),
|
||||
@@ -334,6 +338,192 @@ describe("WorkboardStore", () => {
|
||||
expect(rolledBack.completedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks lifecycle status provenance and clears it on manual status changes", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Sync status provenance" });
|
||||
|
||||
const zeroSourceLifecycle = await store.update(card.id, {
|
||||
status: "running",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
|
||||
});
|
||||
expect(zeroSourceLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(0);
|
||||
|
||||
const lifecycleMoved = await store.update(card.id, {
|
||||
status: "running",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
|
||||
});
|
||||
expect(lifecycleMoved.metadata?.lifecycleStatusSourceUpdatedAt).toBe(1000);
|
||||
|
||||
const newerLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
|
||||
});
|
||||
expect(newerLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
|
||||
|
||||
const manual = await store.move(card.id, "running", 2000);
|
||||
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
|
||||
const staleZeroLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
|
||||
});
|
||||
expect(staleZeroLifecycle).toEqual(manual);
|
||||
expect(staleZeroLifecycle.status).toBe("running");
|
||||
expect(staleZeroLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
|
||||
const staleLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
|
||||
});
|
||||
expect(staleLifecycle).toEqual(manual);
|
||||
expect(staleLifecycle.status).toBe("running");
|
||||
expect(staleLifecycle.updatedAt).toBe(manual.updatedAt);
|
||||
expect(staleLifecycle.events).toHaveLength(manual.events?.length ?? 0);
|
||||
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
|
||||
const freshLifecycleSourceUpdatedAt = Date.now() + 1000;
|
||||
const freshLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: freshLifecycleSourceUpdatedAt },
|
||||
});
|
||||
expect(freshLifecycle.status).toBe("review");
|
||||
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(
|
||||
freshLifecycleSourceUpdatedAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps creation status from stale lifecycle patches", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(2000);
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({
|
||||
title: "Initial running status",
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const staleLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
|
||||
});
|
||||
expect(staleLifecycle).toEqual(card);
|
||||
expect(staleLifecycle.status).toBe("running");
|
||||
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
|
||||
const freshLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
|
||||
});
|
||||
expect(freshLifecycle.status).toBe("review");
|
||||
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-status fields from stale lifecycle patches", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({
|
||||
title: "Keep stale sync details",
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
runId: "run-1",
|
||||
startedAt: 1,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
});
|
||||
const lifecycleMoved = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: {
|
||||
lifecycleStatusSourceUpdatedAt: 1000,
|
||||
stale: {
|
||||
detectedAt: 1000,
|
||||
lastSessionUpdatedAt: 1000,
|
||||
reason: "Session has not reported recent activity.",
|
||||
},
|
||||
},
|
||||
});
|
||||
const manual = await store.update(card.id, {
|
||||
status: "running",
|
||||
metadata: lifecycleMoved.metadata,
|
||||
});
|
||||
|
||||
const synced = await store.update(card.id, {
|
||||
status: "review",
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "done",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: "agent:main:dashboard:1",
|
||||
runId: "run-1",
|
||||
startedAt: 1,
|
||||
updatedAt: 2000,
|
||||
},
|
||||
metadata: {
|
||||
lifecycleStatusSourceUpdatedAt: 1000,
|
||||
stale: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(manual.metadata?.stale).toBeDefined();
|
||||
expect(synced.status).toBe("running");
|
||||
expect(synced.execution).toMatchObject({
|
||||
runId: "run-1",
|
||||
status: "done",
|
||||
updatedAt: 2000,
|
||||
});
|
||||
expect(synced.metadata?.stale).toBeUndefined();
|
||||
expect(synced.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
expect(synced.events?.at(-1)).toMatchObject({
|
||||
kind: "attempt_updated",
|
||||
runId: "run-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears copied lifecycle provenance on manual status patches", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({ title: "Clear copied provenance" });
|
||||
const lifecycleMoved = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: {
|
||||
lifecycleStatusSourceUpdatedAt: 1000,
|
||||
stale: {
|
||||
kind: "session",
|
||||
status: "done",
|
||||
updatedAt: 1000,
|
||||
observedAt: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manual = await store.update(card.id, {
|
||||
status: "running",
|
||||
metadata: {
|
||||
...lifecycleMoved.metadata,
|
||||
stale: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(manual.status).toBe("running");
|
||||
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
|
||||
const staleLifecycle = await store.update(card.id, {
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
|
||||
});
|
||||
expect(staleLifecycle.status).toBe("running");
|
||||
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps execution session links aligned with edited card links", async () => {
|
||||
const store = new WorkboardStore(createMemoryStore());
|
||||
const card = await store.create({
|
||||
|
||||
@@ -1215,6 +1215,7 @@ function normalizeMetadata(
|
||||
: null;
|
||||
const hasArchivedAt = Object.hasOwn(record, "archivedAt");
|
||||
const hasStale = Object.hasOwn(record, "stale");
|
||||
const hasLifecycleStatusSourceUpdatedAt = Object.hasOwn(record, "lifecycleStatusSourceUpdatedAt");
|
||||
const links = Array.isArray(record.links)
|
||||
? record.links.map(normalizeLink).filter((link): link is WorkboardLink => link !== null)
|
||||
: undefined;
|
||||
@@ -1311,6 +1312,9 @@ function normalizeMetadata(
|
||||
}
|
||||
: undefined
|
||||
: fallback.stale,
|
||||
lifecycleStatusSourceUpdatedAt: hasLifecycleStatusSourceUpdatedAt
|
||||
? normalizeTimestamp(record.lifecycleStatusSourceUpdatedAt, 0)
|
||||
: fallback.lifecycleStatusSourceUpdatedAt,
|
||||
failureCount:
|
||||
typeof record.failureCount === "number" && Number.isFinite(record.failureCount)
|
||||
? Math.max(0, Math.trunc(record.failureCount))
|
||||
@@ -1419,6 +1423,7 @@ function removeUndefinedMetadataFields(metadata: WorkboardMetadata): WorkboardMe
|
||||
"templateId",
|
||||
"archivedAt",
|
||||
"stale",
|
||||
"lifecycleStatusSourceUpdatedAt",
|
||||
"failureCount",
|
||||
] as const) {
|
||||
const value = next[key];
|
||||
@@ -1639,6 +1644,49 @@ function latestMetadataIdChanged(
|
||||
return Boolean(latestId && latestId !== existing?.at(-1)?.id);
|
||||
}
|
||||
|
||||
function lifecycleStatusSourceUpdatedAtFromPatch(metadata: unknown): number | undefined {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.hasOwn(metadata, "lifecycleStatusSourceUpdatedAt")) {
|
||||
return undefined;
|
||||
}
|
||||
const sourceUpdatedAt = normalizeTimestamp(
|
||||
(metadata as Record<string, unknown>).lifecycleStatusSourceUpdatedAt,
|
||||
0,
|
||||
);
|
||||
return sourceUpdatedAt;
|
||||
}
|
||||
|
||||
function latestStatusTransitionAt(card: WorkboardCard): number | undefined {
|
||||
for (let index = (card.events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
||||
const event = card.events?.[index];
|
||||
if (
|
||||
(event?.kind === "moved" || event?.kind === "created") &&
|
||||
((event.kind === "created" && card.status !== "todo") ||
|
||||
(event.kind === "moved" && event.fromStatus !== event.toStatus)) &&
|
||||
event.toStatus === card.status &&
|
||||
typeof event.at === "number" &&
|
||||
Number.isFinite(event.at)
|
||||
) {
|
||||
return event.at;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldSkipPersistedLifecycleStatusUpdate(
|
||||
existing: WorkboardCard,
|
||||
sourceUpdatedAt: number,
|
||||
): boolean {
|
||||
const lifecycleStatusSourceUpdatedAt = existing.metadata?.lifecycleStatusSourceUpdatedAt;
|
||||
if (lifecycleStatusSourceUpdatedAt !== undefined) {
|
||||
return sourceUpdatedAt < lifecycleStatusSourceUpdatedAt;
|
||||
}
|
||||
const statusTransitionAt = latestStatusTransitionAt(existing);
|
||||
return statusTransitionAt !== undefined && sourceUpdatedAt < statusTransitionAt;
|
||||
}
|
||||
|
||||
function updateEvent(
|
||||
existing: WorkboardCard,
|
||||
next: WorkboardCard,
|
||||
@@ -2586,6 +2634,31 @@ export class WorkboardStore {
|
||||
if (!existing) {
|
||||
throw new Error(`card not found: ${id}`);
|
||||
}
|
||||
const lifecycleStatusSourceUpdatedAt = lifecycleStatusSourceUpdatedAtFromPatch(patch.metadata);
|
||||
const existingLifecycleStatusSourceUpdatedAt =
|
||||
existing.metadata?.lifecycleStatusSourceUpdatedAt;
|
||||
const hasFreshLifecycleStatusSource =
|
||||
lifecycleStatusSourceUpdatedAt !== undefined &&
|
||||
lifecycleStatusSourceUpdatedAt !== existingLifecycleStatusSourceUpdatedAt;
|
||||
if (
|
||||
patch.status !== undefined &&
|
||||
lifecycleStatusSourceUpdatedAt !== undefined &&
|
||||
shouldSkipPersistedLifecycleStatusUpdate(existing, lifecycleStatusSourceUpdatedAt)
|
||||
) {
|
||||
// Ignore stale lifecycle status writes, but still accept any non-status updates in the patch.
|
||||
patch.status = undefined;
|
||||
if (patch.metadata && typeof patch.metadata === "object" && !Array.isArray(patch.metadata)) {
|
||||
const metadataPatch = patch.metadata as Record<string, unknown>;
|
||||
const { lifecycleStatusSourceUpdatedAt: _ignored, ...rest } = metadataPatch;
|
||||
patch.metadata = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
const hasSemanticPatch = Object.entries(patch).some(
|
||||
([key, value]) => key !== "status" && key !== "metadata" && value !== undefined,
|
||||
);
|
||||
if (!hasSemanticPatch && patch.metadata === undefined) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const status = normalizeStatus(patch.status, existing.status);
|
||||
const now = Date.now();
|
||||
const startedAt =
|
||||
@@ -2613,6 +2686,11 @@ export class WorkboardStore {
|
||||
let metadata = normalizeMetadata(patch.metadata, existing.metadata, {
|
||||
allowDependencyLinks: options.allowMetadataDependencyLinks !== false,
|
||||
});
|
||||
if (status !== existing.status && !hasFreshLifecycleStatusSource) {
|
||||
// Status patches often spread existing metadata. Only a newly supplied
|
||||
// lifecycle source is provenance; copied markers must not survive a manual transition.
|
||||
metadata = { ...metadata, lifecycleStatusSourceUpdatedAt: undefined };
|
||||
}
|
||||
const automationPatch: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"tenant",
|
||||
|
||||
@@ -297,6 +297,7 @@ export type WorkboardMetadata = {
|
||||
templateId?: WorkboardTemplateId;
|
||||
archivedAt?: number;
|
||||
stale?: WorkboardStaleState;
|
||||
lifecycleStatusSourceUpdatedAt?: number;
|
||||
failureCount?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ function createClient(
|
||||
return { request };
|
||||
}
|
||||
|
||||
function requestPatch(client: ReturnType<typeof createClient>, index: number) {
|
||||
return (client.request.mock.calls[index]?.[1] as { patch?: Record<string, unknown> } | undefined)
|
||||
?.patch;
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((res) => {
|
||||
@@ -365,6 +370,157 @@ describe("workboard controller", () => {
|
||||
expect(state.editingCardId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps edit-modal status saves from being rewritten by stale lifecycle sync", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
sessionKey: sampleSession.key,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
state.draftOpen = true;
|
||||
state.editingCardId = linked.id;
|
||||
state.draftTitle = linked.title;
|
||||
state.draftNotes = linked.notes ?? "";
|
||||
state.draftStatus = "running";
|
||||
state.draftPriority = linked.priority;
|
||||
state.draftLabels = linked.labels.join(", ");
|
||||
state.draftAgentId = linked.agentId ?? "";
|
||||
state.draftSessionKey = linked.sessionKey ?? "";
|
||||
const saved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.update") {
|
||||
return { card: saved };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await saveWorkboardCardDraft({ host, client: client as never });
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({ status: "running" }),
|
||||
});
|
||||
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
|
||||
id: "card-1",
|
||||
patch: { execution: expect.objectContaining({ status: "review" }) },
|
||||
});
|
||||
expect(requestPatch(client, 1)).not.toHaveProperty("status");
|
||||
expect(state.cards[0]).toMatchObject({ status: "running" });
|
||||
});
|
||||
|
||||
it("blocks stale lifecycle status writes while edit-modal status saves are in flight", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
sessionKey: sampleSession.key,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
state.draftOpen = true;
|
||||
state.editingCardId = linked.id;
|
||||
state.draftTitle = linked.title;
|
||||
state.draftNotes = linked.notes ?? "";
|
||||
state.draftStatus = "running";
|
||||
state.draftPriority = linked.priority;
|
||||
state.draftLabels = linked.labels.join(", ");
|
||||
state.draftAgentId = linked.agentId ?? "";
|
||||
state.draftSessionKey = linked.sessionKey ?? "";
|
||||
const saved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
const saveResponse = createDeferred<{ card: WorkboardCard }>();
|
||||
let updateCalls = 0;
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.update") {
|
||||
updateCalls += 1;
|
||||
if (updateCalls > 1) {
|
||||
return {
|
||||
card: {
|
||||
...linked,
|
||||
execution: { ...linked.execution, status: "succeeded", updatedAt: 3 },
|
||||
updatedAt: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
return saveResponse.promise;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const saving = saveWorkboardCardDraft({ host, client: client as never });
|
||||
await Promise.resolve();
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
|
||||
id: "card-1",
|
||||
patch: { execution: expect.objectContaining({ status: "review" }) },
|
||||
});
|
||||
expect(requestPatch(client, 1)).not.toHaveProperty("status");
|
||||
saveResponse.resolve({ card: saved });
|
||||
await saving;
|
||||
expect(state.cards[0]).toMatchObject({ status: "running" });
|
||||
});
|
||||
|
||||
it("adds operator notes to a selected detail card without opening the edit draft", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
@@ -465,8 +621,8 @@ describe("workboard controller", () => {
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
@@ -1101,8 +1257,8 @@ describe("workboard controller", () => {
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
status: "running",
|
||||
sessionKey: sampleTaskSessionKey,
|
||||
runId: "run-1",
|
||||
startedAt: 10,
|
||||
@@ -1392,6 +1548,395 @@ describe("workboard controller", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps dragged status changes from being rewritten by stale lifecycle sync", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
sessionKey: sampleSession.key,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
model: "openai/gpt-5.5",
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} satisfies WorkboardCard;
|
||||
const moved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
position: 2000,
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: moved };
|
||||
}
|
||||
if (method === "workboard.cards.update") {
|
||||
return { card: { ...moved, status: "review", updatedAt: 3 } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await moveWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
cardId: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
|
||||
id: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
|
||||
id: "card-1",
|
||||
patch: { execution: expect.objectContaining({ status: "review" }) },
|
||||
});
|
||||
expect(requestPatch(client, 1)).not.toHaveProperty("status");
|
||||
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
|
||||
});
|
||||
|
||||
it("blocks stale lifecycle status writes while dragged status changes are in flight", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
sessionKey: sampleSession.key,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
status: "running",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} satisfies WorkboardCard;
|
||||
const moved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
position: 2000,
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const moveResponse = createDeferred<{ card: WorkboardCard }>();
|
||||
let updateCalls = 0;
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.move") {
|
||||
return moveResponse.promise;
|
||||
}
|
||||
if (method === "workboard.cards.update") {
|
||||
updateCalls += 1;
|
||||
if (updateCalls > 1) {
|
||||
throw new Error("expected lifecycle sync to skip pending drag");
|
||||
}
|
||||
return { card: { ...moved, status: "review", updatedAt: 3 } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const moving = moveWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
cardId: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
expect(client.request.mock.calls[1]?.[1]).toMatchObject({
|
||||
id: "card-1",
|
||||
patch: { execution: expect.objectContaining({ status: "review" }) },
|
||||
});
|
||||
expect(requestPatch(client, 1)).not.toHaveProperty("status");
|
||||
moveResponse.resolve({ card: moved });
|
||||
await moving;
|
||||
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
|
||||
});
|
||||
|
||||
it("ignores stale lifecycle responses when dragged status changes while sync is in flight", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = { ...sampleCard, sessionKey: sampleSession.key } satisfies WorkboardCard;
|
||||
const moved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
position: 2000,
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
const staleLifecycleCard = {
|
||||
...linked,
|
||||
status: "review",
|
||||
updatedAt: 3,
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const lifecycleResponse = createDeferred<{ card: WorkboardCard }>();
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.update") {
|
||||
return lifecycleResponse.promise;
|
||||
}
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: moved };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const syncing = syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 1 }],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await moveWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
cardId: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
lifecycleResponse.resolve({ card: staleLifecycleCard });
|
||||
await syncing;
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
|
||||
}),
|
||||
});
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
|
||||
id: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
|
||||
});
|
||||
|
||||
it("ignores lifecycle responses without provenance when dragged status changes while sync is in flight", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
sessionKey: sampleSession.key,
|
||||
execution: {
|
||||
id: "exec-1",
|
||||
kind: "agent-session",
|
||||
engine: "codex",
|
||||
mode: "autonomous",
|
||||
model: "openai/gpt-5.5",
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} satisfies WorkboardCard;
|
||||
const moved = {
|
||||
...linked,
|
||||
status: "running",
|
||||
position: 2000,
|
||||
updatedAt: 2,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
const staleLifecycleCard = {
|
||||
...linked,
|
||||
status: "review",
|
||||
updatedAt: 3,
|
||||
execution: { ...linked.execution, status: "review" as const, updatedAt: 3 },
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const lifecycleResponse = createDeferred<{ card: WorkboardCard }>();
|
||||
const client = createClient((method) => {
|
||||
if (method === "workboard.cards.update") {
|
||||
return lifecycleResponse.promise;
|
||||
}
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: moved };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const syncing = syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: null }],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await moveWorkboardCard({
|
||||
host,
|
||||
client: client as never,
|
||||
cardId: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
lifecycleResponse.resolve({ card: staleLifecycleCard });
|
||||
await syncing;
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: { execution: expect.objectContaining({ status: "review" }) },
|
||||
});
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.move", {
|
||||
id: "card-1",
|
||||
status: "running",
|
||||
position: 2000,
|
||||
});
|
||||
expect(state.cards[0]).toMatchObject({ status: "running", position: 2000 });
|
||||
});
|
||||
|
||||
it("keeps non-status edits following newer linked session lifecycle sync", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const edited = {
|
||||
...sampleCard,
|
||||
title: "Renamed only",
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
updatedAt: 5,
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 2,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
{ id: "edit-1", kind: "edited", at: 5 },
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [edited];
|
||||
const client = createClient({
|
||||
"workboard.cards.update": {
|
||||
card: { ...edited, status: "review", updatedAt: 6 },
|
||||
},
|
||||
});
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 3 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({ status: "review" }),
|
||||
});
|
||||
expect(state.cards[0]).toMatchObject({ title: "Renamed only", status: "review" });
|
||||
});
|
||||
|
||||
it("keeps lifecycle-created moves following newer linked session lifecycle sync", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const lifecycleMoved = {
|
||||
...sampleCard,
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
updatedAt: 5,
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 1 },
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 5,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [lifecycleMoved];
|
||||
const client = createClient({
|
||||
"workboard.cards.update": {
|
||||
card: {
|
||||
...lifecycleMoved,
|
||||
status: "review",
|
||||
updatedAt: 6,
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [{ ...sampleSession, hasActiveRun: false, status: "done", updatedAt: 3 }],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: expect.objectContaining({
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
|
||||
}),
|
||||
});
|
||||
expect(state.cards[0]).toMatchObject({
|
||||
status: "review",
|
||||
metadata: { lifecycleStatusSourceUpdatedAt: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stale dependency links from local cards after delete", async () => {
|
||||
const host = {};
|
||||
const parent: WorkboardCard = {
|
||||
@@ -1586,11 +2131,81 @@ describe("workboard controller", () => {
|
||||
expect(client.request).toHaveBeenCalledOnce();
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: { status: "running" },
|
||||
patch: expect.objectContaining({
|
||||
status: "running",
|
||||
metadata: expect.objectContaining({
|
||||
lifecycleStatusSourceUpdatedAt: sampleSession.updatedAt,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(state.cards.find((card) => card.id === "card-review")?.status).toBe("review");
|
||||
});
|
||||
|
||||
it("does not sync stale linked-session status over a card creation status", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
state.loaded = true;
|
||||
state.cards = [
|
||||
{
|
||||
...sampleCard,
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
createdAt: 2000,
|
||||
updatedAt: 2000,
|
||||
events: [{ id: "event-created", kind: "created", at: 2000, toStatus: "running" }],
|
||||
},
|
||||
];
|
||||
const client = createClient({
|
||||
"workboard.cards.update": {
|
||||
card: { ...sampleCard, status: "review", sessionKey: sampleSession.key },
|
||||
},
|
||||
});
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [
|
||||
{
|
||||
...sampleSession,
|
||||
status: "done",
|
||||
hasActiveRun: false,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(state.cards[0]?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not sync linked card status from sessions without lifecycle provenance", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
state.loaded = true;
|
||||
state.cards = [{ ...sampleCard, sessionKey: sampleSession.key }];
|
||||
const client = createClient({
|
||||
"workboard.cards.update": {
|
||||
card: { ...sampleCard, status: "review", sessionKey: sampleSession.key },
|
||||
},
|
||||
});
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [
|
||||
{
|
||||
...sampleSession,
|
||||
status: "done",
|
||||
hasActiveRun: false,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(state.cards[0]).toMatchObject({ status: "todo" });
|
||||
});
|
||||
|
||||
it("refreshes task lifecycle before syncing task-backed cards", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
@@ -1620,7 +2235,12 @@ describe("workboard controller", () => {
|
||||
expect(client.request).toHaveBeenNthCalledWith(1, "tasks.list", { limit: 500 });
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: { status: "review" },
|
||||
patch: expect.objectContaining({
|
||||
status: "review",
|
||||
metadata: expect.objectContaining({
|
||||
lifecycleStatusSourceUpdatedAt: sampleTask.updatedAt,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(state.tasksByCardId.get("card-1")).toMatchObject({ status: "completed" });
|
||||
});
|
||||
@@ -1665,6 +2285,7 @@ describe("workboard controller", () => {
|
||||
patch: {
|
||||
status: "running",
|
||||
metadata: {
|
||||
lifecycleStatusSourceUpdatedAt: staleUpdatedAt,
|
||||
stale: expect.objectContaining({
|
||||
lastSessionUpdatedAt: staleUpdatedAt,
|
||||
reason: "Linked session has not reported recent activity.",
|
||||
@@ -1714,6 +2335,58 @@ describe("workboard controller", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale metadata after a newer manual status move", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
const linked = {
|
||||
...sampleCard,
|
||||
status: "running",
|
||||
sessionKey: sampleSession.key,
|
||||
metadata: {
|
||||
stale: {
|
||||
detectedAt: 1,
|
||||
lastSessionUpdatedAt: 1,
|
||||
reason: "Linked session has not reported recent activity.",
|
||||
},
|
||||
},
|
||||
events: [
|
||||
{
|
||||
id: "move-1",
|
||||
kind: "moved",
|
||||
at: 5,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
state.loaded = true;
|
||||
state.cards = [linked];
|
||||
const client = createClient({
|
||||
"workboard.cards.update": {
|
||||
card: { ...linked, metadata: undefined, updatedAt: 6 },
|
||||
},
|
||||
});
|
||||
|
||||
await syncWorkboardLifecycle({
|
||||
host,
|
||||
client: client as never,
|
||||
sessions: [
|
||||
{
|
||||
...sampleSession,
|
||||
status: "running",
|
||||
updatedAt: 3,
|
||||
hasActiveRun: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith("workboard.cards.update", {
|
||||
id: "card-1",
|
||||
patch: { metadata: { stale: null } },
|
||||
});
|
||||
expect(state.cards[0]?.metadata?.stale).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not rewrite unchanged stale session metadata", async () => {
|
||||
const host = {};
|
||||
const state = getWorkboardState(host);
|
||||
|
||||
@@ -251,6 +251,7 @@ export type WorkboardMetadata = {
|
||||
templateId?: WorkboardTemplateId;
|
||||
archivedAt?: number;
|
||||
stale?: WorkboardStaleState;
|
||||
lifecycleStatusSourceUpdatedAt?: number;
|
||||
failureCount?: number;
|
||||
};
|
||||
|
||||
@@ -289,6 +290,7 @@ export type WorkboardLifecycle = {
|
||||
session: GatewaySessionRow | null;
|
||||
state: WorkboardLifecycleState;
|
||||
targetStatus?: WorkboardStatus;
|
||||
sourceUpdatedAt?: number;
|
||||
};
|
||||
|
||||
export type WorkboardTaskStatus =
|
||||
@@ -831,6 +833,11 @@ function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
|
||||
}
|
||||
: undefined;
|
||||
const automation = normalizeAutomation(value.automation);
|
||||
const lifecycleStatusSourceUpdatedAt =
|
||||
typeof value.lifecycleStatusSourceUpdatedAt === "number" &&
|
||||
Number.isFinite(value.lifecycleStatusSourceUpdatedAt)
|
||||
? Math.max(0, Math.trunc(value.lifecycleStatusSourceUpdatedAt))
|
||||
: undefined;
|
||||
const metadata: WorkboardMetadata = {
|
||||
...(attempts.length ? { attempts } : {}),
|
||||
...(comments.length ? { comments } : {}),
|
||||
@@ -849,6 +856,7 @@ function normalizeMetadata(value: unknown): WorkboardMetadata | undefined {
|
||||
: {}),
|
||||
...(typeof value.archivedAt === "number" ? { archivedAt: value.archivedAt } : {}),
|
||||
...(stale ? { stale } : {}),
|
||||
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
|
||||
...(typeof value.failureCount === "number" ? { failureCount: value.failureCount } : {}),
|
||||
};
|
||||
return Object.keys(metadata).length ? metadata : undefined;
|
||||
@@ -1021,6 +1029,17 @@ function taskUpdatedAtValue(task: WorkboardTaskSummary): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function taskLifecycleSourceUpdatedAt(task: WorkboardTaskSummary): number | undefined {
|
||||
const updatedAt = taskUpdatedAtValue(task);
|
||||
return updatedAt > 0 ? updatedAt : undefined;
|
||||
}
|
||||
|
||||
function sessionUpdatedAtValue(session: GatewaySessionRow): number | undefined {
|
||||
return typeof session.updatedAt === "number" && Number.isFinite(session.updatedAt)
|
||||
? session.updatedAt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function taskSessionKeyMatchesCardSession(
|
||||
cardSessionKey: string,
|
||||
taskSessionKey: string | undefined,
|
||||
@@ -1297,12 +1316,14 @@ export function getWorkboardLifecycle(
|
||||
session,
|
||||
state: "running",
|
||||
targetStatus: "running",
|
||||
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
session,
|
||||
state: "succeeded",
|
||||
targetStatus: "review",
|
||||
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
|
||||
};
|
||||
case "failed":
|
||||
case "cancelled":
|
||||
@@ -1311,6 +1332,7 @@ export function getWorkboardLifecycle(
|
||||
session,
|
||||
state: "failed",
|
||||
targetStatus: "blocked",
|
||||
sourceUpdatedAt: taskLifecycleSourceUpdatedAt(task),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1321,16 +1343,36 @@ export function getWorkboardLifecycle(
|
||||
return { session: null, state: "missing" };
|
||||
}
|
||||
if (staleSessionState(session)) {
|
||||
return { session, state: "stale", targetStatus: "running" };
|
||||
return {
|
||||
session,
|
||||
state: "stale",
|
||||
targetStatus: "running",
|
||||
sourceUpdatedAt: sessionUpdatedAtValue(session),
|
||||
};
|
||||
}
|
||||
if (session.hasActiveRun === true || session.status === "running") {
|
||||
return { session, state: "running", targetStatus: "running" };
|
||||
return {
|
||||
session,
|
||||
state: "running",
|
||||
targetStatus: "running",
|
||||
sourceUpdatedAt: sessionUpdatedAtValue(session),
|
||||
};
|
||||
}
|
||||
if (session.abortedLastRun || isFailedSessionStatus(session.status)) {
|
||||
return { session, state: "failed", targetStatus: "blocked" };
|
||||
return {
|
||||
session,
|
||||
state: "failed",
|
||||
targetStatus: "blocked",
|
||||
sourceUpdatedAt: sessionUpdatedAtValue(session),
|
||||
};
|
||||
}
|
||||
if (session.status === "done") {
|
||||
return { session, state: "succeeded", targetStatus: "review" };
|
||||
return {
|
||||
session,
|
||||
state: "succeeded",
|
||||
targetStatus: "review",
|
||||
sourceUpdatedAt: sessionUpdatedAtValue(session),
|
||||
};
|
||||
}
|
||||
return { session, state: "idle" };
|
||||
}
|
||||
@@ -1348,6 +1390,83 @@ function shouldSyncCardStatus(card: WorkboardCard, targetStatus: WorkboardStatus
|
||||
return false;
|
||||
}
|
||||
|
||||
const pendingStatusTransitions = new WeakMap<WorkboardHost, Set<string>>();
|
||||
|
||||
function pendingStatusTransitionMap(host: WorkboardHost) {
|
||||
let transitions = pendingStatusTransitions.get(host);
|
||||
if (!transitions) {
|
||||
transitions = new Set();
|
||||
pendingStatusTransitions.set(host, transitions);
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
function recordPendingStatusTransition(
|
||||
host: WorkboardHost,
|
||||
card: WorkboardCard | undefined,
|
||||
status: WorkboardStatus,
|
||||
): boolean {
|
||||
if (!card || card.status === status) {
|
||||
return false;
|
||||
}
|
||||
pendingStatusTransitionMap(host).add(card.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearPendingStatusTransition(host: WorkboardHost, cardId: string, recorded: boolean) {
|
||||
if (!recorded) {
|
||||
return;
|
||||
}
|
||||
const transitions = pendingStatusTransitions.get(host);
|
||||
transitions?.delete(cardId);
|
||||
}
|
||||
|
||||
function hasPendingStatusTransition(host: WorkboardHost, cardId: string): boolean {
|
||||
return pendingStatusTransitions.get(host)?.has(cardId) ?? false;
|
||||
}
|
||||
|
||||
function shouldSkipStaleLifecycleStatus(
|
||||
card: WorkboardCard,
|
||||
lifecycle: WorkboardLifecycle,
|
||||
): boolean {
|
||||
if (lifecycle.sourceUpdatedAt === undefined) {
|
||||
return false;
|
||||
}
|
||||
const lifecycleStatusSourceUpdatedAt = card.metadata?.lifecycleStatusSourceUpdatedAt;
|
||||
if (lifecycleStatusSourceUpdatedAt !== undefined) {
|
||||
return lifecycle.sourceUpdatedAt < lifecycleStatusSourceUpdatedAt;
|
||||
}
|
||||
const statusTransitionAt = latestStatusTransitionAt(card);
|
||||
return statusTransitionAt !== undefined && lifecycle.sourceUpdatedAt < statusTransitionAt;
|
||||
}
|
||||
|
||||
function shouldSkipLifecycleStatusWrite(
|
||||
host: WorkboardHost,
|
||||
card: WorkboardCard,
|
||||
lifecycle: WorkboardLifecycle,
|
||||
): boolean {
|
||||
return (
|
||||
hasPendingStatusTransition(host, card.id) || shouldSkipStaleLifecycleStatus(card, lifecycle)
|
||||
);
|
||||
}
|
||||
|
||||
function latestStatusTransitionAt(card: WorkboardCard): number | undefined {
|
||||
for (let index = (card.events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
||||
const event = card.events?.[index];
|
||||
if (
|
||||
(event?.kind === "moved" || event?.kind === "created") &&
|
||||
((event.kind === "created" && card.status !== "todo") ||
|
||||
(event.kind === "moved" && event.fromStatus !== event.toStatus)) &&
|
||||
event.toStatus === card.status &&
|
||||
typeof event.at === "number" &&
|
||||
Number.isFinite(event.at)
|
||||
) {
|
||||
return event.at;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function executionStatusForLifecycle(
|
||||
lifecycle: WorkboardLifecycle,
|
||||
): WorkboardExecutionStatus | undefined {
|
||||
@@ -1387,6 +1506,7 @@ function lifecycleSyncKey(card: WorkboardCard, lifecycle: WorkboardLifecycle): s
|
||||
session?.status ?? "",
|
||||
session?.hasActiveRun === true ? "active" : "idle",
|
||||
session?.updatedAt ?? "",
|
||||
lifecycle.sourceUpdatedAt ?? "",
|
||||
card.execution?.status ?? "",
|
||||
card.execution?.updatedAt ?? "",
|
||||
].join(":");
|
||||
@@ -1403,6 +1523,13 @@ function getLifecycleSyncKeys(host: WorkboardHost): Map<string, string> {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function mergePatchMetadata(patch: Record<string, unknown>, metadata: Record<string, unknown>) {
|
||||
patch.metadata = {
|
||||
...(isRecord(patch.metadata) ? patch.metadata : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@@ -1621,8 +1748,15 @@ export async function syncWorkboardLifecycle(params: {
|
||||
);
|
||||
const executionStatus = executionStatusForLifecycle(lifecycle);
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (shouldSyncCardStatus(card, lifecycle.targetStatus)) {
|
||||
if (
|
||||
lifecycle.sourceUpdatedAt !== undefined &&
|
||||
!shouldSkipLifecycleStatusWrite(params.host, card, lifecycle) &&
|
||||
shouldSyncCardStatus(card, lifecycle.targetStatus)
|
||||
) {
|
||||
patch.status = lifecycle.targetStatus;
|
||||
mergePatchMetadata(patch, {
|
||||
lifecycleStatusSourceUpdatedAt: lifecycle.sourceUpdatedAt,
|
||||
});
|
||||
}
|
||||
if (shouldSyncExecutionStatus(card, executionStatus)) {
|
||||
patch.execution = {
|
||||
@@ -1639,17 +1773,17 @@ export async function syncWorkboardLifecycle(params: {
|
||||
existingStale.lastSessionUpdatedAt !== stale.lastSessionUpdatedAt ||
|
||||
existingStale.reason !== stale.reason;
|
||||
if (staleChanged) {
|
||||
patch.metadata = {
|
||||
mergePatchMetadata(patch, {
|
||||
stale: {
|
||||
...stale,
|
||||
detectedAt: existingStale?.detectedAt ?? stale.detectedAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (existingStale) {
|
||||
patch.metadata = {
|
||||
mergePatchMetadata(patch, {
|
||||
stale: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
continue;
|
||||
@@ -1665,7 +1799,20 @@ export async function syncWorkboardLifecycle(params: {
|
||||
id: card.id,
|
||||
patch,
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
const currentCard = state.cards.find((candidate) => candidate.id === card.id);
|
||||
const responseCard = normalizeCardPayload(payload);
|
||||
// The user can change status after this request was sent; lifecycle responses
|
||||
// are full-card replacements, so stale responses need the same guard again.
|
||||
if (
|
||||
!currentCard ||
|
||||
hasPendingStatusTransition(params.host, currentCard.id) ||
|
||||
(currentCard.status !== card.status && responseCard.status !== currentCard.status) ||
|
||||
(shouldSkipStaleLifecycleStatus(currentCard, lifecycle) &&
|
||||
responseCard.status !== currentCard.status)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
replaceCard(state, responseCard);
|
||||
syncKeys.set(card.id, key);
|
||||
} catch (error) {
|
||||
state.error = formatError(error);
|
||||
@@ -1716,10 +1863,16 @@ export async function saveWorkboardCardDraft(params: {
|
||||
}
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
const cardId = state.editingCardId;
|
||||
const pendingStatusRecorded = recordPendingStatusTransition(
|
||||
params.host,
|
||||
state.cards.find((card) => card.id === cardId),
|
||||
state.draftStatus,
|
||||
);
|
||||
params.requestUpdate?.();
|
||||
try {
|
||||
const payload = await params.client.request("workboard.cards.update", {
|
||||
id: state.editingCardId,
|
||||
id: cardId,
|
||||
patch: draftPayload(state),
|
||||
});
|
||||
replaceCard(state, normalizeCardPayload(payload));
|
||||
@@ -1727,6 +1880,7 @@ export async function saveWorkboardCardDraft(params: {
|
||||
} catch (error) {
|
||||
state.error = formatError(error);
|
||||
} finally {
|
||||
clearPendingStatusTransition(params.host, cardId, pendingStatusRecorded);
|
||||
state.loading = false;
|
||||
params.requestUpdate?.();
|
||||
}
|
||||
@@ -1781,6 +1935,11 @@ export async function moveWorkboardCard(params: {
|
||||
}
|
||||
state.busyCardId = params.cardId;
|
||||
state.error = null;
|
||||
const pendingStatusRecorded = recordPendingStatusTransition(
|
||||
params.host,
|
||||
state.cards.find((card) => card.id === params.cardId),
|
||||
params.status,
|
||||
);
|
||||
params.requestUpdate?.();
|
||||
try {
|
||||
const payload = await params.client.request("workboard.cards.move", {
|
||||
@@ -1792,6 +1951,7 @@ export async function moveWorkboardCard(params: {
|
||||
} catch (error) {
|
||||
state.error = formatError(error);
|
||||
} finally {
|
||||
clearPendingStatusTransition(params.host, params.cardId, pendingStatusRecorded);
|
||||
state.busyCardId = null;
|
||||
state.draggedCardId = null;
|
||||
params.requestUpdate?.();
|
||||
|
||||
355
ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts
Normal file
355
ui/src/ui/e2e/workboard-status-persistence.e2e.test.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type Locator, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayControls,
|
||||
type MockGatewayRequest,
|
||||
} from "../../test-helpers/control-ui-e2e.ts";
|
||||
import type { WorkboardCard } from "../controllers/workboard.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const artifactDir = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"control-ui-e2e",
|
||||
"workboard-status-persistence",
|
||||
);
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
const linkedSessionKey = "agent:main:workboard-linked-session";
|
||||
const manualTodoAt = 2_000;
|
||||
const editedAt = 2_500;
|
||||
const draggedRunningAt = 3_000;
|
||||
const staleCompletedSessionAt = 1_000;
|
||||
|
||||
const initialCard = {
|
||||
id: "card-1",
|
||||
title: "Persist queue status",
|
||||
notes: "Original notes",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["ui"],
|
||||
agentId: "main",
|
||||
position: 1_000,
|
||||
createdAt: 900,
|
||||
updatedAt: manualTodoAt,
|
||||
sessionKey: linkedSessionKey,
|
||||
events: [
|
||||
{
|
||||
id: "event-manual-todo",
|
||||
kind: "moved",
|
||||
at: manualTodoAt,
|
||||
fromStatus: "running",
|
||||
toStatus: "todo",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
|
||||
const editedCard = {
|
||||
...initialCard,
|
||||
title: "Persisted renamed card",
|
||||
notes: "Edited notes survive reopening.",
|
||||
priority: "high",
|
||||
updatedAt: editedAt,
|
||||
events: [...initialCard.events, { id: "event-edited", kind: "edited", at: editedAt }],
|
||||
} satisfies WorkboardCard;
|
||||
|
||||
const draggedRunningCard = {
|
||||
...editedCard,
|
||||
status: "running",
|
||||
position: 1_000,
|
||||
updatedAt: draggedRunningAt,
|
||||
events: [
|
||||
...editedCard.events,
|
||||
{
|
||||
id: "event-manual-running",
|
||||
kind: "moved",
|
||||
at: draggedRunningAt,
|
||||
fromStatus: "todo",
|
||||
toStatus: "running",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
|
||||
const staleReviewCard = {
|
||||
...draggedRunningCard,
|
||||
status: "review",
|
||||
updatedAt: draggedRunningAt + 500,
|
||||
events: [
|
||||
...draggedRunningCard.events,
|
||||
{
|
||||
id: "event-stale-review",
|
||||
kind: "moved",
|
||||
at: draggedRunningAt + 500,
|
||||
fromStatus: "running",
|
||||
toStatus: "review",
|
||||
},
|
||||
],
|
||||
} satisfies WorkboardCard;
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Expected object value");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
|
||||
return requireRecord(request.params);
|
||||
}
|
||||
|
||||
function workboardColumn(page: Page, title: string) {
|
||||
return page.locator(".workboard-column", {
|
||||
has: page.getByRole("heading", { name: title }),
|
||||
});
|
||||
}
|
||||
|
||||
function workboardCard(page: Page, columnTitle: string, title: string) {
|
||||
return workboardColumn(page, columnTitle).locator(".workboard-card", { hasText: title });
|
||||
}
|
||||
|
||||
async function dispatchHtml5Drag(source: Locator, target: Locator): Promise<void> {
|
||||
const sourceHandle = await source.elementHandle();
|
||||
const targetHandle = await target.elementHandle();
|
||||
if (!sourceHandle || !targetHandle) {
|
||||
throw new Error("Could not resolve Workboard drag source or target");
|
||||
}
|
||||
try {
|
||||
await sourceHandle.evaluate((sourceElement, targetElement) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
const init = { bubbles: true, cancelable: true, dataTransfer };
|
||||
sourceElement.dispatchEvent(new DragEvent("dragstart", init));
|
||||
targetElement.dispatchEvent(new DragEvent("dragover", init));
|
||||
targetElement.dispatchEvent(new DragEvent("drop", init));
|
||||
sourceElement.dispatchEvent(new DragEvent("dragend", init));
|
||||
}, targetHandle);
|
||||
} finally {
|
||||
await sourceHandle.dispose();
|
||||
await targetHandle.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRequestCount(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
count: number,
|
||||
): Promise<MockGatewayRequest[]> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let stableSince: number | null = null;
|
||||
let latest: MockGatewayRequest[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
latest = await gateway.getRequests(method);
|
||||
if (latest.length === count) {
|
||||
stableSince ??= Date.now();
|
||||
if (Date.now() - stableSince >= 250) {
|
||||
return latest;
|
||||
}
|
||||
} else {
|
||||
stableSince = null;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for exactly ${count} ${method} requests: ${JSON.stringify(latest)}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI Workboard status persistence E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a compatible browser, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
);
|
||||
}
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("persists edit/reopen fields and does not bounce a dragged linked card from stale lifecycle", async () => {
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } },
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
workboard: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
hash: "workboard-e2e-config",
|
||||
},
|
||||
"sessions.list": {
|
||||
count: 1,
|
||||
defaults: {
|
||||
contextTokens: null,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: null,
|
||||
displayName: "Completed linked session",
|
||||
hasActiveRun: false,
|
||||
key: linkedSessionKey,
|
||||
kind: "direct",
|
||||
label: "Completed linked session",
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
status: "done",
|
||||
totalTokens: 0,
|
||||
updatedAt: staleCompletedSessionAt,
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
},
|
||||
"tasks.list": {
|
||||
nextCursor: null,
|
||||
tasks: [],
|
||||
},
|
||||
"workboard.cards.list": {
|
||||
cards: [initialCard],
|
||||
statuses: [
|
||||
"triage",
|
||||
"backlog",
|
||||
"todo",
|
||||
"scheduled",
|
||||
"ready",
|
||||
"running",
|
||||
"review",
|
||||
"blocked",
|
||||
"done",
|
||||
],
|
||||
},
|
||||
"workboard.cards.move": {
|
||||
card: draggedRunningCard,
|
||||
},
|
||||
"workboard.cards.update": {
|
||||
cases: [
|
||||
{
|
||||
match: {
|
||||
id: "card-1",
|
||||
patch: {
|
||||
title: "Persisted renamed card",
|
||||
notes: "Edited notes survive reopening.",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
labels: ["ui"],
|
||||
agentId: "main",
|
||||
sessionKey: linkedSessionKey,
|
||||
},
|
||||
},
|
||||
response: { card: editedCard },
|
||||
},
|
||||
{
|
||||
match: {
|
||||
id: "card-1",
|
||||
patch: { status: "review" },
|
||||
},
|
||||
response: { card: staleReviewCard },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}workboard`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await workboardCard(page, "Todo", "Persist queue status").waitFor({ timeout: 10_000 });
|
||||
await waitForRequestCount(gateway, "workboard.cards.update", 0);
|
||||
|
||||
await workboardCard(page, "Todo", "Persist queue status").getByTitle("Edit card").click();
|
||||
await page.getByLabel("Title").fill("Persisted renamed card");
|
||||
await page.getByLabel("Notes").fill("Edited notes survive reopening.");
|
||||
await page.getByLabel("Priority").selectOption("high");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
const updateRequests = await waitForRequestCount(gateway, "workboard.cards.update", 1);
|
||||
expect(requestParams(updateRequests[0])).toMatchObject({
|
||||
id: "card-1",
|
||||
patch: {
|
||||
notes: "Edited notes survive reopening.",
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
title: "Persisted renamed card",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 });
|
||||
} catch (err) {
|
||||
const requests = await gateway.getRequests("workboard.cards.update");
|
||||
throw new Error(
|
||||
`Edit dialog stayed open after save. Update requests: ${JSON.stringify(requests)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
await workboardCard(page, "Todo", "Persisted renamed card").waitFor({ timeout: 10_000 });
|
||||
|
||||
await workboardCard(page, "Todo", "Persisted renamed card").getByTitle("Edit card").click();
|
||||
await page.locator('[role="dialog"]').waitFor({ timeout: 10_000 });
|
||||
await expect.poll(() => page.getByLabel("Title").inputValue()).toBe("Persisted renamed card");
|
||||
await expect
|
||||
.poll(() => page.getByLabel("Notes").inputValue())
|
||||
.toBe("Edited notes survive reopening.");
|
||||
await expect.poll(() => page.getByLabel("Priority").inputValue()).toBe("high");
|
||||
await page.screenshot({
|
||||
fullPage: true,
|
||||
path: path.join(artifactDir, "workboard-edit-reopen.png"),
|
||||
});
|
||||
await page
|
||||
.locator('[role="dialog"] .workboard-modal__actions')
|
||||
.last()
|
||||
.getByRole("button", { name: "Cancel" })
|
||||
.click();
|
||||
await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 10_000 });
|
||||
|
||||
await dispatchHtml5Drag(
|
||||
workboardCard(page, "Todo", "Persisted renamed card"),
|
||||
workboardColumn(page, "Running").locator(".workboard-column__cards"),
|
||||
);
|
||||
const moveRequest = await gateway.waitForRequest("workboard.cards.move");
|
||||
expect(requestParams(moveRequest)).toMatchObject({
|
||||
id: "card-1",
|
||||
position: 1_000,
|
||||
status: "running",
|
||||
});
|
||||
await workboardCard(page, "Running", "Persisted renamed card").waitFor({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await waitForRequestCount(gateway, "workboard.cards.update", 1);
|
||||
await page.screenshot({
|
||||
fullPage: true,
|
||||
path: path.join(artifactDir, "workboard-drag-running-persisted.png"),
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
495
ui/src/ui/e2e/workboard.e2e.test.ts
Normal file
495
ui/src/ui/e2e/workboard.e2e.test.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/version.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
type MockGatewayControls,
|
||||
type MockGatewayRequest,
|
||||
} from "../../test-helpers/control-ui-e2e.ts";
|
||||
import { WORKBOARD_STATUSES, type WorkboardCard } from "../controllers/workboard.ts";
|
||||
import type { GatewaySessionRow } from "../types.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/workboard");
|
||||
const viewport = { height: 1000, width: 2400 };
|
||||
const baseTime = Date.parse("2026-06-01T18:00:00.000Z");
|
||||
const linkedSessionKey = "agent:main:workboard-proof";
|
||||
const linkedSessionName = "Implementation session";
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
type RecordedPage = {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
rawVideoDir: string;
|
||||
};
|
||||
|
||||
type ProofArtifacts = {
|
||||
screenshots: string[];
|
||||
videos: string[];
|
||||
};
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Expected object value");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requestParams(request: MockGatewayRequest): Record<string, unknown> {
|
||||
return requireRecord(request.params);
|
||||
}
|
||||
|
||||
async function waitForRequests(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
count: number,
|
||||
): Promise<MockGatewayRequest[]> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const requests = await gateway.getRequests(method);
|
||||
if (requests.length >= count) {
|
||||
return requests;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} ${method} requests`);
|
||||
}
|
||||
|
||||
async function waitForNextRequest(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
previousCount: number,
|
||||
): Promise<MockGatewayRequest> {
|
||||
const requests = await waitForRequests(gateway, method, previousCount + 1);
|
||||
const request = requests.at(-1);
|
||||
if (!request) {
|
||||
throw new Error(`No ${method} request found`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function workboardConfigSnapshot() {
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
workboard: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
config,
|
||||
hash: "workboard-e2e-config",
|
||||
path: "/tmp/openclaw-e2e/openclaw.json",
|
||||
raw: JSON.stringify(config, null, 2),
|
||||
resolved: config,
|
||||
sourceConfig: config,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionsListResponse(sessions: GatewaySessionRow[]) {
|
||||
return {
|
||||
count: sessions.length,
|
||||
defaults: {
|
||||
contextTokens: null,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
},
|
||||
path: "",
|
||||
sessions,
|
||||
ts: baseTime,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionRow(overrides: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
|
||||
return {
|
||||
contextTokens: 0,
|
||||
displayName: linkedSessionName,
|
||||
hasActiveRun: false,
|
||||
key: linkedSessionKey,
|
||||
kind: "direct",
|
||||
label: linkedSessionName,
|
||||
model: "gpt-5.5",
|
||||
modelProvider: "openai",
|
||||
totalTokens: 0,
|
||||
updatedAt: baseTime,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readOnlyConnectResponse() {
|
||||
return {
|
||||
auth: {
|
||||
deviceToken: "e2e-read-only-device-token",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
features: { events: [], methods: ["chat.startup"] },
|
||||
protocol: PROTOCOL_VERSION,
|
||||
server: { connId: "control-ui-e2e-read-only", version: "e2e" },
|
||||
snapshot: {
|
||||
sessionDefaults: {
|
||||
defaultAgentId: "main",
|
||||
mainKey: "main",
|
||||
mainSessionKey: "main",
|
||||
scope: "agent",
|
||||
},
|
||||
},
|
||||
type: "hello-ok",
|
||||
};
|
||||
}
|
||||
|
||||
function card(
|
||||
overrides: Partial<WorkboardCard> & Pick<WorkboardCard, "id" | "title">,
|
||||
): WorkboardCard {
|
||||
return {
|
||||
createdAt: baseTime,
|
||||
labels: [],
|
||||
notes: "",
|
||||
position: 1000,
|
||||
priority: "normal",
|
||||
status: "todo",
|
||||
updatedAt: baseTime,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cardsListResponse(cards: WorkboardCard[]) {
|
||||
return {
|
||||
cards,
|
||||
statuses: WORKBOARD_STATUSES,
|
||||
};
|
||||
}
|
||||
|
||||
function statusColumn(page: Page, status: string) {
|
||||
return page
|
||||
.locator(".workboard-column")
|
||||
.filter({
|
||||
has: page.locator(".workboard-column__header h2", {
|
||||
hasText: new RegExp(`^${status}$`, "u"),
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
}
|
||||
|
||||
function cardInColumn(page: Page, status: string, title: string) {
|
||||
return statusColumn(page, status).locator(".workboard-card", { hasText: title }).first();
|
||||
}
|
||||
|
||||
async function newRecordedPage(label: string): Promise<RecordedPage> {
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
const rawVideoDir = path.join(artifactDir, `${label}-raw`);
|
||||
await rm(rawVideoDir, { force: true, recursive: true });
|
||||
await mkdir(rawVideoDir, { recursive: true });
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
recordVideo: {
|
||||
dir: rawVideoDir,
|
||||
size: viewport,
|
||||
},
|
||||
serviceWorkers: "block",
|
||||
viewport,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10_000);
|
||||
return { context, page, rawVideoDir };
|
||||
}
|
||||
|
||||
async function captureScreenshot(
|
||||
page: Page,
|
||||
artifacts: ProofArtifacts,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const screenshotPath = path.join(artifactDir, `${name}.png`);
|
||||
await page.screenshot({ fullPage: true, path: screenshotPath });
|
||||
artifacts.screenshots.push(screenshotPath);
|
||||
}
|
||||
|
||||
async function closeRecordedPage(
|
||||
recorded: RecordedPage,
|
||||
artifacts: ProofArtifacts,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const video = recorded.page.video();
|
||||
try {
|
||||
await recorded.context.close();
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
const rawVideoPath = await video.path();
|
||||
const videoPath = path.join(artifactDir, `${label}.webm`);
|
||||
await copyFile(rawVideoPath, videoPath);
|
||||
artifacts.videos.push(videoPath);
|
||||
} finally {
|
||||
await rm(recorded.rawVideoDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI Workboard mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("persists Workboard create, edit, running move, lifecycle sync, reload, and read-only state", async () => {
|
||||
await rm(artifactDir, { force: true, recursive: true });
|
||||
const artifacts: ProofArtifacts = { screenshots: [], videos: [] };
|
||||
const createdCard = card({
|
||||
id: "card-1",
|
||||
labels: ["ui", "proof"],
|
||||
notes: "Acceptance: browser proof",
|
||||
sessionKey: linkedSessionKey,
|
||||
title: "Draft Workboard browser proof",
|
||||
updatedAt: baseTime + 1,
|
||||
});
|
||||
const editedCard = card({
|
||||
...createdCard,
|
||||
labels: ["ui", "proof", "e2e"],
|
||||
notes: "Acceptance: mocked Gateway browser proof\nProof: pending",
|
||||
priority: "high",
|
||||
title: "Workboard browser proof",
|
||||
updatedAt: baseTime + 2,
|
||||
});
|
||||
const runningCard = card({
|
||||
...editedCard,
|
||||
status: "running",
|
||||
updatedAt: baseTime + 3,
|
||||
});
|
||||
const reviewedCard = card({
|
||||
...runningCard,
|
||||
events: [
|
||||
{
|
||||
at: baseTime + 4,
|
||||
fromStatus: "running",
|
||||
id: "event-review",
|
||||
kind: "moved",
|
||||
toStatus: "review",
|
||||
},
|
||||
],
|
||||
status: "review",
|
||||
updatedAt: baseTime + 4,
|
||||
});
|
||||
|
||||
const writable = await newRecordedPage("workboard-writable");
|
||||
const writableGateway = await installMockGateway(writable.page, {
|
||||
methodResponses: {
|
||||
"config.get": workboardConfigSnapshot(),
|
||||
"sessions.list": sessionsListResponse([sessionRow()]),
|
||||
"tasks.list": { nextCursor: null, tasks: [] },
|
||||
"workboard.cards.list": cardsListResponse([]),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await writable.page.goto(`${server.baseUrl}workboard`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await statusColumn(writable.page, "Todo").waitFor({ state: "visible" });
|
||||
await captureScreenshot(writable.page, artifacts, "01-empty-board");
|
||||
|
||||
await writableGateway.deferNext("workboard.cards.create");
|
||||
await writable.page
|
||||
.locator(".workboard-toolbar__actions")
|
||||
.getByRole("button", { name: /New card/u })
|
||||
.click();
|
||||
const createDialog = writable.page.getByRole("dialog", { name: "New card" });
|
||||
await createDialog.getByLabel("Title").fill(createdCard.title);
|
||||
await createDialog.getByLabel("Notes").fill(createdCard.notes ?? "");
|
||||
await createDialog.getByLabel("Session").selectOption(linkedSessionKey);
|
||||
await createDialog.getByLabel("Labels").fill("ui, proof");
|
||||
await captureScreenshot(writable.page, artifacts, "02-create-dialog");
|
||||
const createBefore = (await writableGateway.getRequests("workboard.cards.create")).length;
|
||||
await createDialog.getByRole("button", { name: /^Create$/u }).click();
|
||||
const createRequest = await waitForNextRequest(
|
||||
writableGateway,
|
||||
"workboard.cards.create",
|
||||
createBefore,
|
||||
);
|
||||
expect(requestParams(createRequest)).toMatchObject({
|
||||
labels: ["ui", "proof"],
|
||||
notes: createdCard.notes,
|
||||
sessionKey: linkedSessionKey,
|
||||
status: "todo",
|
||||
title: createdCard.title,
|
||||
});
|
||||
await writableGateway.resolveDeferred("workboard.cards.create", { card: createdCard });
|
||||
await cardInColumn(writable.page, "Todo", createdCard.title).waitFor({ state: "visible" });
|
||||
await captureScreenshot(writable.page, artifacts, "03-created-card");
|
||||
|
||||
await writableGateway.deferNext("workboard.cards.update");
|
||||
await cardInColumn(writable.page, "Todo", createdCard.title)
|
||||
.locator('button[title="Edit card"]')
|
||||
.click();
|
||||
const editDialog = writable.page.getByRole("dialog", { name: "Edit card" });
|
||||
await editDialog.getByLabel("Title").fill(editedCard.title);
|
||||
await editDialog.getByLabel("Notes").fill(editedCard.notes ?? "");
|
||||
await editDialog.getByLabel("Priority").selectOption("high");
|
||||
await editDialog.getByLabel("Labels").fill("ui, proof, e2e");
|
||||
const updateBeforeEdit = (await writableGateway.getRequests("workboard.cards.update")).length;
|
||||
await editDialog.getByRole("button", { name: /^Save$/u }).click();
|
||||
const editRequest = await waitForNextRequest(
|
||||
writableGateway,
|
||||
"workboard.cards.update",
|
||||
updateBeforeEdit,
|
||||
);
|
||||
expect(requestParams(editRequest)).toMatchObject({ id: createdCard.id });
|
||||
expect(requireRecord(requestParams(editRequest).patch)).toMatchObject({
|
||||
labels: ["ui", "proof", "e2e"],
|
||||
notes: editedCard.notes,
|
||||
priority: "high",
|
||||
sessionKey: linkedSessionKey,
|
||||
title: editedCard.title,
|
||||
});
|
||||
await writableGateway.resolveDeferred("workboard.cards.update", { card: editedCard });
|
||||
await cardInColumn(writable.page, "Todo", editedCard.title).waitFor({ state: "visible" });
|
||||
await captureScreenshot(writable.page, artifacts, "04-edited-card");
|
||||
|
||||
await cardInColumn(writable.page, "Todo", editedCard.title).click();
|
||||
const details = writable.page.locator(".workboard-detail");
|
||||
await details.getByText(editedCard.title).waitFor({ state: "visible" });
|
||||
await details.getByText("Acceptance: mocked Gateway browser proof").waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await details.locator('button[title="Cancel"]').click();
|
||||
|
||||
await writableGateway.deferNext("workboard.cards.move");
|
||||
const moveBefore = (await writableGateway.getRequests("workboard.cards.move")).length;
|
||||
await cardInColumn(writable.page, "Todo", editedCard.title).dragTo(
|
||||
statusColumn(writable.page, "Running").locator(".workboard-column__cards"),
|
||||
);
|
||||
const moveRequest = await waitForNextRequest(
|
||||
writableGateway,
|
||||
"workboard.cards.move",
|
||||
moveBefore,
|
||||
);
|
||||
expect(requestParams(moveRequest)).toMatchObject({
|
||||
id: editedCard.id,
|
||||
status: "running",
|
||||
});
|
||||
await writableGateway.resolveDeferred("workboard.cards.move", { card: runningCard });
|
||||
await cardInColumn(writable.page, "Running", editedCard.title).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await captureScreenshot(writable.page, artifacts, "05-moved-running");
|
||||
|
||||
await writableGateway.deferNext("workboard.cards.update");
|
||||
const syncBefore = (await writableGateway.getRequests("workboard.cards.update")).length;
|
||||
await writableGateway.emitGatewayEvent("sessions.changed", {
|
||||
...sessionRow({
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
updatedAt: baseTime + 4,
|
||||
}),
|
||||
reason: "lifecycle",
|
||||
sessionKey: linkedSessionKey,
|
||||
ts: baseTime + 4,
|
||||
});
|
||||
const syncRequest = await waitForNextRequest(
|
||||
writableGateway,
|
||||
"workboard.cards.update",
|
||||
syncBefore,
|
||||
);
|
||||
expect(requestParams(syncRequest)).toMatchObject({ id: runningCard.id });
|
||||
expect(requireRecord(requestParams(syncRequest).patch)).toMatchObject({
|
||||
status: "review",
|
||||
});
|
||||
await writableGateway.resolveDeferred("workboard.cards.update", { card: reviewedCard });
|
||||
const reviewedCardSurface = cardInColumn(writable.page, "Review", editedCard.title);
|
||||
await reviewedCardSurface.waitFor({ state: "visible" });
|
||||
await reviewedCardSurface.getByTitle("View details").click();
|
||||
await writable.page.locator(".workboard-detail").getByText("Moved to Review").waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await captureScreenshot(writable.page, artifacts, "06-lifecycle-review");
|
||||
await details.locator('button[title="Cancel"]').click();
|
||||
await details.waitFor({ state: "hidden" });
|
||||
|
||||
await writableGateway.deferNext("workboard.cards.list");
|
||||
const listBeforeReload = (await writableGateway.getRequests("workboard.cards.list")).length;
|
||||
await writable.page
|
||||
.locator(".workboard-toolbar__actions")
|
||||
.getByRole("button", { name: /^Refresh$/u })
|
||||
.click();
|
||||
await waitForNextRequest(writableGateway, "workboard.cards.list", listBeforeReload);
|
||||
await writableGateway.resolveDeferred("workboard.cards.list", {
|
||||
cards: [reviewedCard],
|
||||
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 captureScreenshot(writable.page, artifacts, "07-reloaded-review");
|
||||
} finally {
|
||||
await closeRecordedPage(writable, artifacts, "workboard-writable");
|
||||
}
|
||||
|
||||
const readOnly = await newRecordedPage("workboard-read-only");
|
||||
const readOnlyGateway = await installMockGateway(readOnly.page, {
|
||||
methodResponses: {
|
||||
connect: readOnlyConnectResponse(),
|
||||
"config.get": workboardConfigSnapshot(),
|
||||
"sessions.list": sessionsListResponse([
|
||||
sessionRow({ hasActiveRun: false, status: "done", updatedAt: baseTime + 4 }),
|
||||
]),
|
||||
"tasks.list": { nextCursor: null, tasks: [] },
|
||||
"workboard.cards.list": cardsListResponse([runningCard]),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await readOnly.page.goto(`${server.baseUrl}workboard`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await cardInColumn(readOnly.page, "Running", editedCard.title).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await captureScreenshot(readOnly.page, artifacts, "08-read-only-board");
|
||||
expect(await readOnly.page.getByRole("button", { name: /New card/u }).count()).toBe(0);
|
||||
expect(await readOnly.page.locator('button[title="Edit card"]').count()).toBe(0);
|
||||
expect(await readOnly.page.locator('button[title="Delete card"]').count()).toBe(0);
|
||||
expect(await readOnly.page.locator('button[title="Run default agent"]').count()).toBe(0);
|
||||
expect(
|
||||
await cardInColumn(readOnly.page, "Running", editedCard.title).getAttribute("draggable"),
|
||||
).toBe("false");
|
||||
|
||||
await cardInColumn(readOnly.page, "Running", editedCard.title).click();
|
||||
await readOnly.page.locator(".workboard-detail").getByText(editedCard.title).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
expect(await readOnly.page.locator(".workboard-detail__note").count()).toBe(0);
|
||||
expect(await readOnly.page.getByRole("button", { name: /Add note/u }).count()).toBe(0);
|
||||
expect(await readOnlyGateway.getRequests("workboard.cards.update")).toHaveLength(0);
|
||||
expect(await readOnlyGateway.getRequests("workboard.cards.move")).toHaveLength(0);
|
||||
expect(await readOnlyGateway.getRequests("workboard.cards.create")).toHaveLength(0);
|
||||
} finally {
|
||||
await closeRecordedPage(readOnly, artifacts, "workboard-read-only");
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(artifactDir, "manifest.json"),
|
||||
`${JSON.stringify(artifacts, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1675,6 +1675,22 @@ describe("renderWorkboard", () => {
|
||||
priority: "high",
|
||||
}),
|
||||
});
|
||||
expect(state.cards[0]).toMatchObject({ title: "Renamed", priority: "high", updatedAt: 2 });
|
||||
|
||||
render(renderWorkboard(props), container);
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('button[title="Edit card"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
render(renderWorkboard(props), container);
|
||||
|
||||
expect(container.querySelector<HTMLInputElement>(".workboard-draft__title")?.value).toBe(
|
||||
"Renamed",
|
||||
);
|
||||
expect(
|
||||
[...container.querySelectorAll<HTMLSelectElement>(".workboard-draft__meta select")].at(1)
|
||||
?.value,
|
||||
).toBe("high");
|
||||
});
|
||||
|
||||
it("locks edit-modal actions while a comment request is in flight", () => {
|
||||
|
||||
@@ -857,7 +857,10 @@ function renderCardModal(props: WorkboardProps) {
|
||||
}}
|
||||
>
|
||||
${state.statuses.map(
|
||||
(status) => html`<option value=${status}>${formatStatusLabel(status)}</option>`,
|
||||
(status) =>
|
||||
html`<option value=${status} ?selected=${state.draftStatus === status}>
|
||||
${formatStatusLabel(status)}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -873,7 +876,10 @@ function renderCardModal(props: WorkboardProps) {
|
||||
}}
|
||||
>
|
||||
${WORKBOARD_PRIORITIES.map(
|
||||
(priority) => html`<option value=${priority}>${priority}</option>`,
|
||||
(priority) =>
|
||||
html`<option value=${priority} ?selected=${state.draftPriority === priority}>
|
||||
${priority}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -887,10 +893,12 @@ function renderCardModal(props: WorkboardProps) {
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
>
|
||||
<option value="">${t("workboard.defaultAgent")}</option>
|
||||
<option value="" ?selected=${!state.draftAgentId}>
|
||||
${t("workboard.defaultAgent")}
|
||||
</option>
|
||||
${agents.map(
|
||||
(agent) =>
|
||||
html`<option value=${agent.id}>
|
||||
html`<option value=${agent.id} ?selected=${state.draftAgentId === agent.id}>
|
||||
${agent.name ?? agent.identity?.name ?? agent.id}
|
||||
</option>`,
|
||||
)}
|
||||
@@ -906,10 +914,15 @@ function renderCardModal(props: WorkboardProps) {
|
||||
props.onRequestUpdate?.();
|
||||
}}
|
||||
>
|
||||
<option value="">${t("workboard.noLinkedSession")}</option>
|
||||
<option value="" ?selected=${!state.draftSessionKey}>
|
||||
${t("workboard.noLinkedSession")}
|
||||
</option>
|
||||
${sessions.map(
|
||||
(session) =>
|
||||
html`<option value=${session.key}>
|
||||
html`<option
|
||||
value=${session.key}
|
||||
?selected=${state.draftSessionKey === session.key}
|
||||
>
|
||||
${session.displayName ?? session.label ?? session.key}
|
||||
</option>`,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user