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");