Skip to content

Commit e07dbb2

Browse files
authored
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.
1 parent d9d4514 commit e07dbb2

10 files changed

Lines changed: 2031 additions & 24 deletions

File tree

extensions/workboard/src/sqlite-store.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type {
2626
} from "./types.js";
2727

2828
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
29-
const SCHEMA_VERSION = 1;
29+
const SCHEMA_VERSION = 2;
3030
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
3131
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
3232
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
@@ -118,6 +118,21 @@ function runTransaction<T>(db: DatabaseSync, run: () => T): T {
118118
}
119119
}
120120

121+
function tableColumns(db: DatabaseSync, tableName: string): Set<string> {
122+
return new Set(
123+
(db.prepare(`PRAGMA table_info(${tableName})`).all() as Row[]).flatMap((row) =>
124+
typeof row.name === "string" ? [row.name] : [],
125+
),
126+
);
127+
}
128+
129+
function ensureColumn(db: DatabaseSync, tableName: string, columnName: string, definition: string) {
130+
if (tableColumns(db, tableName).has(columnName)) {
131+
return;
132+
}
133+
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
134+
}
135+
121136
function ensureWorkboardSchema(db: DatabaseSync): void {
122137
db.exec(`
123138
PRAGMA foreign_keys = ON;
@@ -171,6 +186,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
171186
template_id TEXT,
172187
archived_at INTEGER,
173188
stale_json TEXT,
189+
lifecycle_status_source_updated_at INTEGER,
174190
failure_count INTEGER
175191
);
176192
CREATE INDEX IF NOT EXISTS workboard_cards_board_status_idx
@@ -333,6 +349,12 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
333349
updated_at INTEGER NOT NULL
334350
);
335351
`);
352+
ensureColumn(
353+
db,
354+
"workboard_cards",
355+
"lifecycle_status_source_updated_at",
356+
"lifecycle_status_source_updated_at INTEGER",
357+
);
336358
db.prepare(
337359
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
338360
).run(`schema-${SCHEMA_VERSION}`, Date.now());
@@ -630,6 +652,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
630652
const automation = parseJson(row.automation_json) as WorkboardMetadata["automation"] | undefined;
631653
const claim = parseJson(row.claim_json) as WorkboardMetadata["claim"] | undefined;
632654
const stale = parseJson(row.stale_json) as WorkboardMetadata["stale"] | undefined;
655+
const lifecycleStatusSourceUpdatedAt = numberValue(row, "lifecycle_status_source_updated_at");
633656
return optional({
634657
...(attempts.length > 0 ? { attempts } : {}),
635658
...(comments.length > 0 ? { comments } : {}),
@@ -660,6 +683,7 @@ function readMetadata(db: DatabaseSync, row: Row): WorkboardMetadata | undefined
660683
? { archivedAt: numberValue(row, "archived_at") }
661684
: {}),
662685
...(stale ? { stale } : {}),
686+
...(lifecycleStatusSourceUpdatedAt !== undefined ? { lifecycleStatusSourceUpdatedAt } : {}),
663687
...(numberValue(row, "failure_count") !== undefined
664688
? { failureCount: numberValue(row, "failure_count") }
665689
: {}),
@@ -738,14 +762,14 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
738762
execution_id, execution_kind, execution_engine, execution_mode, execution_status,
739763
execution_model, execution_session_key, execution_run_id, execution_started_at,
740764
execution_updated_at, automation_json, claim_json, template_id, archived_at, stale_json,
741-
failure_count
765+
lifecycle_status_source_updated_at, failure_count
742766
) VALUES (
743767
@id, @board_id, @title, @notes, @status, @priority, @agent_id, @session_key, @run_id,
744768
@task_id, @source_url, @position, @created_at, @updated_at, @started_at, @completed_at,
745769
@execution_id, @execution_kind, @execution_engine, @execution_mode, @execution_status,
746770
@execution_model, @execution_session_key, @execution_run_id, @execution_started_at,
747771
@execution_updated_at, @automation_json, @claim_json, @template_id, @archived_at,
748-
@stale_json, @failure_count
772+
@stale_json, @lifecycle_status_source_updated_at, @failure_count
749773
)
750774
ON CONFLICT(id) DO UPDATE SET
751775
board_id = excluded.board_id,
@@ -778,6 +802,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
778802
template_id = excluded.template_id,
779803
archived_at = excluded.archived_at,
780804
stale_json = excluded.stale_json,
805+
lifecycle_status_source_updated_at = excluded.lifecycle_status_source_updated_at,
781806
failure_count = excluded.failure_count
782807
`,
783808
).run({
@@ -812,6 +837,7 @@ function insertCard(db: DatabaseSync, card: WorkboardCard): void {
812837
template_id: bindNull(metadata?.templateId),
813838
archived_at: bindNull(metadata?.archivedAt),
814839
stale_json: jsonValue(metadata?.stale),
840+
lifecycle_status_source_updated_at: bindNull(metadata?.lifecycleStatusSourceUpdatedAt),
815841
failure_count: bindNull(metadata?.failureCount),
816842
});
817843

extensions/workboard/src/store.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ describe("WorkboardStore", () => {
7777
fileName: "large-proof.bin",
7878
contentBase64: Buffer.alloc(70 * 1024).toString("base64"),
7979
});
80+
await store.update(card.id, {
81+
metadata: { lifecycleStatusSourceUpdatedAt: 1234 },
82+
});
8083
const attachmentId = attached.metadata?.attachments?.[0]?.id;
8184
const subscription = await store.subscribeNotifications({
8285
boardId: board.id,
@@ -118,6 +121,7 @@ describe("WorkboardStore", () => {
118121
labels: ["sqlite", "doctor"],
119122
metadata: {
120123
automation: { boardId: "planning" },
124+
lifecycleStatusSourceUpdatedAt: 1234,
121125
comments: [expect.objectContaining({ body: "round trip" })],
122126
attachments: expect.arrayContaining([
123127
expect.objectContaining({ fileName: "proof.txt" }),
@@ -334,6 +338,192 @@ describe("WorkboardStore", () => {
334338
expect(rolledBack.completedAt).toBeUndefined();
335339
});
336340

341+
it("tracks lifecycle status provenance and clears it on manual status changes", async () => {
342+
const store = new WorkboardStore(createMemoryStore());
343+
const card = await store.create({ title: "Sync status provenance" });
344+
345+
const zeroSourceLifecycle = await store.update(card.id, {
346+
status: "running",
347+
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
348+
});
349+
expect(zeroSourceLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(0);
350+
351+
const lifecycleMoved = await store.update(card.id, {
352+
status: "running",
353+
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
354+
});
355+
expect(lifecycleMoved.metadata?.lifecycleStatusSourceUpdatedAt).toBe(1000);
356+
357+
const newerLifecycle = await store.update(card.id, {
358+
status: "review",
359+
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
360+
});
361+
expect(newerLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
362+
363+
const manual = await store.move(card.id, "running", 2000);
364+
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
365+
366+
const staleZeroLifecycle = await store.update(card.id, {
367+
status: "review",
368+
metadata: { lifecycleStatusSourceUpdatedAt: 0 },
369+
});
370+
expect(staleZeroLifecycle).toEqual(manual);
371+
expect(staleZeroLifecycle.status).toBe("running");
372+
expect(staleZeroLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
373+
374+
const staleLifecycle = await store.update(card.id, {
375+
status: "review",
376+
metadata: { lifecycleStatusSourceUpdatedAt: 2000 },
377+
});
378+
expect(staleLifecycle).toEqual(manual);
379+
expect(staleLifecycle.status).toBe("running");
380+
expect(staleLifecycle.updatedAt).toBe(manual.updatedAt);
381+
expect(staleLifecycle.events).toHaveLength(manual.events?.length ?? 0);
382+
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
383+
384+
const freshLifecycleSourceUpdatedAt = Date.now() + 1000;
385+
const freshLifecycle = await store.update(card.id, {
386+
status: "review",
387+
metadata: { lifecycleStatusSourceUpdatedAt: freshLifecycleSourceUpdatedAt },
388+
});
389+
expect(freshLifecycle.status).toBe("review");
390+
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(
391+
freshLifecycleSourceUpdatedAt,
392+
);
393+
});
394+
395+
it("keeps creation status from stale lifecycle patches", async () => {
396+
vi.useFakeTimers();
397+
try {
398+
vi.setSystemTime(2000);
399+
const store = new WorkboardStore(createMemoryStore());
400+
const card = await store.create({
401+
title: "Initial running status",
402+
status: "running",
403+
});
404+
405+
const staleLifecycle = await store.update(card.id, {
406+
status: "review",
407+
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
408+
});
409+
expect(staleLifecycle).toEqual(card);
410+
expect(staleLifecycle.status).toBe("running");
411+
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
412+
413+
const freshLifecycle = await store.update(card.id, {
414+
status: "review",
415+
metadata: { lifecycleStatusSourceUpdatedAt: 3000 },
416+
});
417+
expect(freshLifecycle.status).toBe("review");
418+
expect(freshLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBe(3000);
419+
} finally {
420+
vi.useRealTimers();
421+
}
422+
});
423+
424+
it("keeps non-status fields from stale lifecycle patches", async () => {
425+
const store = new WorkboardStore(createMemoryStore());
426+
const card = await store.create({
427+
title: "Keep stale sync details",
428+
execution: {
429+
id: "exec-1",
430+
kind: "agent-session",
431+
engine: "codex",
432+
mode: "autonomous",
433+
status: "running",
434+
model: "openai/gpt-5.5",
435+
sessionKey: "agent:main:dashboard:1",
436+
runId: "run-1",
437+
startedAt: 1,
438+
updatedAt: 1000,
439+
},
440+
});
441+
const lifecycleMoved = await store.update(card.id, {
442+
status: "review",
443+
metadata: {
444+
lifecycleStatusSourceUpdatedAt: 1000,
445+
stale: {
446+
detectedAt: 1000,
447+
lastSessionUpdatedAt: 1000,
448+
reason: "Session has not reported recent activity.",
449+
},
450+
},
451+
});
452+
const manual = await store.update(card.id, {
453+
status: "running",
454+
metadata: lifecycleMoved.metadata,
455+
});
456+
457+
const synced = await store.update(card.id, {
458+
status: "review",
459+
execution: {
460+
id: "exec-1",
461+
kind: "agent-session",
462+
engine: "codex",
463+
mode: "autonomous",
464+
status: "done",
465+
model: "openai/gpt-5.5",
466+
sessionKey: "agent:main:dashboard:1",
467+
runId: "run-1",
468+
startedAt: 1,
469+
updatedAt: 2000,
470+
},
471+
metadata: {
472+
lifecycleStatusSourceUpdatedAt: 1000,
473+
stale: null,
474+
},
475+
});
476+
477+
expect(manual.metadata?.stale).toBeDefined();
478+
expect(synced.status).toBe("running");
479+
expect(synced.execution).toMatchObject({
480+
runId: "run-1",
481+
status: "done",
482+
updatedAt: 2000,
483+
});
484+
expect(synced.metadata?.stale).toBeUndefined();
485+
expect(synced.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
486+
expect(synced.events?.at(-1)).toMatchObject({
487+
kind: "attempt_updated",
488+
runId: "run-1",
489+
});
490+
});
491+
492+
it("clears copied lifecycle provenance on manual status patches", async () => {
493+
const store = new WorkboardStore(createMemoryStore());
494+
const card = await store.create({ title: "Clear copied provenance" });
495+
const lifecycleMoved = await store.update(card.id, {
496+
status: "review",
497+
metadata: {
498+
lifecycleStatusSourceUpdatedAt: 1000,
499+
stale: {
500+
kind: "session",
501+
status: "done",
502+
updatedAt: 1000,
503+
observedAt: 1000,
504+
},
505+
},
506+
});
507+
508+
const manual = await store.update(card.id, {
509+
status: "running",
510+
metadata: {
511+
...lifecycleMoved.metadata,
512+
stale: null,
513+
},
514+
});
515+
516+
expect(manual.status).toBe("running");
517+
expect(manual.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
518+
519+
const staleLifecycle = await store.update(card.id, {
520+
status: "review",
521+
metadata: { lifecycleStatusSourceUpdatedAt: 1000 },
522+
});
523+
expect(staleLifecycle.status).toBe("running");
524+
expect(staleLifecycle.metadata?.lifecycleStatusSourceUpdatedAt).toBeUndefined();
525+
});
526+
337527
it("keeps execution session links aligned with edited card links", async () => {
338528
const store = new WorkboardStore(createMemoryStore());
339529
const card = await store.create({

0 commit comments

Comments
 (0)