From 396ed22a029e33b9b27380a85b33ba548822160a Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 08:58:02 -0400 Subject: [PATCH 1/2] =?UTF-8?q?test:=20fail-first=20specs=20for=20P4=20Pha?= =?UTF-8?q?se=20C=20=E2=80=94=20flow=20decomposition,=20pending=20steps,?= =?UTF-8?q?=20Build-a-flow=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New assertions, run against main (A+B merged, studio#79) — all fail for the right reasons; NO live gateway call anywhere (the decomposition request is tested shape-only, the plan.test.ts discipline): - packages/composer-core/src/flow-plan.test.ts (new) — buildFlowPlanRequest (goal as the user message; system names the design system, lists intent ids, states the 2–8 ordered ONE-screen-per-step rules, forbids invented steps; schema: steps minItems 2 / maxItems 8, closed objects, intent as a REAL-id enum, optional per step), reconcileFlowPlan (valid plan kept source "model"; invalid/missing step intent -> planDeterministic on THAT step's goal; bounds 8 steps + name/title/goal slices; <2 usable steps or garbage -> deterministic fallback, never an empty plan), and flowPlanDeterministic (a Gateway-#12-shaped numbered lifecycle fixture -> >=4 ordered steps, real intents, labeled scripted with a deterministic reason; sentence clustering; single-sentence goals still outline >=2 steps; rambling goals bounded to 6). - apps/composer/app/flows.test.ts — surfaceId "" is a PENDING step: flowLint warns "not built yet" (code pending-step) instead of the dangling error, one cause one finding (no advanceOn warn on a pending step); parseFlow shape anchor ("" is a state, not malformation). - packages/composer-core/src/flows.test.ts — flowSchema/parseFlows anchor: an empty surfaceId parses (already true; pinned so it stays true). - e2e/composer-prod-smoke.spec.ts — one added spec: default Build render has NO flow-mode elements; toggle -> 3-sentence goal -> deterministic labeled plan -> edit (remove a step, retitle, pin intents) -> "Create flow & build steps" -> flow exists PENDING -> two sequential ordinary scripted builds -> accept turn 1 (pre-targeted by the driver) -> Preview shows the half-built flow with the pending step disabled + the pending body state -> accept turn 2 -> the completed flow walks end to end. Pre-change output: composer-core (npx vitest run src/flow-plan.test.ts src/flows.test.ts): ✓ src/flows.test.ts (7 tests) [pending-shape anchors hold] FAIL src/flow-plan.test.ts [ src/flow-plan.test.ts ] Error: Cannot find module './flow-plan' apps/composer (npx vitest run app/flows.test.ts): 2 failed | 30 passed × an EMPTY surfaceId is a PENDING step (Phase C): warn 'not built yet', never a dangling error → expected 'error' to be 'warn' × a pending step never also warns about its advanceOn (one cause, one finding) → expected 'dangling-surface' to be 'pending-step' e2e smoke (-g "build a flow", against the freshly built static export of main): Error: locator.click: ... waiting for getByTestId('build-mode-flow') 1 failed Note: the Gateway #12 corpus text lives outside this repo; the fixture mirrors its shape (one workflow goal, numbered stations) and is labeled so. Co-Authored-By: Claude Fable 5 --- apps/composer/app/flows.test.ts | 26 +++ e2e/composer-prod-smoke.spec.ts | 62 ++++++ packages/composer-core/src/flow-plan.test.ts | 189 +++++++++++++++++++ packages/composer-core/src/flows.test.ts | 9 + 4 files changed, 286 insertions(+) create mode 100644 packages/composer-core/src/flow-plan.test.ts diff --git a/apps/composer/app/flows.test.ts b/apps/composer/app/flows.test.ts index 3a79273..cdd0386 100644 --- a/apps/composer/app/flows.test.ts +++ b/apps/composer/app/flows.test.ts @@ -358,6 +358,32 @@ describe("flowLint — reference validation with the finding() shape, gate 'flow expect(findings).toHaveLength(1); expect(findings[0].severity).toBe("error"); }); + + it("an EMPTY surfaceId is a PENDING step (Phase C): warn 'not built yet', never a dangling error", () => { + const flows: Flow[] = [{ id: "flow.flow-1", name: "Outline", steps: [{ id: "step.a", title: "A", surfaceId: "" }] }]; + const findings = flowLint(flows, ctx()); + expect(findings).toHaveLength(1); + expect(findings[0].gate).toBe("flow"); + expect(findings[0].severity).toBe("warn"); + expect(findings[0].code).toBe("pending-step"); + expect(findings[0].target).toBe("flow.flow-1/step.a"); + expect(findings[0].message).toMatch(/not built yet/i); + }); + + it("a pending step never also warns about its advanceOn (one cause, one finding)", () => { + const flows: Flow[] = [ + { id: "flow.flow-1", name: "Outline", steps: [{ id: "step.a", title: "A", surfaceId: "", advanceOn: ["anything"] }] }, + ]; + const findings = flowLint(flows, ctx()); + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe("pending-step"); + expect(findings[0].severity).toBe("warn"); + }); + + it("parseFlow accepts a pending step's empty surfaceId (shape anchor: '' is a state, not malformation)", () => { + const pending = { id: "flow.x", name: "X", steps: [{ id: "step.a", title: "A", surfaceId: "" }] }; + expect(parseFlow(pending)).toEqual(pending); + }); }); describe("bindStepSurface — accept-into-step (P4 Phase B)", () => { diff --git a/e2e/composer-prod-smoke.spec.ts b/e2e/composer-prod-smoke.spec.ts index 0723b5d..8862f2d 100644 --- a/e2e/composer-prod-smoke.spec.ts +++ b/e2e/composer-prod-smoke.spec.ts @@ -366,6 +366,68 @@ test("flows: create, walk, advance, persist, and round-trip through export/impor await expect(page.locator("[data-project-canvas]")).toContainText(/order/i); // step 2 now renders the re-bound surface }); +test("build a flow: plan deterministically, edit the plan, create the pending flow, build steps, walk it (P4 Phase C)", async ({ page }) => { + await page.goto("/"); + await newProject(page, "shadcn", "Flow composer"); + + // Flow mode is opt-in: the DEFAULT Build render carries no flow-mode + // elements — the single-surface path is untouched until the toggle. + await expect(page.getByTestId("build-flow-goal")).toHaveCount(0); + await expect(page.getByTestId("flow-plan-run")).toHaveCount(0); + await expect(page.getByTestId("flow-plan-accept")).toHaveCount(0); + + await page.getByTestId("build-model").selectOption("scripted"); + await page.getByTestId("build-mode-flow").click(); + await page + .getByTestId("build-flow-goal") + .fill("Show one order in full detail. Let people delete their account. Show a table of the remaining accounts."); + await page.getByTestId("flow-plan-run").click(); + + // Scripted planning is the labeled deterministic outline — and editable. + await expect(page.getByTestId("flow-plan-editor")).toBeVisible(); + await expect(page.getByTestId("flow-plan-source")).toContainText(/deterministic/i); + await expect(page.getByTestId("flow-plan-title-2")).toBeVisible(); // 3 sentences → 3 steps + await page.getByTestId("flow-plan-remove-2").click(); + await expect(page.getByTestId("flow-plan-title-2")).toHaveCount(0); + await page.getByTestId("flow-plan-title-0").fill("Review the order"); + await page.getByTestId("flow-plan-intent-0").selectOption("record-detail"); + await page.getByTestId("flow-plan-title-1").fill("Delete the account"); + await page.getByTestId("flow-plan-intent-1").selectOption("destructive-action"); + + // Create flow & build steps: the flow exists immediately with PENDING + // steps, then each step runs as an ORDINARY sequential scripted build. + await page.getByTestId("flow-plan-accept").click(); + await expect(page.getByTestId("build-gate-summary-1")).toContainText("Follows your design-system rules", { timeout: 30_000 }); + await expect(page.getByTestId("build-gate-summary-2")).toContainText("Follows your design-system rules", { timeout: 30_000 }); + + // Accept turn 1 — PRE-TARGETED to its step by the driver (Phase B binding). + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + await expect(page.getByTestId("build-accepted-1")).toContainText("Review the order"); + + // Half-built flow: step 2 is visibly PENDING in Preview — an outline state, + // never a crash, never a dangling error. + await page.getByTestId("nav-preview").click(); + await page.getByTestId("flow-flow.flow-1").click(); + await expect(page.getByTestId("flow-step-step.review-the-order")).toHaveClass(/st-btn--active/); + await expect(page.getByTestId("flow-step-step.delete-the-account")).toBeDisabled(); + await expect(page.getByTestId("flow-step-step.delete-the-account")).toContainText(/pending/i); + await page.getByTestId("flow-next").click(); + await expect(page.getByTestId("flow-step-pending")).toBeVisible(); + await expect(page.getByTestId("flow-step-pending")).toContainText(/not built yet/i); + + // Back in Build, accept turn 2 into ITS step; the flow completes. + await page.getByTestId("nav-build").click(); + await page.getByTestId("build-accept-2").click(); + await expect(page.getByTestId("build-accepted-2")).toContainText("Delete the account"); + await page.getByTestId("nav-preview").click(); + await page.getByTestId("flow-flow.flow-1").click(); + await expect(page.locator("[data-project-canvas]")).toContainText(/order/i); + await page.getByTestId("flow-next").click(); + await expect(page.getByTestId("flow-step-step.delete-the-account")).toHaveClass(/st-btn--active/); + await expect(page.locator("[data-project-canvas]")).toContainText(/delete/i); +}); + test("client traffic carries no private hosts, local paths, or key material", async ({ page }) => { const bodies: string[] = []; page.on("response", async (r) => { diff --git a/packages/composer-core/src/flow-plan.test.ts b/packages/composer-core/src/flow-plan.test.ts new file mode 100644 index 0000000..66a88a4 --- /dev/null +++ b/packages/composer-core/src/flow-plan.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { buildFlowPlanRequest, flowPlanDeterministic, flowPlanSchema, reconcileFlowPlan } from "./flow-plan"; + +/** + * P4 Phase C — flow decomposition planning (fail-first). + * + * "Build a flow" turns ONE workflow goal into an editable plan of 2–8 steps, + * each step an ordinary single-surface build (title + generation goal + + * governed intent picked from the contract's OWN taxonomy). Same layer and + * idioms as plan.ts: a provider-agnostic request tested SHAPE-ONLY (no live + * gateway call anywhere), a reconciler that clamps model output to the + * contract, and a deterministic fallback that never blocks. + */ + +const contract = { + name: "demo/ui", + intents: [ + { + id: "structured-input", + name: "Structured input", + description: "Collects structured values and commits them on an explicit action — a form the user fills and submits.", + }, + { + id: "destructive-action", + name: "Destructive action", + description: "Performs an irreversible or high-consequence operation: deleting records or accounts, revoking access.", + }, + { + id: "record-collection", + name: "Record collection", + description: "Presents many records of the same kind — tickets, orders, members — in a table or list.", + }, + { + id: "record-detail", + name: "Record detail", + description: "Shows one record in full: an order, an account, a project, with its fields and related actions.", + }, + ], + components: { + button: { whenToUse: "Trigger an action" }, + input: { whenToUse: "Collect a single-line text value" }, + card: { whenToUse: "Group related content" }, + table: { whenToUse: "Present rows of records" }, + }, +}; + +const intentIds = contract.intents.map((i) => i.id); + +/** A Gateway-#12-style lifecycle goal (the corpus text lives outside this + * repo; this fixture mirrors its shape: one workflow, numbered stations). */ +const LIFECYCLE_GOAL = [ + "The whole request lifecycle for a customer integration:", + "1. Browse the catalog of integration packages in a table and pick one.", + "2. Fill in an estimate form with quantities and the delivery window.", + "3. Review the estimate and confirm the order irreversibly.", + "4. Show the created project record with its status fields.", +].join("\n"); + +describe("buildFlowPlanRequest — provider-agnostic decomposition request (shape only)", () => { + it("carries the goal as the user message and names the design system + governed contexts", () => { + const req = buildFlowPlanRequest(LIFECYCLE_GOAL, contract); + expect(req.messages).toEqual([{ role: "user", content: LIFECYCLE_GOAL }]); + expect(req.system).toContain("demo/ui"); + for (const id of intentIds) expect(req.system).toContain(id); + // The decomposition rules, stated: bounded ordered steps, one screen each, + // intents from the listed ids only, no invented steps. + expect(req.system).toMatch(/2–8 ordered steps|2-8 ordered steps/); + expect(req.system).toContain("ONE screen"); + expect(req.system).toContain("Do not invent steps"); + }); + + it("schemas the plan strictly: 2–8 steps, closed objects, intent as a REAL-id enum", () => { + const schema = buildFlowPlanRequest("a goal", contract).jsonSchema as { + additionalProperties?: boolean; + required?: string[]; + properties: { name: unknown; steps: { minItems?: number; maxItems?: number; items: { additionalProperties?: boolean; required?: string[]; properties: { intent?: { enum?: string[] } } } } }; + }; + expect(schema.additionalProperties).toBe(false); + expect(schema.required).toEqual(["name", "steps"]); + expect(schema.properties.steps.minItems).toBe(2); + expect(schema.properties.steps.maxItems).toBe(8); + const item = schema.properties.steps.items; + expect(item.additionalProperties).toBe(false); + expect(item.required).toEqual(["title", "goal"]); // intent stays optional; the reconciler fills it + expect(item.properties.intent?.enum).toEqual(intentIds); + }); + + it("flowPlanSchema degrades to a plain string intent when a contract has no intents", () => { + const schema = flowPlanSchema([]) as { properties: { steps: { items: { properties: { intent: unknown } } } } }; + expect(schema.properties.steps.items.properties.intent).toEqual({ type: "string" }); + }); +}); + +describe("reconcileFlowPlan — clamp a model plan to the contract", () => { + const rawPlan = { + name: "Integration request lifecycle", + steps: [ + { title: "Browse the catalog", goal: "a table of integration packages to pick from", intent: "record-collection" }, + { title: "Create the estimate", goal: "a form with quantities and delivery window", intent: "structured-input" }, + ], + }; + + it("keeps a valid plan verbatim, source 'model'", () => { + const plan = reconcileFlowPlan(rawPlan, contract, LIFECYCLE_GOAL); + expect(plan.source).toBe("model"); + expect(plan.name).toBe("Integration request lifecycle"); + expect(plan.steps.map((s) => s.intent)).toEqual(["record-collection", "structured-input"]); + expect(plan.steps.map((s) => s.title)).toEqual(["Browse the catalog", "Create the estimate"]); + }); + + it("an invalid or missing step intent falls back to planDeterministic on THAT STEP's goal", () => { + const plan = reconcileFlowPlan( + { + name: "X", + steps: [ + { title: "A", goal: "permanently delete the account and revoke access", intent: "made-up" }, + { title: "B", goal: "a table of all support tickets" }, // no intent at all + ], + }, + contract, + "goal", + ); + expect(plan.steps[0].intent).toBe("destructive-action"); + expect(plan.steps[1].intent).toBe("record-collection"); + }); + + it("bounds the plan: at most 8 steps, sliced name/title/goal lengths", () => { + const steps = Array.from({ length: 12 }, (_, i) => ({ + title: `Step ${i} ${"t".repeat(200)}`, + goal: `${"g".repeat(500)}`, + intent: "structured-input", + })); + const plan = reconcileFlowPlan({ name: "n".repeat(400), steps }, contract, "goal"); + expect(plan.steps).toHaveLength(8); + expect(plan.name.length).toBeLessThanOrEqual(120); + for (const s of plan.steps) { + expect(s.title.length).toBeLessThanOrEqual(80); + expect(s.goal.length).toBeLessThanOrEqual(300); + } + }); + + it("fewer than 2 usable steps (or garbage) falls back to the deterministic outline — never an empty plan", () => { + for (const raw of [null, {}, { name: "X", steps: [] }, { name: "X", steps: [{ title: "", goal: " " }] }, { name: "X", steps: "nope" }]) { + const plan = reconcileFlowPlan(raw, contract, LIFECYCLE_GOAL); + expect(plan.source).toBe("scripted"); + expect(plan.steps.length).toBeGreaterThanOrEqual(2); + for (const s of plan.steps) expect(intentIds).toContain(s.intent); + } + }); +}); + +describe("flowPlanDeterministic — the honest outline that never blocks", () => { + it("splits a numbered lifecycle goal into its stations (the #12 shape): ≥4 steps, real intents, labeled scripted", () => { + const plan = flowPlanDeterministic(LIFECYCLE_GOAL, contract); + expect(plan.source).toBe("scripted"); + expect(plan.reason).toMatch(/deterministic/i); + expect(plan.steps.length).toBeGreaterThanOrEqual(4); + expect(plan.steps.length).toBeLessThanOrEqual(8); + expect(plan.name.length).toBeGreaterThan(0); + for (const s of plan.steps) { + expect(s.title.length).toBeGreaterThan(0); + expect(s.goal.length).toBeGreaterThan(0); + expect(intentIds).toContain(s.intent); + } + // The stations arrive in order: catalog table → estimate form → confirm → project record. + expect(plan.steps[0].goal).toMatch(/catalog|packages/i); + expect(plan.steps[0].intent).toBe("record-collection"); + }); + + it("clusters a plain multi-sentence goal into one step per sentence", () => { + const plan = flowPlanDeterministic( + "Show one order in full detail. Let people delete their account. Show a table of remaining accounts.", + contract, + ); + expect(plan.steps).toHaveLength(3); + expect(plan.steps[1].intent).toBe("destructive-action"); + }); + + it("a single-sentence goal still yields an editable outline of at least 2 steps", () => { + const plan = flowPlanDeterministic("let people book a meeting room", contract); + expect(plan.steps.length).toBeGreaterThanOrEqual(2); + for (const s of plan.steps) expect(intentIds).toContain(s.intent); + }); + + it("a rambling goal is bounded to 6 outline steps", () => { + const rambling = Array.from({ length: 12 }, (_, i) => `Do the ${i}th thing with records.`).join(" "); + expect(flowPlanDeterministic(rambling, contract).steps.length).toBeLessThanOrEqual(6); + }); +}); diff --git a/packages/composer-core/src/flows.test.ts b/packages/composer-core/src/flows.test.ts index 4280809..a5360ed 100644 --- a/packages/composer-core/src/flows.test.ts +++ b/packages/composer-core/src/flows.test.ts @@ -33,6 +33,15 @@ describe("flowSchema — the zod twin of the app's parseFlow", () => { } }); + it("accepts an EMPTY surfaceId — a PENDING step, planned but not yet built (Phase C anchor)", () => { + const outline = { id: "flow.x", name: "X", steps: [{ id: "step.a", title: "A", surfaceId: "" }] }; + const parsed = flowSchema.safeParse(outline); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data).toEqual(outline); + const asArray = parseFlows([outline]); + expect(asArray.ok).toBe(true); + }); + it("preserves the RESERVED `on` branching annotation without acting on it (F4)", () => { const withOn = { id: "flow.x", From f06b2967c7f1d4571c1339076e6235c3effeb4a6 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Wed, 12 Aug 2026 09:08:29 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20P4=20Phase=20C=20=E2=80=94=20"Build?= =?UTF-8?q?=20a=20flow":=20decomposition=20planning,=20pending=20steps,=20?= =?UTF-8?q?sequential=20step=20driver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One workflow goal becomes an EDITABLE plan; every step then builds through the ordinary governed pipeline — same gates, same repairs, same Accept — one step at a time. dspack-gen never learns flows exist; the planner is Composer/composer-core code beside plan.ts, and no test touches a live gateway (the decomposition request is shape-only, the plan.test.ts rule). - composer-core flow-plan.ts (new, plan.ts idioms): buildFlowPlanRequest — system names the design system, lists the contract's intents, states the 2–8 ordered ONE-screen-per-step rules and forbids invented steps; schema closes every object, bounds steps 2–8, and constrains step intent to a REAL-id enum (optional; the reconciler fills gaps). reconcileFlowPlan — clamps each step's intent (invalid/missing -> planDeterministic on THAT step's goal), bounds name/title/goal with the same slice idiom, bounds to 8 steps, and falls back to the deterministic outline when fewer than 2 usable steps remain; source "model". flowPlanDeterministic — the honest, never-blocking outline: one step per numbered item (the workflow framing line is not a step), else per sentence clustered to at most 6, else the goal plus a review step; per-step intents through the same deterministic classifier scripted builds use; labeled scripted with a reason that says it is a deterministic outline. - Pending steps are first-class: surfaceId "" means PLANNED, NOT YET BUILT. flowLint warns (pending-step, "not built yet") instead of the dangling error — one cause, one finding; parseFlow/flowSchema/manifest accept "" (anchored). Preview renders pending navigator chips visibly disabled ("· pending"), and a pending current step shows a quiet outline state (flow-step-pending) — a flow of only pending steps previews as its outline, never a crash. - Build gains an OPT-IN flow mode (build-mode-flow; the default single-surface render and behavior are untouched — the e2e pins that no flow-mode elements exist until the toggle): goal textarea -> "Plan the flow" (hosted plans via the existing runGatewayRequest seam, any throw -> the labeled deterministic outline; scripted/local plan deterministically) -> an editable plan editor (title/goal/intent per step, add/remove/ reorder, Phase A editor idioms) -> "Create flow & build steps": the flow is created IMMEDIATELY with every step pending through the single saveFlows funnel, then the driver runs each step as an ordinary runBuild turn (goal = step.goal, intentOverride = step.intent) STRICTLY sequentially — never two builds in flight — spaced 8s for model providers (the P3a burst finding; scripted has no provider to protect and runs back-to-back). Each result is a normal turn whose Accept is PRE-TARGETED to its step via the Phase B binding (the header select still wins); a failing step stops the drive, later steps stay pending, and per-step "build step" buttons resume or re-run individually. - runBuild now RESOLVES with the turn's outcome, computed from the turn's own event stream (never from state) so the driver can sequence and stop; single-surface callers ignore it. Turns carry an optional flowStepHint; the accept note names the pre-targeted step. All suites green: composer-core 77 -> 89, composer 65 -> 68, agent 48 (untouched), typecheck clean, smoke e2e 12 -> 13 (existing specs untouched), composer-agent e2e 44/44 against the real agent. Co-Authored-By: Claude Fable 5 --- apps/composer/app/flows.ts | 19 +- apps/composer/app/planning.ts | 29 +- apps/composer/app/state.tsx | 37 ++- apps/composer/app/views/build-view.tsx | 320 +++++++++++++++++++++-- apps/composer/app/views/preview-view.tsx | 14 +- packages/composer-core/src/flow-plan.ts | 222 ++++++++++++++++ packages/composer-core/src/index.ts | 8 + 7 files changed, 611 insertions(+), 38 deletions(-) create mode 100644 packages/composer-core/src/flow-plan.ts diff --git a/apps/composer/app/flows.ts b/apps/composer/app/flows.ts index aaced33..0139a5c 100644 --- a/apps/composer/app/flows.ts +++ b/apps/composer/app/flows.ts @@ -255,6 +255,18 @@ export function missingSurfaceMessage(step: FlowStep): string { return `step '${step.id}' references surface '${step.surfaceId}', which is not in this project's surfaces`; } +/** A PENDING step (Phase C): planned but not yet built — `surfaceId: ""` is a + * STATE, never a malformation and never a dangling reference. "Build a + * flow" creates whole flows of these, then fills them step by step. */ +export function isPendingStep(step: FlowStep): boolean { + return step.surfaceId === ""; +} + +/** The pending wording, shared by flow-lint and Preview's outline state. */ +export function pendingStepMessage(step: FlowStep): string { + return `step '${step.id}' is not built yet — build it from Build and accept into this step`; +} + export function flowLint(flows: Flow[], ctx: FlowLintContext): ComposerFinding[] { const findings: ComposerFinding[] = []; const flowIdsSeen = new Set(); @@ -278,7 +290,12 @@ export function flowLint(flows: Flow[], ctx: FlowLintContext): ComposerFinding[] } stepIdsSeen.add(step.id); - if (!ctx.exampleIds.has(step.surfaceId)) { + if (isPendingStep(step)) { + // Planned-but-unbuilt is a first-class state (Phase C): a WARN that + // names the remaining work, never a dangling error — and one cause, + // one finding (no advanceOn check against a surface that isn't there). + findings.push(finding("flow", "pending-step", "warn", target, pendingStepMessage(step))); + } else if (!ctx.exampleIds.has(step.surfaceId)) { // One cause, one finding: with no surface there is nothing to check // advanceOn against, so the reference error stands alone. findings.push(finding("flow", "dangling-surface", "error", target, missingSurfaceMessage(step))); diff --git a/apps/composer/app/planning.ts b/apps/composer/app/planning.ts index 9942cdd..0d0ba5e 100644 --- a/apps/composer/app/planning.ts +++ b/apps/composer/app/planning.ts @@ -16,7 +16,16 @@ * Any inference failure falls back to the deterministic classifier so the flow * always proceeds. */ -import { buildPlanRequest, planDeterministic, reconcilePlan, type GoalPlan } from "@dspack-studio/composer-core"; +import { + buildFlowPlanRequest, + buildPlanRequest, + flowPlanDeterministic, + planDeterministic, + reconcileFlowPlan, + reconcilePlan, + type FlowPlan, + type GoalPlan, +} from "@dspack-studio/composer-core"; import { runGatewayRequest } from "./hosted-build"; export async function planGoal(goal: string, modelRef: string, contract: Record): Promise { @@ -33,3 +42,21 @@ export async function planGoal(goal: string, modelRef: string, contract: Record< // scripted + agent (v1): deterministic routing. return planDeterministic(goal, contract); } + +/** + * Flow decomposition (P4 Phase C): one workflow goal → an editable plan of + * ordered steps, through the SAME dispatch shape as planGoal — hosted infers + * via the gateway, everything else (and any gateway failure) gets the + * clearly-labeled deterministic outline. Planning never blocks the flow. + */ +export async function planFlow(goal: string, modelRef: string, contract: Record): Promise { + if (modelRef === "hosted-ai") { + try { + const json = await runGatewayRequest(buildFlowPlanRequest(goal, contract)); + return reconcileFlowPlan(json, contract, goal); + } catch { + return flowPlanDeterministic(goal, contract); + } + } + return flowPlanDeterministic(goal, contract); +} diff --git a/apps/composer/app/state.tsx b/apps/composer/app/state.tsx index 3a05a24..0082213 100644 --- a/apps/composer/app/state.tsx +++ b/apps/composer/app/state.tsx @@ -115,6 +115,9 @@ export interface BuildTurn { accepted?: string; // the saved example id /** The flow step this accept re-bound, by title (P4 Phase B). */ acceptedIntoStep?: string; + /** "Build a flow" (Phase C): the step this turn was built FOR. Accept + * pre-targets it (the Phase B binding); the header select still wins. */ + flowStepHint?: { flowId: string; stepId: string; title: string }; /** Structured findings from a refused Accept, rendered in place (#41). */ acceptFindings?: ComposerFinding[]; /** The inferred governed context + feasibility for this turn (goal-first). */ @@ -221,7 +224,17 @@ export interface ComposerState { configureLocalProvider: (kind: LocalKind, baseUrl: string, model: string) => void; /** Setup completeness for building; reason names the exact remaining work. */ readiness: BuildReadiness; - runBuild: (input: { goal: string; modelRef: string; refine?: boolean; intentOverride?: string }) => Promise; + /** One build turn. Resolves with the turn's OUTCOME (computed from the + * turn's own event stream, never from state) so the Phase C flow driver + * can sequence steps and stop on failure; single-surface callers ignore + * it. `flowStepHint` tags the turn with the flow step it builds FOR. */ + runBuild: (input: { + goal: string; + modelRef: string; + refine?: boolean; + intentOverride?: string; + flowStepHint?: { flowId: string; stepId: string; title: string }; + }) => Promise<"passed" | "failed" | "vocab-gap" | "not-run">; /** Accept a turn as a worked example; the agent mints the id (#42). An * optional flow-step binding re-points that step at the minted id (P4) — * a STALE binding never fails the accept, it is reported in the notice. */ @@ -1059,23 +1072,29 @@ export function ComposerProvider({ children }: { children: ReactNode }) { * prior surface; every gate runs again; prior turns stay for audit. */ const runBuild = useCallback( - async (input: { goal: string; modelRef: string; refine?: boolean; intentOverride?: string }) => { - if (buildBusy) return; + async (input: { + goal: string; + modelRef: string; + refine?: boolean; + intentOverride?: string; + flowStepHint?: { flowId: string; stepId: string; title: string }; + }): Promise<"passed" | "failed" | "vocab-gap" | "not-run"> => { + if (buildBusy) return "not-run"; if (!contract || !profile) { setNotice("No project loaded yet."); - return; + return "not-run"; } // A local provider runs through the agent; without it, don't silently // fall back to a different provider — say so and stop. if (isLocalRef(input.modelRef) && !agentUp) { setNotice("This model runs on your machine through the local agent, which isn’t running. Start it (pnpm --filter agent dev), or choose Hosted or Scripted in Settings."); - return; + return "not-run"; } // Only a completed, passing turn can seed a refinement (#43). const prior = input.refine ? [...buildTurns].reverse().find((t) => canRefineTurn(t.progress)) : undefined; if (input.refine && !prior) { setNotice("Nothing to refine yet — refinement starts from a completed build that passed its gates."); - return; + return "not-run"; } setBuildBusy(true); const id = ++turnSeq.current; @@ -1086,6 +1105,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { modelRef: input.modelRef, refinement: !!prior, ...(prior ? { parentId: prior.id } : {}), + ...(input.flowStepHint ? { flowStepHint: input.flowStepHint } : {}), progress: { status: "streaming", attempts: [] }, gaps: [], kind: "surface", @@ -1114,7 +1134,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) { ); buildStream.current = null; setBuildBusy(false); - return; + return "vocab-gap"; } // --- GENERATE: the SAME deterministic pipeline, now under the inferred @@ -1179,6 +1199,9 @@ export function ComposerProvider({ children }: { children: ReactNode }) { }); buildStream.current = null; setBuildBusy(false); + // The turn's outcome, from ITS OWN event stream (state would be stale + // in this closure): the Phase C driver sequences on it. + return foldBuildEvents(events).outcome === "passed" ? "passed" : "failed"; }, [mode, projectPath, buildBusy, buildTurns, contract, profile, providerConfig, agentUp], ); diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index 7bb88a1..d123384 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -15,8 +15,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { A2uiCanvas } from "@dspack-studio/a2ui-ingest"; import { registryFor, canvasScopeFor } from "../registries"; -import { buildFailure, canAcceptTurn, canRefineTurn, intentLabel } from "@dspack-studio/composer-core"; -import type { StepBinding } from "../flows"; +import { buildFailure, canAcceptTurn, canRefineTurn, intentLabel, type FlowPlan } from "@dspack-studio/composer-core"; +import { mintStepId, nextFlowId, type StepBinding } from "../flows"; +import { planFlow } from "../planning"; import type { BuildTurn } from "../state"; import { useComposer } from "../state"; import { Eyebrow } from "../ui"; @@ -24,6 +25,24 @@ import { browserEmit } from "../validation"; const GATE_COLOR: Record = { PASS: "var(--ok)", FAIL: "var(--err)", SKIPPED: "var(--fg-dim)" }; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Spacing between sequential flow-step builds (the P3a burst finding): the + * driver NEVER fires two builds concurrently, and model providers get 8s of + * air between steps. Scripted is deterministic with zero model calls — no + * provider to protect — so it runs back-to-back (still strictly sequential). */ +const stepSpacingMs = (modelRef: string): number => (modelRef === "scripted" ? 0 : 8000); + +const field = { + fontFamily: "var(--mono)", + fontSize: 12, + background: "var(--bg-1)", + border: "1px solid var(--line)", + color: "var(--fg)", + padding: "4px 6px", + borderRadius: 2, +} as const; + /** Governance in plain language — S1/S2/S3 are implementation codes; a first-time * user reads outcomes. The deterministic evidence is unchanged; the raw gates, * rule ids, and rationales stay one expander away (advanced detail). */ @@ -175,6 +194,11 @@ function TurnCanvas({ turn }: { turn: BuildTurn }) { function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: StepBinding }) { const { acceptBuildTurn, buildBusy, busy, mode, isExample } = useComposer(); + // Accept targeting: the header "flow step" select (an explicit choice) + // wins; otherwise a "Build a flow" turn is PRE-TARGETED to the step it was + // built for (Phase C driver hint). Plain accepts stay plain. + const binding = + intoFlowStep ?? (turn.flowStepHint ? { flowId: turn.flowStepHint.flowId, stepId: turn.flowStepHint.stepId } : undefined); // Blank by default: identity is minted from the contract ON DISK, so a // reload or a second tab can never collide with saved work (#42). const [exampleId, setExampleId] = useState(""); @@ -331,7 +355,7 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste className="st-btn" disabled={locked} aria-label={`Accept turn ${turn.id} into the project${exampleId ? ` with id ${exampleId}` : ""}`} - onClick={() => void acceptBuildTurn(turn.id, exampleId.trim() || undefined, intoFlowStep)} + onClick={() => void acceptBuildTurn(turn.id, exampleId.trim() || undefined, binding)} data-testid={`build-accept-${turn.id}`} > Add to project @@ -343,6 +367,9 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste : isExample ? "Kept for this session only — duplicate this example into your projects to keep what you build." : "Saves to this project in your browser as a worked example — it appears in Preview and Scenarios, and seeds future generation."} + {!intoFlowStep && turn.flowStepHint && ( + <> Accepting also points flow step “{turn.flowStepHint.title}” at this surface. + )}

)} @@ -358,7 +385,7 @@ function TurnBlock({ turn, intoFlowStep }: { turn: BuildTurn; intoFlowStep?: Ste } export function BuildView() { - const { mode, agentUp, contract, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows } = + const { mode, agentUp, contract, readiness, buildTurns, buildBusy, buildModels, selectableModels, runBuild, activeModel, setActiveModel, flows, saveFlows } = useComposer(); const [prompt, setPrompt] = useState(""); // "" = auto: the governed context is INFERRED from the goal. A specific value @@ -368,6 +395,15 @@ export function BuildView() { // that flow step to the minted surface (P4 Phase B). Generation itself is // untouched — this is an ACCEPT-time affordance on the intent-select pattern. const [intoStepKey, setIntoStepKey] = useState(""); + /* ---- "Build a flow" (P4 Phase C) — OPT-IN; the default single-surface + path renders and behaves exactly as before until the toggle. ---- */ + const [buildMode, setBuildMode] = useState<"surface" | "flow">("surface"); + const [flowGoal, setFlowGoal] = useState(""); + const [flowPlan, setFlowPlan] = useState(null); + const [planBusy, setPlanBusy] = useState(false); + // The accepted plan's created flow + its minted step ids (index-aligned + // with the frozen plan), and the sequential driver's position. + const [flowBuild, setFlowBuild] = useState<{ flowId: string; stepIds: string[]; running: boolean; at: number | null } | null>(null); const intents = ((contract?.intents ?? []) as Array<{ id: string }>).map((i) => i.id); const streamStatus = useRef(null); const canRefine = buildTurns.some((t) => canRefineTurn(t.progress)); @@ -391,6 +427,81 @@ export function BuildView() { setPrompt(""); }; + /* ---------------- "Build a flow" planning + sequential driver ---------------- */ + + const planTheFlow = async () => { + if (!contract || !flowGoal.trim() || planBusy) return; + setPlanBusy(true); + setFlowBuild(null); // a fresh plan starts a fresh composition session + try { + setFlowPlan(await planFlow(flowGoal.trim(), activeModel, contract)); + } finally { + setPlanBusy(false); + } + }; + + const patchPlanStep = (at: number, patch: Partial) => + setFlowPlan((p) => (p ? { ...p, steps: p.steps.map((s, i) => (i === at ? { ...s, ...patch } : s)) } : p)); + + const movePlanStep = (at: number, delta: -1 | 1) => + setFlowPlan((p) => { + if (!p) return p; + const to = at + delta; + if (to < 0 || to >= p.steps.length) return p; + const steps = p.steps.slice(); + [steps[at], steps[to]] = [steps[to], steps[at]]; + return { ...p, steps }; + }); + + const removePlanStep = (at: number) => setFlowPlan((p) => (p ? { ...p, steps: p.steps.filter((_, i) => i !== at) } : p)); + + const addPlanStep = () => + setFlowPlan((p) => (p ? { ...p, steps: [...p.steps, { title: `Step ${p.steps.length + 1}`, goal: "", intent: intents[0] ?? "" }] } : p)); + + /** ONE step's ordinary build, tagged with the step it builds for. Used by + * the sequential driver and by the per-step resume/re-run buttons. */ + const driveStep = async (at: number, flowId: string, stepIds: string[], plan: FlowPlan) => { + const step = plan.steps[at]; + const stepId = stepIds[at]; + if (!step || !stepId) return "not-run" as const; + return runBuild({ + goal: step.goal.trim() || step.title, + modelRef: activeModel, + intentOverride: step.intent, + flowStepHint: { flowId, stepId, title: step.title }, + }); + }; + + /** + * Create the flow IMMEDIATELY with every step PENDING (it exists, previews + * as an outline, exports, lints as pending), then drive the step builds + * SEQUENTIALLY — never two in flight, spaced for model providers (the P3a + * burst finding). Each result arrives as an ordinary turn with the normal + * Accept, pre-targeted to its step; a failure stops the drive and the + * remaining steps stay pending (resume per step below). + */ + const acceptPlan = async () => { + if (!flowPlan || flowPlan.steps.length === 0 || planBusy || buildBusy || flowBuild?.running) return; + const plan = flowPlan; // frozen for this drive; the editor locks while running + const flowId = nextFlowId(flows.map((f) => f.id)); + const taken = new Set(); + const flowSteps = plan.steps.map((s) => { + const stepId = mintStepId(s.title, taken); + taken.add(stepId); + return { id: stepId, title: s.title, surfaceId: "" }; + }); + saveFlows([...flows, { id: flowId, name: plan.name.trim() || "Untitled flow", description: flowGoal.trim().slice(0, 240), steps: flowSteps }]); + const stepIds = flowSteps.map((s) => s.id); + setFlowBuild({ flowId, stepIds, running: true, at: 0 }); + for (let i = 0; i < plan.steps.length; i++) { + if (i > 0 && stepSpacingMs(activeModel) > 0) await sleep(stepSpacingMs(activeModel)); + setFlowBuild((fb) => (fb ? { ...fb, at: i } : fb)); + const outcome = await driveStep(i, flowId, stepIds, plan); + if (outcome !== "passed") break; // the failure turn tells the story; later steps stay pending + } + setFlowBuild((fb) => (fb ? { ...fb, running: false, at: null } : fb)); + }; + if (!readiness.ready) { return (
@@ -410,33 +521,190 @@ export function BuildView() { Describe what you want, in your own words. Composer works out the governed context, builds it from this project’s approved components only, checks it in front of you, and renders it in your design system.

- -
- setPrompt(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submit(false)} - placeholder="e.g. a form to invite teammates by email · a confirmation for deleting a project · a table of orders" - aria-label="Describe what you want to build" - style={{ flex: 1 }} - data-testid="build-prompt" - /> - +
+ {buildMode === "flow" && ( + + one workflow goal → an editable plan → ordinary per-step builds. Nothing new is generated in one shot. + + )}
+ {buildMode === "surface" ? ( +
+ setPrompt(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit(false)} + placeholder="e.g. a form to invite teammates by email · a confirmation for deleting a project · a table of orders" + aria-label="Describe what you want to build" + style={{ flex: 1 }} + data-testid="build-prompt" + /> + + +
+ ) : ( +
+

+ Describe the whole journey. Composer proposes an editable outline; every step then builds through the ordinary + governed pipeline — same gates, same Accept, one step at a time. +

+
+