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/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. +

+
+