diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000000..f2a9d02482 --- /dev/null +++ b/demo/README.md @@ -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. diff --git a/demo/seed.ts b/demo/seed.ts index d2492bd809..0513a37d91 100644 --- a/demo/seed.ts +++ b/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 = { 3: [ "Set up infrastructure and dependencies", @@ -340,6 +438,33 @@ ${stepsSection} async function addLogs(store: TaskStore, id: string, targetColumn: string) { const actions: Record = { + 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"], diff --git a/demo/simulate.ts b/demo/simulate.ts index bf2610a25d..7bc818dee0 100644 --- a/demo/simulate.ts +++ b/demo/simulate.ts @@ -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); diff --git a/packages/core/src/__tests__/browser-demo-lifecycle.test.ts b/packages/core/src/__tests__/browser-demo-lifecycle.test.ts new file mode 100644 index 0000000000..4259b82254 --- /dev/null +++ b/packages/core/src/__tests__/browser-demo-lifecycle.test.ts @@ -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; + + 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); + }); +});