From 59f7380da303f5201ec549369aff11b557120463 Mon Sep 17 00:00:00 2001 From: suharshit singh Date: Wed, 16 Sep 2026 22:31:06 +0530 Subject: [PATCH] fix: production AI plan schema, SSO errors, and Accelerate URL check - Design agent: the plan prompt tells the model to list only components to add, but the plan schema required at least one component, so "add to the existing canvas" turns failed with "response did not match schema" on both attempts. The model now fills a lenient draft schema; sanitizePlanDraft repairs or drops empty fields and aborts with a user-facing message when nothing is left to add. - withSchemaRetry logs finish reason, validation cause, and the start of the response for both attempts. - Sign-in/sign-up: errors from signIn.sso / signUp.sso are logged and shown on the card instead of being ignored. - lib/prisma.ts: fix the Accelerate URL prefix check (prisma+postgres://). - Progress tracker: record the production migration incident and these fixes. Co-Authored-By: Claude Opus 5 --- components/auth/sign-in-card.tsx | 7 ++- components/auth/sign-up-card.tsx | 7 ++- context/progress-tracker.md | 19 +++++++ lib/ai/agent-schema.ts | 95 +++++++++++++++++++++++++++----- lib/ai/design-agent-engine.ts | 10 ++-- lib/ai/model.ts | 21 ++++++- lib/ai/session-turns.ts | 2 + lib/prisma.ts | 2 +- src/trigger/design-agent.ts | 13 ++++- 9 files changed, 152 insertions(+), 24 deletions(-) diff --git a/components/auth/sign-in-card.tsx b/components/auth/sign-in-card.tsx index 2fbb680..b95ee1e 100644 --- a/components/auth/sign-in-card.tsx +++ b/components/auth/sign-in-card.tsx @@ -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() { diff --git a/components/auth/sign-up-card.tsx b/components/auth/sign-up-card.tsx index 2cd55db..9772b56 100644 --- a/components/auth/sign-up-card.tsx +++ b/components/auth/sign-up-card.tsx @@ -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() { diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 91e5f6c..b428e91 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -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. diff --git a/lib/ai/agent-schema.ts b/lib/ai/agent-schema.ts index f414ef5..a2ac8e6 100644 --- a/lib/ai/agent-schema.ts +++ b/lib/ai/agent-schema.ts @@ -106,41 +106,70 @@ export const turnAnalysisSchema = z.object({ export type TurnAnalysis = z.infer; 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; +/** 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; + +/** + * 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; +export type DesignPlanDraft = z.infer; + +/** 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", [ @@ -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, diff --git a/lib/ai/design-agent-engine.ts b/lib/ai/design-agent-engine.ts index 9107f4a..2bc68af 100644 --- a/lib/ai/design-agent-engine.ts +++ b/lib/ai/design-agent-engine.ts @@ -5,8 +5,9 @@ import { clampPlan, clampQuestions, designBriefSchema, - designPlanSchema, + designPlanDraftSchema, MAX_CLARIFY_ROUNDS, + sanitizePlanDraft, turnAnalysisSchema, type DesignAgentPayload, type DesignBrief, @@ -145,19 +146,20 @@ export async function draftPlan( previousPlan: DesignPlan | null, latestInput: string, canvas: CanvasSummary | null = null, -): Promise { +): Promise { 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 { diff --git a/lib/ai/model.ts b/lib/ai/model.ts index a15ab7b..bf57226 100644 --- a/lib/ai/model.ts +++ b/lib/ai/model.ts @@ -48,7 +48,24 @@ export async function withSchemaRetry(call: () => Promise): Promise { 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), + }; +} diff --git a/lib/ai/session-turns.ts b/lib/ai/session-turns.ts index 9d1223f..c421f2c 100644 --- a/lib/ai/session-turns.ts +++ b/lib/ai/session-turns.ts @@ -9,6 +9,7 @@ import { formatPlanText, formatQuestionsText, GENERATE_TURN_TEXT, + PLAN_NOTHING_TO_ADD_MESSAGE, readPlanPayload, readQuestionsPayload, SKIP_TURN_TEXT, @@ -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], }); } diff --git a/lib/prisma.ts b/lib/prisma.ts index ad51743..a66734b 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -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; diff --git a/src/trigger/design-agent.ts b/src/trigger/design-agent.ts index 646feba..bcf5dfe 100644 --- a/src/trigger/design-agent.ts +++ b/src/trigger/design-agent.ts @@ -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, @@ -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");