fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser received a headerless 524 and reported it as a CORS failure. - preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one retry — comfortably inside the proxy budget - generateSpec maps an exhausted timeout to SpecTimeoutError; the route returns a clean 504 (with CORS headers) instead of a stalled connection - analyze step: live elapsed-seconds counter as freeze-proof, plus a reduced-motion exception so the loading spinner keeps spinning (a status indicator, which WCAG exempts from reduced-motion) - textarea resize grip restyled to dark theme (light hatch on dark square) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d0d5668d8
commit
e198d44e1e
@ -1,25 +1,36 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db';
|
||||
import { getSession } from '@bmm/auth';
|
||||
import { stopContainer } from '../lib/docker.js';
|
||||
import {
|
||||
CreateServerInput,
|
||||
IterateServerInput,
|
||||
and,
|
||||
buildLogs,
|
||||
builds,
|
||||
createDb,
|
||||
desc,
|
||||
eq,
|
||||
mcpServers,
|
||||
secrets,
|
||||
sql,
|
||||
templates,
|
||||
} from '@bmm/db';
|
||||
import { BannedPatternError, SpecTimeoutError, SpecValidationError, generateSpec } from '@bmm/llm';
|
||||
import {
|
||||
BuildEvent,
|
||||
PreviewInput,
|
||||
CreateServerInput,
|
||||
GeneratorSpec,
|
||||
IterateServerInput,
|
||||
PreviewInput,
|
||||
type SpecEdit,
|
||||
} from '@bmm/types';
|
||||
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { encryptSecret } from '../lib/crypto.js';
|
||||
import { stopContainer } from '../lib/docker.js';
|
||||
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
|
||||
import { requireAuth } from '../plugins/session.js';
|
||||
import { getBuildQueue } from '../lib/queue.js';
|
||||
import { buildChannel, getSubscriber } from '../lib/redis.js';
|
||||
import { encryptSecret } from '../lib/crypto.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { requireAuth } from '../plugins/session.js';
|
||||
import { getForkRefTemplate } from './templates.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
@ -42,7 +53,11 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
try {
|
||||
const { spec, source } = await generateSpec(parsed.data.prompt, {
|
||||
apiKey: config.ANTHROPIC_API_KEY,
|
||||
model: 'claude-opus-4-7',
|
||||
// Sonnet 4.6 drafts the spec well inside Cloudflare's ~100s proxy cap;
|
||||
// Opus routinely exceeded it, which reached the browser as a CORS error.
|
||||
model: 'claude-sonnet-4-6',
|
||||
timeoutMs: 45_000,
|
||||
maxRetries: 1,
|
||||
});
|
||||
const previewId = await cacheSpec(spec);
|
||||
return reply.send({
|
||||
@ -67,6 +82,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (err instanceof BannedPatternError) {
|
||||
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
|
||||
}
|
||||
if (err instanceof SpecTimeoutError) {
|
||||
return reply.code(504).send({
|
||||
error: 'preview_timeout',
|
||||
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
|
||||
});
|
||||
}
|
||||
app.log.error(err);
|
||||
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
|
||||
}
|
||||
@ -78,7 +99,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
||||
}
|
||||
const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data;
|
||||
const {
|
||||
name,
|
||||
slug,
|
||||
prompt,
|
||||
secrets: secretValues,
|
||||
previewId,
|
||||
specEdit,
|
||||
templateId,
|
||||
} = parsed.data;
|
||||
|
||||
// ---- Template-fork validation ----
|
||||
// templateId is user-controlled. To prevent fork_count manipulation + garbage
|
||||
@ -401,7 +430,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
const result = await stopContainer(server.containerId);
|
||||
containerStopped = result.ok;
|
||||
if (!result.ok) {
|
||||
app.log.warn({ containerId: server.containerId, detail: result.detail }, 'delete: stop failed');
|
||||
app.log.warn(
|
||||
{ containerId: server.containerId, detail: result.detail },
|
||||
'delete: stop failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input, Label, Textarea } from '@/components/input';
|
||||
import { StreamingLogs } from '@/components/streaming-logs';
|
||||
import { InstallSnippets } from '@/components/install-snippets';
|
||||
import { CodeBlock } from '@/components/code-block';
|
||||
import { Input, Label, Textarea } from '@/components/input';
|
||||
import { InstallSnippets } from '@/components/install-snippets';
|
||||
import { StreamingLogs } from '@/components/streaming-logs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
{
|
||||
@ -85,6 +85,7 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
|
||||
function NewServerPageInner() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<Step>('prompt');
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
@ -105,7 +106,11 @@ function NewServerPageInner() {
|
||||
const templateSlug = searchParams.get('template');
|
||||
|
||||
const trySlug = (n: string) =>
|
||||
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
||||
n
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
|
||||
useEffect(() => {
|
||||
@ -189,6 +194,19 @@ function NewServerPageInner() {
|
||||
}
|
||||
}, [preview, editable]);
|
||||
|
||||
// Live elapsed counter for the analyze step — a value that ticks every
|
||||
// second is unambiguous proof the page is alive, even when CSS animation is
|
||||
// suppressed (e.g. the OS "reduce motion" setting).
|
||||
useEffect(() => {
|
||||
if (step !== 'analyzing') return;
|
||||
setElapsedSec(0);
|
||||
const startedAt = Date.now();
|
||||
const id = setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [step]);
|
||||
|
||||
async function analyze() {
|
||||
setError(null);
|
||||
if (prompt.trim().length < 10) {
|
||||
@ -323,9 +341,7 @@ function NewServerPageInner() {
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
|
||||
'/v1/servers',
|
||||
{
|
||||
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
@ -337,8 +353,7 @@ function NewServerPageInner() {
|
||||
// are already in the Redis cache. Edits would invalidate the impls.
|
||||
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
setBuildId(res.build.id);
|
||||
setServerId(res.server.id);
|
||||
setStep('building');
|
||||
@ -412,7 +427,9 @@ function NewServerPageInner() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
|
||||
<Label htmlFor="slug" hint="becomes subdomain / id">
|
||||
Slug
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
@ -437,10 +454,13 @@ function NewServerPageInner() {
|
||||
|
||||
{step === 'analyzing' && (
|
||||
<div className="mt-10 panel p-8 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
|
||||
<p className="mt-4 text-[13px]">Analyzing your prompt…</p>
|
||||
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
||||
Claude Opus 4.7 is parsing the spec. Usually 20–40 seconds.
|
||||
Claude Sonnet 4.6 is drafting the tool spec. Usually 20–40 seconds.
|
||||
</p>
|
||||
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
|
||||
{elapsedSec}s elapsed
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -490,7 +510,9 @@ function NewServerPageInner() {
|
||||
)}
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">
|
||||
Confirm what we'll build
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{editsDirty && (
|
||||
<button
|
||||
@ -508,9 +530,9 @@ function NewServerPageInner() {
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
||||
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
|
||||
Edit tool names, descriptions or input schemas inline. Renaming parameters may
|
||||
require an <span className="mono">Iterate</span> after build to update the
|
||||
implementation — the existing impl references the original names.
|
||||
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
|
||||
an <span className="mono">Iterate</span> after build to update the implementation —
|
||||
the existing impl references the original names.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -560,9 +582,7 @@ function NewServerPageInner() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
||||
Credentials we need
|
||||
</h3>
|
||||
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
|
||||
implementation doesn't actually use one.
|
||||
@ -625,12 +645,7 @@ function NewServerPageInner() {
|
||||
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={build}
|
||||
disabled={Boolean(hasSchemaErrors)}
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
|
||||
Build server →
|
||||
</Button>
|
||||
</div>
|
||||
@ -754,9 +769,7 @@ function SharePanel({
|
||||
}) {
|
||||
const [share, setShare] = useState(true);
|
||||
const [category, setCategory] = useState('other');
|
||||
const [shortDescription, setShortDescription] = useState(
|
||||
defaultShortDescription.slice(0, 280),
|
||||
);
|
||||
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
|
||||
const [hints, setHints] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(secretKeys.map((k) => [k, ''])),
|
||||
);
|
||||
@ -832,8 +845,8 @@ function SharePanel({
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||
Your secrets stay private — they are never copied into a template. But your{' '}
|
||||
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
|
||||
so others can audit it before forking. Unshare anytime.
|
||||
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
|
||||
others can audit it before forking. Unshare anytime.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@ -868,9 +881,7 @@ function SharePanel({
|
||||
|
||||
{secretKeys.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label hint="optional — helps forkers know what to paste">
|
||||
Credential hints
|
||||
</Label>
|
||||
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
|
||||
{secretKeys.map((k) => (
|
||||
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
|
||||
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
|
||||
@ -892,12 +903,7 @@ function SharePanel({
|
||||
<p className="text-[11px] text-[--color-fg-subtle]">
|
||||
Published code is re-scanned for banned patterns and hardcoded secrets.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={publish}
|
||||
disabled={state === 'submitting'}
|
||||
>
|
||||
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
|
||||
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -87,6 +87,17 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-fg-subtle);
|
||||
}
|
||||
/* Textarea resize grip — light hatching on a dark square so it matches the
|
||||
theme instead of the OS default (light square with dark hatching). */
|
||||
textarea::-webkit-resizer {
|
||||
background-color: var(--color-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--color-fg-muted) 0 1px,
|
||||
transparent 1px 4px
|
||||
);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@ -115,6 +126,16 @@
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
/* A loading spinner reports system status — WCAG treats status indicators as
|
||||
an exception to reduced-motion, so keep it spinning after the rule above
|
||||
has damped every decorative animation. Same layer + higher specificity
|
||||
than the universal selector, so this !important wins. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-spin {
|
||||
animation-duration: 1s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
|
||||
@ -54,18 +54,42 @@ export interface GenerateOptions {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
/** Per-attempt request timeout in ms. Omit to use the SDK default. */
|
||||
timeoutMs?: number;
|
||||
/** SDK retry count. Omit to use the SDK default. */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export async function generateSpec(prompt: string, opts: GenerateOptions = {}): Promise<GenerationResult> {
|
||||
export async function generateSpec(
|
||||
prompt: string,
|
||||
opts: GenerateOptions = {},
|
||||
): Promise<GenerationResult> {
|
||||
if (!opts.apiKey) {
|
||||
return { spec: mockSpec(prompt), source: 'mock' };
|
||||
}
|
||||
const client = new Anthropic({ apiKey: opts.apiKey });
|
||||
const response = await client.messages.create({
|
||||
const requestOptions: { timeout?: number; maxRetries?: number } = {};
|
||||
if (opts.timeoutMs !== undefined) requestOptions.timeout = opts.timeoutMs;
|
||||
if (opts.maxRetries !== undefined) requestOptions.maxRetries = opts.maxRetries;
|
||||
|
||||
const response = await client.messages
|
||||
.create(
|
||||
{
|
||||
model: opts.model ?? 'claude-opus-4-7',
|
||||
max_tokens: opts.maxTokens ?? 8192,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
},
|
||||
requestOptions,
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
// A per-attempt timeout surfaces as APIConnectionTimeoutError once the
|
||||
// SDK exhausts retries. Map it to a typed error so the API layer returns
|
||||
// a clean 504 instead of letting the edge proxy time out headerless.
|
||||
if (err instanceof Anthropic.APIConnectionTimeoutError) {
|
||||
throw new SpecTimeoutError('spec generation exceeded the time budget');
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
const text = response.content
|
||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
||||
@ -88,6 +112,10 @@ export class BannedPatternError extends Error {
|
||||
override readonly name = 'BannedPatternError';
|
||||
}
|
||||
|
||||
export class SpecTimeoutError extends Error {
|
||||
override readonly name = 'SpecTimeoutError';
|
||||
}
|
||||
|
||||
function extractJson(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user