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:
25
demo/README.md
Normal file
25
demo/README.md
Normal 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.
|
||||
131
demo/seed.ts
131
demo/seed.ts
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
71
packages/core/src/__tests__/browser-demo-lifecycle.test.ts
Normal file
71
packages/core/src/__tests__/browser-demo-lifecycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user