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
7 changes: 6 additions & 1 deletion components/auth/sign-in-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,16 @@ export function SignInCard({ redirectUrl }: { redirectUrl?: string }) {

async function handleSso(strategy: SsoStrategy) {
setLocalError(null);
await signIn.sso({
const { error } = await signIn.sso({
strategy,
redirectUrl: toSafeDestination(redirectUrl),
redirectCallbackUrl: SSO_CALLBACK_PATH,
});
// A failed redirect otherwise leaves the button doing nothing, with no message anywhere.
if (error) {
console.error("SSO sign-in failed", error);
setLocalError(messageOf(error as AuthMessage) ?? "Couldn't start sign-in. Please try again.");
}
}

async function handleIdentifier() {
Expand Down
7 changes: 6 additions & 1 deletion components/auth/sign-up-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,16 @@ export function SignUpCard({ redirectUrl }: { redirectUrl?: string }) {

async function handleSso(strategy: SsoStrategy) {
setLocalError(null);
await signUp.sso({
const { error } = await signUp.sso({
strategy,
redirectUrl: toSafeDestination(redirectUrl),
redirectCallbackUrl: SSO_CALLBACK_PATH,
});
// A failed redirect otherwise leaves the button doing nothing, with no message anywhere.
if (error) {
console.error("SSO sign-up failed", error);
setLocalError(messageOf(error as AuthMessage) ?? "Couldn't start sign-up. Please try again.");
}
}

async function handleDetails() {
Expand Down
19 changes: 19 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -1541,3 +1541,22 @@ Update this file whenever the current phase, active feature, or implementation s
- Signed-out requests to the three spec routes are stopped by Clerk (307).
- Not yet verified: the Specs tab in a signed-in browser (button, stages, auto-download, Download link), Mermaid
rendering on GitHub, and the ref-stripping fix in a live run.

### Production fixes (2026-09-16)

- **Missing migrations in production:** project creation, AI sessions, and spec generation all failed with Prisma
`P2022` (`Project.specMdPath` does not exist). The production database (Prisma Postgres) had only the first two
migrations; `add_ai_sessions` and `add_project_spec` were applied with `prisma migrate deploy` (run manually).
Nothing applies migrations on deploy yet — open follow-up (CI job or build step).
- **Design agent plan schema:** `designPlanSchema` required at least one component while the plan prompt tells the
model to list only components to add, so "add to the existing canvas" turns failed with "response did not match
schema" on both attempts. The model now fills `designPlanDraftSchema` (no non-empty rules); `sanitizePlanDraft`
drops unnamed components and incomplete decisions, fills a missing role, responsibility, or summary, and returns
null when nothing is left, which aborts the run with `PLAN_NOTHING_TO_ADD_MESSAGE` (shown to the user as is).
`withSchemaRetry` now logs finish reason, validation cause, and the start of the response for both attempts.
- **Auth:** GitHub/Google SSO errors from `signIn.sso` / `signUp.sso` are now logged and shown on the card instead of
being ignored. Production still runs on Clerk development keys (`pk_test_`) — a production instance needs a custom
domain.
- `lib/prisma.ts` Accelerate URL check fixed (`prisma+postgres://`).
- Validation: `pnpm typecheck` and eslint on changed files passed; sanitizer checked on empty and partial drafts.
Not yet verified: an "add components" AI turn in production after the Trigger.dev deploy.
95 changes: 82 additions & 13 deletions lib/ai/agent-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,41 +106,70 @@ export const turnAnalysisSchema = z.object({
export type TurnAnalysis = z.infer<typeof turnAnalysisSchema>;

export const planDecisionSchema = z.object({
title: z.string().min(1).describe("The question the decision answers, e.g. 'Primary datastore'."),
choice: z.string().min(1).describe("What was chosen."),
rationale: z.string().min(1).describe("Why, tied to the brief."),
alternatives: z
.array(z.string())
.describe(atMost(LIST_LIMITS.alternatives, "Realistic options that were not chosen.")),
title: z.string().min(1),
choice: z.string().min(1),
rationale: z.string().min(1),
alternatives: z.array(z.string()),
});

export type PlanDecision = z.infer<typeof planDecisionSchema>;

/** A stored plan. Plans reach this shape only through {@link sanitizePlanDraft}. */
export const designPlanSchema = z.object({
summary: z.string().min(1).describe("Two or three sentences describing the architecture."),
summary: z.string().min(1),
components: z
.array(z.object({ name: z.string().min(1), role: z.string().min(1), responsibility: z.string().min(1) }))
.min(1),
flows: z.array(z.string()),
decisions: z.array(planDecisionSchema),
assumptions: z.array(z.string()),
});

export type DesignPlan = z.infer<typeof designPlanSchema>;

/**
* What the model returns when drafting a plan. Like the list limits, the
* non-empty rules are not enforced here: the plan prompt tells the model to
* list only components to add, so a request that adds nothing new came back
* with no components and failed the schema on every attempt. Empty values are
* handled by {@link sanitizePlanDraft} instead.
*/
export const designPlanDraftSchema = z.object({
summary: z.string().describe("Two or three sentences describing the architecture."),
components: z
.array(
z.object({
name: z.string().min(1).describe("Short unique component name, used verbatim on the diagram."),
name: z.string().describe("Short unique component name, used verbatim on the diagram."),
role: z
.string()
.min(1)
.describe("One or two word kind: Client, Gateway, Service, Worker, Queue, Cache, Database, External."),
responsibility: z.string().min(1).describe("What it does, in one sentence."),
responsibility: z.string().describe("What it does, in one sentence."),
}),
)
.min(1)
.describe(atMost(LIST_LIMITS.components, "The components to draw.")),
flows: z
.array(z.string())
.describe(atMost(LIST_LIMITS.flows, "Key request or data flows, each written as 'A → B → C: purpose'.")),
decisions: z
.array(planDecisionSchema)
.array(
z.object({
title: z.string().describe("The question the decision answers, e.g. 'Primary datastore'."),
choice: z.string().describe("What was chosen."),
rationale: z.string().describe("Why, tied to the brief."),
alternatives: z
.array(z.string())
.describe(atMost(LIST_LIMITS.alternatives, "Realistic options that were not chosen.")),
}),
)
.describe(atMost(LIST_LIMITS.decisions, "The major architectural choices, most consequential first.")),
assumptions: z.array(z.string()).describe(atMost(LIST_LIMITS.assumptions, "Assumptions the plan depends on.")),
});

export type DesignPlan = z.infer<typeof designPlanSchema>;
export type DesignPlanDraft = z.infer<typeof designPlanDraftSchema>;

/** Shown when a drafted plan has no components to draw. */
export const PLAN_NOTHING_TO_ADD_MESSAGE =
"The plan didn't include any new components to add. Say which components you want added or changed.";

/** Output of one design-agent run. The server validates it before storing it. */
export const designAgentResultSchema = z.discriminatedUnion("action", [
Expand Down Expand Up @@ -190,6 +219,46 @@ export function clampQuestions(questions: readonly ClarifyQuestion[]): ClarifyQu
}));
}

function nonEmpty(values: readonly string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean);
}

/**
* Turns a model draft into a storable plan: drops unnamed components and
* incomplete decisions, fills a missing role, responsibility, or summary, and
* returns null when no component is left to draw.
*/
export function sanitizePlanDraft(draft: DesignPlanDraft): DesignPlan | null {
const components = draft.components
.map((component) => ({
name: component.name.trim(),
role: component.role.trim() || "Service",
responsibility: component.responsibility.trim() || component.name.trim(),
}))
.filter((component) => component.name.length > 0);

if (components.length === 0) {
return null;
}

const decisions = draft.decisions
.map((decision) => ({
title: decision.title.trim(),
choice: decision.choice.trim(),
rationale: decision.rationale.trim(),
alternatives: nonEmpty(decision.alternatives),
}))
.filter((decision) => decision.title && decision.choice && decision.rationale);

return {
summary: draft.summary.trim() || `Adds ${components.map((component) => component.name).join(", ")}.`,
components,
flows: nonEmpty(draft.flows),
decisions,
assumptions: nonEmpty(draft.assumptions),
};
}

export function clampPlan(plan: DesignPlan): DesignPlan {
return {
...plan,
Expand Down
10 changes: 6 additions & 4 deletions lib/ai/design-agent-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
clampPlan,
clampQuestions,
designBriefSchema,
designPlanSchema,
designPlanDraftSchema,
MAX_CLARIFY_ROUNDS,
sanitizePlanDraft,
turnAnalysisSchema,
type DesignAgentPayload,
type DesignBrief,
Expand Down Expand Up @@ -145,19 +146,20 @@ export async function draftPlan(
previousPlan: DesignPlan | null,
latestInput: string,
canvas: CanvasSummary | null = null,
): Promise<DesignPlan> {
): Promise<DesignPlan | null> {
const { output } = await withSchemaRetry(() =>
generateText({
model,
system: PLAN_SYSTEM_PROMPT,
prompt: buildPlanPrompt(brief, previousPlan, latestInput, formatCanvasSummary(canvas)),
output: Output.object({ schema: designPlanSchema, name: "design_plan" }),
output: Output.object({ schema: designPlanDraftSchema, name: "design_plan" }),
providerOptions: thinking(model, "medium"),
timeout: { totalMs: CALL_TIMEOUT_MS.plan },
}),
);

return removeCanvasRefs(clampPlan(output));
const plan = sanitizePlanDraft(output);
return plan ? removeCanvasRefs(clampPlan(plan)) : null;
}

export interface GeneratedDesign {
Expand Down
21 changes: 19 additions & 2 deletions lib/ai/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,24 @@ export async function withSchemaRetry<T>(call: () => Promise<T>): Promise<T> {
if (!NoObjectGeneratedError.isInstance(error)) {
throw error;
}
console.warn("[ai] model output did not match the schema; retrying once", error.cause);
return call();
console.warn("[ai] model output did not match the schema; retrying once", describeSchemaMismatch(error));
try {
return await call();
} catch (retryError) {
if (NoObjectGeneratedError.isInstance(retryError)) {
console.error("[ai] model output did not match the schema again", describeSchemaMismatch(retryError));
}
throw retryError;
}
}
}

/** What failed and how, without logging the whole (possibly long) response. */
function describeSchemaMismatch(error: NoObjectGeneratedError) {
return {
finishReason: error.finishReason,
cause: error.cause instanceof Error ? error.cause.message : error.cause,
responseChars: error.text?.length ?? 0,
responseStart: error.text?.slice(0, 500),
};
}
2 changes: 2 additions & 0 deletions lib/ai/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatPlanText,
formatQuestionsText,
GENERATE_TURN_TEXT,
PLAN_NOTHING_TO_ADD_MESSAGE,
readPlanPayload,
readQuestionsPayload,
SKIP_TURN_TEXT,
Expand Down Expand Up @@ -130,6 +131,7 @@ function describeFailedRun(runId: string, message: string | undefined): string {
return describeRunFailure(message, {
generic: GENERIC_RUN_ERROR,
timeout: "The design agent took too long to respond. Try again.",
passthrough: [PLAN_NOTHING_TO_ADD_MESSAGE],
});
}

Expand Down
2 changes: 1 addition & 1 deletion lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function createPrismaClient() {
throw new Error("DATABASE_URL is not set.");
}

if (databaseUrl.startsWith("prism+postgres://")) {
if (databaseUrl.startsWith("prisma+postgres://")) {
return new PrismaClient({
accelerateUrl: databaseUrl,
}).$extends(withAccelerate()) as unknown as PrismaClient;
Expand Down
13 changes: 11 additions & 2 deletions src/trigger/design-agent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { mutateFlow } from "@liveblocks/react-flow/node";
import { logger, metadata, task } from "@trigger.dev/sdk/v3";
import { AbortTaskRunError, logger, metadata, task } from "@trigger.dev/sdk/v3";
import type { LanguageModel } from "ai";

import type { DesignAgentPayload, DesignAgentResult, DesignBrief, DesignPlan } from "@/lib/ai/agent-schema";
import {
PLAN_NOTHING_TO_ADD_MESSAGE,
type DesignAgentPayload,
type DesignAgentResult,
type DesignBrief,
type DesignPlan,
} from "@/lib/ai/agent-schema";
import { summarizeCanvas, type CanvasSummary } from "@/lib/ai/canvas-summary";
import {
analyzeTurn,
Expand Down Expand Up @@ -164,6 +170,9 @@ export const designAgentTask = task({

setStage("planning");
const plan = await draftPlan(model, analysis.brief, payload.plan, payload.input, canvas);
if (!plan) {
throw new AbortTaskRunError(PLAN_NOTHING_TO_ADD_MESSAGE);
}
logger.log("Plan drafted", { components: plan.components.length, decisions: plan.decisions.length });

setStage("done");
Expand Down
Loading