diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index 87653d0..080ead7 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -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 { 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 { 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 { 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 { 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)); diff --git a/apps/web/app/(dashboard)/servers/new/page.tsx b/apps/web/app/(dashboard)/servers/new/page.tsx index 7b87977..df32567 100644 --- a/apps/web/app/(dashboard)/servers/new/page.tsx +++ b/apps/web/app/(dashboard)/servers/new/page.tsx @@ -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('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,22 +341,19 @@ function NewServerPageInner() { }; try { - const res = await apiFetch<{ server: { id: string }; build: { id: string } }>( - '/v1/servers', - { - method: 'POST', - body: JSON.stringify({ - name, - slug, - prompt, - secrets: filledSecrets, - previewId: preview.previewId, - // Don't send specEdit when forking — the template's spec + pre-rendered code - // are already in the Redis cache. Edits would invalidate the impls. - ...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }), - }), - }, - ); + const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', { + method: 'POST', + body: JSON.stringify({ + name, + slug, + prompt, + secrets: filledSecrets, + previewId: preview.previewId, + // Don't send specEdit when forking — the template's spec + pre-rendered code + // 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() { />
- + - +

Analyzing your prompt…

- 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. +

+

+ {elapsedSec}s elapsed

)} @@ -490,7 +510,9 @@ function NewServerPageInner() { )}
-

Confirm what we'll build

+

+ Confirm what we'll build +

{editsDirty && (
@@ -560,9 +582,7 @@ function NewServerPageInner() {
-

- Credentials we need -

+

Credentials we need

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() { -

@@ -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>(() => Object.fromEntries(secretKeys.map((k) => [k, ''])), ); @@ -832,8 +845,8 @@ function SharePanel({

Your secrets stay private — they are never copied into a template. But your{' '} - generated code becomes publicly viewable{' '} - so others can audit it before forking. Unshare anytime. + generated code becomes publicly viewable so + others can audit it before forking. Unshare anytime.

@@ -868,9 +881,7 @@ function SharePanel({ {secretKeys.length > 0 && (
- + {secretKeys.map((k) => (
@@ -892,12 +903,7 @@ function SharePanel({

Published code is re-scanned for banned patterns and hardcoded secrets.

-
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 50c9dce..01f4dfc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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 { diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 51de67d..c02d593 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -54,19 +54,43 @@ 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 { +export async function generateSpec( + prompt: string, + opts: GenerateOptions = {}, +): Promise { if (!opts.apiKey) { return { spec: mockSpec(prompt), source: 'mock' }; } const client = new Anthropic({ apiKey: opts.apiKey }); - 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 }], - }); + 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') .map((b) => b.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]*?)```/);