feat(build): AI architecture recommendation with review + confirm flow

- New /api/projects/[projectId]/architecture (GET/POST/PATCH) — reads PRD
  + phases, calls AI to generate structured monorepo architecture JSON,
  persists to fs_projects.data.architecture; PATCH sets confirmed flag
- Rebuilt Build tab to show the AI-generated recommendation: expandable
  app cards (tech stack, key screens), shared packages, infrastructure,
  integrations, and risk notes; confirm button + "adjustable later" note

Made-with: Cursor
This commit is contained in:
2026-03-03 21:02:06 -08:00
parent 156232062d
commit bedd7d3470
2 changed files with 626 additions and 115 deletions

View File

@@ -4,39 +4,190 @@ import { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
interface Project { interface App {
id: string; name: string;
status?: string; type: string;
prd?: string; description: string;
giteaRepoUrl?: string; tech: string[];
screens: string[];
} }
const BUILD_FEATURES = [ interface Package {
"Authentication system", name: string;
"Database schema", description: string;
"API endpoints", }
"Core UI",
"Business logic", interface Infra {
"Tests", name: string;
]; reason: string;
}
interface Integration {
name: string;
required: boolean;
notes: string;
}
interface Architecture {
productName: string;
productType: string;
summary: string;
apps: App[];
packages: Package[];
infrastructure: Infra[];
integrations: Integration[];
designSurfaces: string[];
riskNotes: string[];
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.12em", textTransform: "uppercase",
marginBottom: 10, marginTop: 28,
}}>
{children}
</div>
);
}
function AppCard({ app }: { app: App }) {
const [open, setOpen] = useState(false);
const icons: Record<string, string> = {
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
mobile: "📱", worker: "⚙️", engine: "🎯",
};
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 8, overflow: "hidden",
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: "100%", textAlign: "left", background: "none", border: "none",
cursor: "pointer", padding: "14px 18px",
display: "flex", alignItems: "center", gap: 12,
fontFamily: "Outfit, sans-serif",
}}
>
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
apps/{app.name}
</span>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#6b6560", background: "#f0ece4",
padding: "2px 7px", borderRadius: 4,
}}>
{app.type}
</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
{app.description}
</div>
</div>
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
</button>
{open && (
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
{app.tech.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{app.tech.map((t, i) => (
<span key={i} style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#4a4640", background: "#f6f4f0",
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
}}>{t}</span>
))}
</div>
</div>
)}
{app.screens.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{app.screens.map((s, i) => (
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#c5c0b8" }}></span> {s}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export default function BuildPage() { export default function BuildPage() {
const params = useParams(); const params = useParams();
const projectId = params.projectId as string; const projectId = params.projectId as string;
const workspace = params.workspace as string; const workspace = params.workspace as string;
const [project, setProject] = useState<Project | null>(null);
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch(`/api/projects/${projectId}`) fetch(`/api/projects/${projectId}/architecture`)
.then((r) => r.json()) .then(r => r.json())
.then((d) => { .then(d => {
setProject(d.project); setPrd(d.prd);
setArchitecture(d.architecture ?? null);
setLoading(false); setLoading(false);
}) })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
// Also check confirmed flag
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
.catch(() => {});
}, [projectId]); }, [projectId]);
const handleGenerate = async (force = false) => {
setGenerating(true);
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ forceRegenerate: force }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error || "Generation failed");
setArchitecture(d.architecture);
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setGenerating(false);
}
};
const handleConfirm = async () => {
setConfirming(true);
try {
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
setArchitectureConfirmed(true);
} catch { /* swallow */ } finally {
setConfirming(false);
}
};
if (loading) { if (loading) {
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
@@ -45,48 +196,24 @@ export default function BuildPage() {
); );
} }
const hasRepo = Boolean(project?.giteaRepoUrl); // No PRD yet
const hasPRD = Boolean(project?.prd); if (!prd) {
if (!hasPRD) {
return ( return (
<div <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
className="vibn-enter"
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
padding: 40, fontFamily: "Outfit, sans-serif",
}}
>
<div style={{ textAlign: "center", maxWidth: 360 }}> <div style={{ textAlign: "center", maxWidth: 360 }}>
<div style={{ <div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
width: 56, height: 56, borderRadius: 14, <h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
background: "#fff", border: "1px solid #e8e4dc",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.4rem", margin: "0 auto 18px",
boxShadow: "0 2px 8px #1a1a1a08",
}}>
🔒
</div>
<h3 style={{
fontFamily: "Newsreader, serif", fontSize: "1.3rem",
fontWeight: 400, color: "#1a1a1a", marginBottom: 8,
}}>
Complete your PRD first Complete your PRD first
</h3> </h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}> <p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
Finish your discovery with Atlas, then the builder unlocks automatically. Finish your discovery conversation with Atlas, then the architect will unlock automatically.
</p> </p>
<Link <Link href={`/${workspace}/project/${projectId}/overview`} style={{
href={`/${workspace}/project/${projectId}/overview`} display: "inline-block", padding: "9px 20px", borderRadius: 7,
style={{
display: "inline-block",
padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff", background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
fontFamily: "Outfit, sans-serif",
textDecoration: "none", textDecoration: "none",
}} }}>
>
Continue with Atlas Continue with Atlas
</Link> </Link>
</div> </div>
@@ -94,83 +221,255 @@ export default function BuildPage() {
); );
} }
if (!hasRepo) { // PRD exists but no architecture yet — prompt to generate
if (!architecture) {
return ( return (
<div <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
className="vibn-enter" <div style={{ textAlign: "center", maxWidth: 440 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
</h3>
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
The AI will read your PRD and recommend the technical structure apps, services, database, and integrations. You'll review it before anything gets built.
</p>
{error && (
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
{error}
</div>
)}
<button
onClick={() => handleGenerate(false)}
disabled={generating}
style={{ style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: "11px 28px", borderRadius: 8,
padding: 40, fontFamily: "Outfit, sans-serif", background: generating ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
transition: "background 0.15s",
}} }}
> >
<div style={{ textAlign: "center", maxWidth: 360 }}> {generating ? "Analysing PRD…" : "Generate architecture →"}
<div style={{ </button>
width: 56, height: 56, borderRadius: 14, {generating && (
background: "#fff", border: "1px solid #e8e4dc", <p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
display: "flex", alignItems: "center", justifyContent: "center", This takes about 1530 seconds
fontSize: "1.4rem", margin: "0 auto 18px",
boxShadow: "0 2px 8px #1a1a1a08",
}}>
</div>
<h3 style={{
fontFamily: "Newsreader, serif", fontSize: "1.3rem",
fontWeight: 400, color: "#1a1a1a", marginBottom: 8,
}}>
PRD ready build coming soon
</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6 }}>
The Architect agent will generate your project structure and kick off the build pipeline.
This feature is in active development.
</p> </p>
)}
</div> </div>
</div> </div>
); );
} }
// Architecture loaded — show full review UI
return ( return (
<div <div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
className="vibn-enter"
style={{ {/* Header */}
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
padding: 40, fontFamily: "Outfit, sans-serif", <div>
}} <h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
> Architecture
<div style={{ width: "100%", maxWidth: 500 }}> </h2>
<h3 style={{ <p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
fontFamily: "Newsreader, serif", fontSize: "1.2rem", {architecture.productType}
fontWeight: 400, color: "#1a1a1a", marginBottom: 18, </p>
}}>
Build progress
</h3>
{BUILD_FEATURES.map((f, i) => (
<div
key={i}
className="vibn-enter"
style={{
display: "flex", alignItems: "center", gap: 12,
padding: "12px 16px", marginBottom: 4, borderRadius: 8,
background: "#fff", border: "1px solid #e8e4dc",
animationDelay: `${i * 0.05}s`,
}}
>
<span style={{
width: 7, height: 7, borderRadius: "50%",
background: "#d4a04a", display: "inline-block", flexShrink: 0,
}} />
<span style={{ flex: 1, fontSize: "0.84rem", color: "#1a1a1a" }}>{f}</span>
<div style={{ width: 80, height: 3, borderRadius: 2, background: "#eae6de" }}>
<div style={{ height: "100%", width: "0%", borderRadius: 2, background: "#3d5afe" }} />
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{architectureConfirmed && (
<span style={{ <span style={{
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
fontSize: "0.7rem", color: "#a09a90", minWidth: 28, textAlign: "right", color: "#2e7d32", background: "#2e7d3210",
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
}}> }}>
0% ✓ Confirmed
</span>
)}
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "6px 14px", borderRadius: 6,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}
>
{generating ? "Regenerating…" : "Regenerate"}
</button>
</div>
</div>
{/* Summary */}
<div style={{
marginTop: 18, padding: "16px 20px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
}}>
{architecture.summary}
</div>
{/* Apps */}
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
{/* Packages */}
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{architecture.packages.map((pkg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "12px 16px",
}}>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
packages/{pkg.name}
</div>
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
{pkg.description}
</div>
</div>
))}
</div>
{/* Infrastructure */}
{architecture.infrastructure.length > 0 && (
<>
<SectionLabel>Infrastructure</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.infrastructure.map((infra, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#3d5afe", background: "#3d5afe0d",
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
flexShrink: 0, marginTop: 1,
}}>
{infra.name}
</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
{infra.reason}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</>
)}
{/* Integrations */}
{architecture.integrations.length > 0 && (
<>
<SectionLabel>External integrations</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.integrations.map((intg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: intg.required ? "#9a7b3a" : "#8a8478",
background: intg.required ? "#d4a04a12" : "#f6f4f0",
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
}}>
{intg.required ? "required" : "optional"}
</span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
</div>
</div>
))}
</div>
</>
)}
{/* Risk notes */}
{architecture.riskNotes.length > 0 && (
<>
<SectionLabel>Architecture risks</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.riskNotes.map((risk, i) => (
<div key={i} style={{
background: "#fff8f0", border: "1px solid #ffe0b2",
borderRadius: 8, padding: "10px 16px",
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
display: "flex", gap: 8,
}}>
<span>⚠️</span><span>{risk}</span>
</div>
))}
</div>
</>
)}
{/* Confirm section */}
<div style={{
marginTop: 32, padding: "20px 24px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
borderLeft: "3px solid #1a1a1a",
}}>
{architectureConfirmed ? (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
✓ Architecture confirmed
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
</p>
<Link href={`/${workspace}/project/${projectId}/design`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Choose your design →
</Link>
</div>
) : (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
Does this look right?
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
Review the structure above. You can regenerate if something's off, or confirm to move to design.
You can always come back and adjust before the build starts nothing gets scaffolded yet.
</p>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handleConfirm}
disabled={confirming}
style={{
padding: "9px 22px", borderRadius: 7,
background: confirming ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
}}
>
{confirming ? "Confirming…" : "Confirm architecture →"}
</button>
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "9px 18px", borderRadius: 7,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.78rem", color: "#8a8478",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
}}
>
Regenerate
</button>
</div>
</div>
)}
</div>
<div style={{ height: 40 }} />
</div> </div>
); );
} }

View File

@@ -0,0 +1,212 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
// ---------------------------------------------------------------------------
// GET — return saved architecture (if it exists)
// ---------------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
const data = rows[0]?.data ?? {};
return NextResponse.json({
architecture: data.architecture ?? null,
prd: data.prd ?? null,
});
} catch {
return NextResponse.json({ architecture: null, prd: null });
}
}
// ---------------------------------------------------------------------------
// POST — generate architecture recommendation from PRD using AI
// ---------------------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
const body = await req.json().catch(() => ({}));
const forceRegenerate = body.forceRegenerate === true;
// Load project PRD + phases
let prd: string | null = null;
let phases: any[] = [];
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
const data = rows[0]?.data ?? {};
prd = data.prd ?? null;
// Return cached architecture if it exists and not forcing regenerate
if (data.architecture && !forceRegenerate) {
return NextResponse.json({ architecture: data.architecture, cached: true });
}
} catch {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
if (!prd) {
return NextResponse.json({ error: "No PRD found — complete discovery first" }, { status: 400 });
}
try {
const phaseRows = await query<{ phase: string; title: string; summary: string; data: any }>(
`SELECT phase, title, summary, data FROM atlas_phases WHERE project_id = $1 ORDER BY saved_at ASC`,
[projectId]
);
phases = phaseRows;
} catch { /* phases optional */ }
// Build a concise context string from phases
const phaseContext = phases.map(p =>
`## ${p.title}\n${p.summary}\n${JSON.stringify(p.data, null, 2)}`
).join("\n\n");
const prompt = `You are a senior software architect. Analyse the following Product Requirements Document and recommend a technical architecture for a Turborepo monorepo.
Return ONLY a valid JSON object (no markdown, no explanation) with this exact structure:
{
"productName": "string",
"productType": "string (e.g. PWA Game, SaaS, Marketplace, Internal Tool)",
"summary": "2-3 sentence plain-English summary of the recommended architecture",
"apps": [
{
"name": "string (e.g. web, api, simulator)",
"type": "string (e.g. Next.js 15, Express API, Node.js service)",
"description": "string — what this app does",
"tech": ["string array of key technologies"],
"screens": ["string array — key screens/routes if applicable, else empty"]
}
],
"packages": [
{
"name": "string (e.g. db, types, ui)",
"description": "string — what this shared package contains"
}
],
"infrastructure": [
{
"name": "string (e.g. PostgreSQL, Redis, Background Jobs)",
"reason": "string — why this is needed based on the PRD"
}
],
"integrations": [
{
"name": "string (e.g. Ad Network SDK)",
"required": true,
"notes": "string"
}
],
"designSurfaces": ["string array — e.g. Web App, Mobile PWA, Admin"],
"riskNotes": ["string array — 1-2 key architectural risks from the PRD"]
}
Be specific to this product. Do not use generic boilerplate — base your decisions on the PRD content.
--- DISCOVERY PHASES ---
${phaseContext}
--- PRD ---
${prd}`;
try {
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: prompt,
session_id: `arch_${projectId}_${Date.now()}`,
history: [],
is_init: false,
tools: [], // no tools needed — just structured generation
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
throw new Error(`Agent runner responded ${res.status}`);
}
const data = await res.json();
const raw = data.reply ?? "";
// Extract JSON from response (strip any accidental markdown)
const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("No JSON in response");
const architecture = JSON.parse(jsonMatch[0]);
// Persist to project data
await query(
`UPDATE fs_projects
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architecture}', $2::jsonb, true),
updated_at = NOW()
WHERE id = $1`,
[projectId, JSON.stringify(architecture)]
);
return NextResponse.json({ architecture, cached: false });
} catch (err) {
console.error("[architecture] Generation failed:", err);
return NextResponse.json(
{ error: "Architecture generation failed. Please try again." },
{ status: 500 }
);
}
}
// ---------------------------------------------------------------------------
// PATCH — confirm architecture (sets architectureConfirmed flag)
// ---------------------------------------------------------------------------
export async function PATCH(
_req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { projectId } = await params;
try {
await query(
`UPDATE fs_projects
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{architectureConfirmed}', 'true'::jsonb, true),
updated_at = NOW()
WHERE id = $1`,
[projectId]
);
return NextResponse.json({ confirmed: true });
} catch {
return NextResponse.json({ error: "Failed to confirm" }, { status: 500 });
}
}