Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/composer/app/flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
19 changes: 18 additions & 1 deletion apps/composer/app/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand All @@ -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)));
Expand Down
29 changes: 28 additions & 1 deletion apps/composer/app/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<GoalPlan> {
Expand All @@ -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<string, unknown>): Promise<FlowPlan> {
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);
}
37 changes: 30 additions & 7 deletions apps/composer/app/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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<void>;
/** 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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
);
Expand Down
Loading
Loading