FN-5986: add browser demo lifecycle board flow

Add a browser demo workflow and supporting fixtures for board walkthroughs.

- add a demo README plus seeded Browser Demo Lifecycle workflow with Todo, In Progress, In Review, QA, and Publish columns
- seed representative browser demo tasks and lifecycle log entries across the custom workflow columns
- update the live simulator to advance workflow tasks through QA and Publish while preserving the legacy merge path
- add a core lifecycle test covering workflow selection and the Todo → In Progress → In Review → QA → Publish transitions

Files changed:
 demo/README.md                                     |  25 ++++
 demo/seed.ts                                       | 131 ++++++++++++++++++++-
 demo/simulate.ts                                   |  35 ++++--
 packages/core/src/__tests__/browser-demo-lifecycle.test.ts   |  71 +++++++++++
 4 files changed, 248 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-5986

Fusion-Task-Lineage: 9638f7da-9b50-4de1-8ffa-d861dbf7aff2
This commit is contained in:
gsxdsm
2026-06-07 20:25:00 -07:00
parent 2d81a95ad9
commit ffce26ab1d
4 changed files with 248 additions and 14 deletions

25
demo/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Demo scripts
## Seed a sample board
```bash
node_modules/.bin/tsx demo/seed.ts /absolute/path/to/demo-project
```
The seeded board now includes a custom **Browser Demo Lifecycle** workflow with cards staged across:
- Todo
- In Progress
- In Review
- QA
- Publish
This makes it easy to verify browser board rendering for a simple end-to-end lifecycle walkthrough.
## Simulate live activity
```bash
node_modules/.bin/tsx demo/simulate.ts /absolute/path/to/demo-project
```
When the seeded workflow is present, the simulator advances demo cards from **In Review → QA → Publish** while keeping legacy tasks flowing through the standard board lifecycle.

View File

@@ -10,14 +10,49 @@
* - Planning: raw ideas just landing
*/
import { TaskStore } from "../packages/core/src/index.js";
import type { WorkflowIr } from "../packages/core/src/workflow-ir-types.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
const root = process.argv[2] || process.cwd();
function browserDemoLifecycleIr(): WorkflowIr {
return {
version: "v2",
name: "browser-demo-lifecycle",
columns: [
{ id: "todo", name: "Todo", traits: [{ trait: "intake" }] },
{ id: "in-progress", name: "In Progress", traits: [{ trait: "wip" }] },
{ id: "in-review", name: "In Review", traits: [{ trait: "merge-blocker" }] },
{ id: "qa", name: "QA", traits: [] },
{ id: "publish", name: "Publish", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "implement", kind: "prompt", column: "in-progress", config: { prompt: "Implement the change" } },
{ id: "review", kind: "prompt", column: "in-review", config: { prompt: "Review the implementation" } },
{ id: "qa-check", kind: "gate", column: "qa", config: { scriptName: "test", name: "QA" } },
{ id: "end", kind: "end", column: "publish" },
],
edges: [
{ from: "start", to: "implement", condition: "success" },
{ from: "implement", to: "review", condition: "success" },
{ from: "review", to: "qa-check", condition: "success" },
{ from: "qa-check", to: "end", condition: "success" },
],
};
}
async function main() {
const store = new TaskStore(root);
await store.init();
await store.updateSettings({ maxConcurrent: 10 } as any);
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const browserDemoWorkflow = await store.createWorkflowDefinition({
name: "Browser Demo Lifecycle",
description: "Simple board lifecycle for browser demos: Todo → In Progress → In Review → QA → Publish.",
ir: browserDemoLifecycleIr(),
});
// ── Done ──────────────────────────────────────────────────────────
@@ -221,6 +256,67 @@ async function main() {
await writePrompt(store, task.id, t.title, t.desc, steps, t.deps);
}
// ── Browser demo lifecycle ───────────────────────────────────────
const browserDemo = [
{
title: "Demo: seed browser board task in Todo",
desc: "Simple browser demo task waiting in Todo for the board walkthrough.",
column: "todo",
currentStep: null,
},
{
title: "Demo: browser task actively implementing",
desc: "Shows a demo task in active implementation with one step currently running.",
column: "in-progress",
currentStep: 1,
},
{
title: "Demo: browser task awaiting review",
desc: "Shows a demo task that finished implementation and is waiting in review.",
column: "in-review",
currentStep: 4,
},
{
title: "Demo: browser task in QA",
desc: "Shows a demo task that passed review and is waiting on QA verification.",
column: "qa",
currentStep: 4,
},
{
title: "Demo: browser task ready to publish",
desc: "Shows a demo task that cleared QA and is sitting in the Publish column.",
column: "publish",
currentStep: 4,
},
] as const;
for (const t of browserDemo) {
const task = await store.createTask({ description: t.desc, title: t.title });
await store.updateTask(task.id, { size: "S", reviewLevel: 0 });
await store.selectTaskWorkflowAndReconcile(task.id, browserDemoWorkflow.id);
const steps = generateSteps(t.title);
await writePrompt(store, task.id, t.title, t.desc, steps);
if (t.currentStep !== null) {
for (let i = 0; i < Math.min(t.currentStep, steps.length); i++) {
await store.updateStep(task.id, i, "done");
}
if (t.currentStep < steps.length) {
await store.updateStep(task.id, t.currentStep, "in-progress");
}
}
const lifecyclePath = ["todo", "in-progress", "in-review", "qa", "publish"] as const;
const targetIndex = lifecyclePath.indexOf(t.column);
for (const column of lifecyclePath.slice(1, targetIndex + 1)) {
await store.moveTask(task.id, column, { moveSource: "user", allowDirectInReviewMove: true });
}
await addLogs(store, task.id, t.column);
}
// ── Planning ──────────────────────────────────────────────────────
const planning = [
@@ -256,13 +352,15 @@ async function main() {
console.log(` Todo: ${byColumn["todo"] || 0}`);
console.log(` In Progress: ${byColumn["in-progress"] || 0}`);
console.log(` In Review: ${byColumn["in-review"] || 0}`);
console.log(` QA: ${byColumn["qa"] || 0}`);
console.log(` Publish: ${byColumn["publish"] || 0}`);
console.log(` Done: ${byColumn["done"] || 0}`);
console.log(`\nRun "kb dashboard" to see the board.`);
console.log(`\nRun "kb dashboard" to see the board, including the browser demo lifecycle columns.`);
}
// ── Helpers ───────────────────────────────────────────────────────────
function generateSteps(title: string): string[] {
function generateSteps(_title: string): string[] {
return [
"Analyze requirements and plan implementation",
"Implement core logic",
@@ -271,7 +369,7 @@ function generateSteps(title: string): string[] {
];
}
function generateStepsDetailed(title: string, count: number): string[] {
function generateStepsDetailed(_title: string, count: number): string[] {
const pools: Record<number, string[]> = {
3: [
"Set up infrastructure and dependencies",
@@ -340,6 +438,33 @@ ${stepsSection}
async function addLogs(store: TaskStore, id: string, targetColumn: string) {
const actions: Record<string, string[][]> = {
publish: [
["Planning complete — plan written", "approved"],
["Scheduled for execution", "worktree created"],
["Step 0 started", "in-progress"],
["Review: step 0", "approved"],
["Step 1 started", "in-progress"],
["Review: step 1", "approved"],
["Step 2 started", "in-progress"],
["Review: step 2", "approved"],
["Step 3 started", "in-progress"],
["Review: step 3", "approved"],
["QA verification complete", "approved"],
["Ready to publish", "awaiting release window"],
],
qa: [
["Planning complete — plan written", "approved"],
["Scheduled for execution", "worktree created"],
["Step 0 started", "in-progress"],
["Review: step 0", "approved"],
["Step 1 started", "in-progress"],
["Review: step 1", "approved"],
["Step 2 started", "in-progress"],
["Review: step 2", "approved"],
["Step 3 started", "in-progress"],
["Review: step 3", "approved"],
["Review complete — moved to QA", "awaiting browser smoke test"],
],
done: [
["Planning complete — plan written", "approved"],
["Scheduled for execution", "worktree created"],

View File

@@ -47,6 +47,7 @@ async function main() {
const planning = tasks.filter((t) => t.column === "triage" && !t.paused);
const inProgress = tasks.filter((t) => t.column === "in-progress" && !t.paused);
const inReview = tasks.filter((t) => t.column === "in-review" && !t.paused);
const qa = tasks.filter((t) => t.column === "qa" && !t.paused);
const todo = tasks.filter((t) => t.column === "todo" && !t.paused);
// Roll dice for what happens this tick
@@ -137,19 +138,31 @@ async function main() {
}
}
await sleep(3000 + Math.random() * 5000);
} else if (roll < 0.95 && inReview.length > 0) {
// Auto-merge a reviewed task
} else if (roll < 0.9 && inReview.length > 0) {
const task = pick(inReview);
await store.logEntry(task.id, "Auto-merged into main");
// Can't use mergeTask (no real branch), so just move directly
const dir = `${root}/.fusion/tasks/${task.id}`;
// Read, update, write manually to move to done
const detail = await store.getTask(task.id);
await store.updateTask(task.id, { status: undefined, worktree: undefined });
// Use moveTask for the column transition
await store.moveTask(task.id, "done");
console.log(` ✓ Merged: ${task.id} → done`);
let movedToQa = false;
try {
await store.moveTask(task.id, "qa", { moveSource: "user", allowDirectInReviewMove: true });
await store.logEntry(task.id, "Review complete — moved to QA", "awaiting browser smoke test");
console.log(` ✓ Review complete: ${task.id} → qa`);
movedToQa = true;
} catch {
// Not every workflow defines QA; fall back to the legacy merge path.
}
if (!movedToQa) {
await store.logEntry(task.id, "Auto-merged into main");
await store.updateTask(task.id, { status: undefined, worktree: undefined });
await store.moveTask(task.id, "done");
console.log(` ✓ Merged: ${task.id} → done`);
}
await sleep(4000 + Math.random() * 3000);
} else if (roll < 0.97 && qa.length > 0) {
const task = pick(qa);
await store.moveTask(task.id, "publish", { moveSource: "user" });
await store.logEntry(task.id, "QA verification complete", "ready to publish");
console.log(` ✓ QA passed: ${task.id} → publish`);
await sleep(3000 + Math.random() * 3000);
} else {
// Quiet tick
await sleep(2000 + Math.random() * 2000);

View File

@@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
function browserDemoLifecycleIr(): WorkflowIr {
return {
version: "v2",
name: "browser-demo-lifecycle",
columns: [
{ id: "todo", name: "Todo", traits: [{ trait: "intake" }] },
{ id: "in-progress", name: "In Progress", traits: [{ trait: "wip" }] },
{ id: "in-review", name: "In Review", traits: [{ trait: "merge-blocker" }] },
{ id: "qa", name: "QA", traits: [] },
{ id: "publish", name: "Publish", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "implement", kind: "prompt", column: "in-progress", config: { prompt: "Implement" } },
{ id: "review", kind: "prompt", column: "in-review", config: { prompt: "Review" } },
{ id: "qa-check", kind: "gate", column: "qa", config: { scriptName: "test", name: "QA" } },
{ id: "end", kind: "end", column: "publish" },
],
edges: [
{ from: "start", to: "implement", condition: "success" },
{ from: "implement", to: "review", condition: "success" },
{ from: "review", to: "qa-check", condition: "success" },
{ from: "qa-check", to: "end", condition: "success" },
],
};
}
describe("browser demo lifecycle workflow", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
await harness.afterEach();
});
it("supports the Todo → In Progress → In Review → QA → Publish board walkthrough", async () => {
const workflow = await store.createWorkflowDefinition({
name: "Browser Demo Lifecycle",
ir: browserDemoLifecycleIr(),
});
const task = await store.createTask({ description: "Browser walkthrough task", title: "Demo lifecycle" });
const selection = await store.selectTaskWorkflowAndReconcile(task.id, workflow.id);
expect(selection.reconciliation).toEqual({ preserved: false, fromColumn: "triage", toColumn: "todo" });
expect((await store.getTask(task.id)).column).toBe("todo");
await store.moveTask(task.id, "in-progress", { moveSource: "user" });
await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
await store.moveTask(task.id, "qa", { moveSource: "user" });
await store.moveTask(task.id, "publish", { moveSource: "user" });
const detail = await store.getTask(task.id);
expect(detail.column).toBe("publish");
const listed = await store.listTasks({ column: "publish" });
expect(listed.map((item) => item.id)).toContain(task.id);
});
});