diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5c934c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NEXT_PUBLIC_APP_NAME=Forge +PIPELINE_STAGE_MIN_MS=2500 +PIPELINE_STAGE_MAX_MS=5500 +PIPELINE_TICK_MS=600 +PROJECT_TTL_MS=3600000 diff --git a/README.md b/README.md index 358a5ee..d3cf8fd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ # forge -Forge — Client portal for Dark Factory autonomous AI development pipeline + +Forge is a full-stack Next.js 14 client portal for Dark Factory project intake and real-time build tracking. + +## Features + +- Project Brief Submission page with guided multi-step form +- Build Tracker Dashboard with 6-stage pipeline timeline +- Live SSE event stream with reconnect-safe `id` replay +- Real-time stats and log feed +- In-memory storage for briefs, stages, logs, and stats +- Zod request validation and structured API error responses + +## Tech Stack + +- Next.js 14 (App Router) +- TypeScript (strict mode) +- Tailwind CSS +- shadcn-style UI primitives +- Zod validation +- Vitest + Playwright tests + +## Setup + +1. Install dependencies: + `npm install` +2. Create env file: + `cp .env.example .env.local` +3. Run dev server: + `npm run dev` + +## Environment Variables + +Defined in `.env.example`: + +- `NEXT_PUBLIC_APP_NAME` +- `PIPELINE_STAGE_MIN_MS` +- `PIPELINE_STAGE_MAX_MS` +- `PIPELINE_TICK_MS` +- `PROJECT_TTL_MS` + +## Routes + +### Pages + +- `/` Project Brief Submission +- `/tracker/[projectId]` Build Tracker Dashboard + +### API + +- `POST /api/projects` create project brief +- `GET /api/projects/[projectId]` fetch project +- `GET /api/projects/[projectId]/stats` fetch stats +- `POST /api/projects/[projectId]/pipeline/start` start simulation pipeline +- `GET /api/projects/[projectId]/events` SSE stream (heartbeat + replay + disconnect cleanup) + +## Architecture + +- `src/lib/store/in-memory-store.ts`: in-memory project state +- `src/lib/store/event-bus.ts`: project-scoped pub/sub and event replay buffers +- `src/lib/pipeline/simulation-engine.ts`: orchestrates 6-stage execution and emits stage/log/stats events +- `src/app/api/projects/*`: HTTP + SSE handlers with validation and typed envelopes +- `src/components/project-brief/*`: form flow UI +- `src/components/build-tracker/*`: dashboard visualization and live updates + +## Testing + +- Unit tests: `npm run test:unit` +- Integration tests: `npm run test:integration` +- E2E tests: `npm run test:e2e` + +## Persistence Notes + +Data is in-memory only and resets on process restart. For local reset, restart the dev server. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..6d32668 --- /dev/null +++ b/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true +}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..cae895c --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "forge", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "npm run test:unit && npm run test:integration", + "test:unit": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:e2e": "playwright test" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "next": "14.2.18", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-hook-form": "^7.53.2", + "tailwind-merge": "^2.5.4", + "zod": "^3.23.8" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.20", + "jsdom": "^25.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.6.3", + "vitest": "^2.1.5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/src/app/api/projects/[projectId]/events/route.ts b/src/app/api/projects/[projectId]/events/route.ts new file mode 100644 index 0000000..1d17dbd --- /dev/null +++ b/src/app/api/projects/[projectId]/events/route.ts @@ -0,0 +1,69 @@ +import { eventBus } from "@/lib/store/event-bus"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; +import type { PipelineEvent } from "@/lib/types/domain"; + +interface Params { + params: { projectId: string }; +} + +function encodeSse(event: PipelineEvent): string { + return `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; +} + +export async function GET(request: Request, { params }: Params): Promise { + try { + const project = inMemoryStore.getProject(params.projectId); + if (!project) { + return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "Project not found" } }), { + status: 404, + headers: { "Content-Type": "application/json" } + }); + } + + const lastEventIdHeader = request.headers.get("last-event-id"); + const parsedLastId = Number.isNaN(Number(lastEventIdHeader)) ? 0 : Number(lastEventIdHeader ?? "0"); + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const replay = eventBus.replayFrom(params.projectId, parsedLastId); + replay.forEach((event) => controller.enqueue(encoder.encode(encodeSse(event)))); + + const unsubscribe = eventBus.subscribe(params.projectId, (event) => { + controller.enqueue(encoder.encode(encodeSse(event))); + }); + + const heartbeat = setInterval(() => { + const event = eventBus.publish(params.projectId, { + type: "heartbeat", + projectId: params.projectId, + timestamp: new Date().toISOString(), + payload: { ok: true } + }); + controller.enqueue(encoder.encode(encodeSse(event))); + }, 15000); + + request.signal.addEventListener("abort", () => { + clearInterval(heartbeat); + unsubscribe(); + controller.close(); + }); + } + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no" + } + }); + } catch (error) { + return new Response(JSON.stringify({ success: false, error: { code: "INTERNAL_SERVER_ERROR", message: "Unexpected server error" } }), { + status: 500, + headers: { "Content-Type": "application/json" } + }); + } +} diff --git a/src/app/api/projects/[projectId]/pipeline/start/route.ts b/src/app/api/projects/[projectId]/pipeline/start/route.ts new file mode 100644 index 0000000..4ffb7ba --- /dev/null +++ b/src/app/api/projects/[projectId]/pipeline/start/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api/response"; +import { conflictResponse, internalServerErrorResponse, notFoundResponse, validationErrorResponse } from "@/lib/api/errors"; +import { startPipelineSchema } from "@/lib/validation/project-brief-schema"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; +import { startPipeline } from "@/lib/pipeline/simulation-engine"; + +interface Params { + params: { projectId: string }; +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const body = await request.json(); + const parsed = startPipelineSchema.safeParse(body); + if (!parsed.success) { + return validationErrorResponse(parsed.error); + } + + if (parsed.data.projectId !== params.projectId) { + return conflictResponse("projectId in body does not match route param"); + } + + const project = inMemoryStore.getProject(params.projectId); + if (!project) { + return notFoundResponse("Project not found"); + } + + const result = await startPipeline(params.projectId); + if (!result.started) { + return conflictResponse(result.reason ?? "Unable to start pipeline"); + } + + return apiSuccess({ started: true }); + } catch (error) { + return internalServerErrorResponse(); + } +} diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts new file mode 100644 index 0000000..c708a1f --- /dev/null +++ b/src/app/api/projects/[projectId]/route.ts @@ -0,0 +1,19 @@ +import { apiSuccess } from "@/lib/api/response"; +import { internalServerErrorResponse, notFoundResponse } from "@/lib/api/errors"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; + +interface Params { + params: { projectId: string }; +} + +export async function GET(_: Request, { params }: Params) { + try { + const project = inMemoryStore.getProject(params.projectId); + if (!project) { + return notFoundResponse("Project not found"); + } + return apiSuccess(project); + } catch (error) { + return internalServerErrorResponse(); + } +} diff --git a/src/app/api/projects/[projectId]/stats/route.ts b/src/app/api/projects/[projectId]/stats/route.ts new file mode 100644 index 0000000..683cbd4 --- /dev/null +++ b/src/app/api/projects/[projectId]/stats/route.ts @@ -0,0 +1,19 @@ +import { apiSuccess } from "@/lib/api/response"; +import { internalServerErrorResponse, notFoundResponse } from "@/lib/api/errors"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; + +interface Params { + params: { projectId: string }; +} + +export async function GET(_: Request, { params }: Params) { + try { + const project = inMemoryStore.getProject(params.projectId); + if (!project) { + return notFoundResponse("Project not found"); + } + return apiSuccess(project.stats); + } catch (error) { + return internalServerErrorResponse(); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..baadf51 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from "next/server"; +import { apiSuccess } from "@/lib/api/response"; +import { internalServerErrorResponse, validationErrorResponse } from "@/lib/api/errors"; +import { projectBriefSchema } from "@/lib/validation/project-brief-schema"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const parsed = projectBriefSchema.safeParse(json); + if (!parsed.success) { + return validationErrorResponse(parsed.error); + } + + const project = inMemoryStore.createProject(parsed.data); + return apiSuccess(project, 201); + } catch (error) { + return internalServerErrorResponse(); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..e432ad9 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +html, +body { + min-height: 100%; +} + +body { + @apply bg-background text-foreground antialiased; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..e8e96a0 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { Manrope } from "next/font/google"; +import "@/app/globals.css"; +import { env } from "@/lib/config/env"; + +const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" }); + +export const metadata: Metadata = { + title: env.NEXT_PUBLIC_APP_NAME, + description: "Forge client portal" +}; + +export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element { + return ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..ea2ff57 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,23 @@ +import { ProjectBriefForm } from "@/components/project-brief/project-brief-form"; +import { StepProgress } from "@/components/project-brief/step-progress"; + +export default function HomePage(): JSX.Element { + return ( +
+
+
+
+

Start Your Forge Build

+

Tell us what to build in a guided brief.

+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/app/tracker/[projectId]/page.tsx b/src/app/tracker/[projectId]/page.tsx new file mode 100644 index 0000000..8558709 --- /dev/null +++ b/src/app/tracker/[projectId]/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; +import { TrackerDashboard } from "@/components/build-tracker/tracker-dashboard"; +import { inMemoryStore } from "@/lib/store/in-memory-store"; + +interface TrackerPageProps { + params: { projectId: string }; +} + +export default function TrackerPage({ params }: TrackerPageProps): JSX.Element { + const project = inMemoryStore.getProject(params.projectId); + if (!project) { + notFound(); + } + return ; +} diff --git a/src/components/build-tracker/log-feed.tsx b/src/components/build-tracker/log-feed.tsx new file mode 100644 index 0000000..58378a0 --- /dev/null +++ b/src/components/build-tracker/log-feed.tsx @@ -0,0 +1,17 @@ +import { componentClasses } from "@/lib/constants/design-tokens"; +import type { PipelineLogEvent } from "@/lib/types/domain"; + +interface LogFeedProps { + logs: PipelineLogEvent[]; +} + +export function LogFeed({ logs }: LogFeedProps): JSX.Element { + return ( +
+ {logs.length === 0 ?

Waiting for logs...

: null} + {logs.map((log) => ( +

[{new Date(log.timestamp).toLocaleTimeString()}] {log.message}

+ ))} +
+ ); +} diff --git a/src/components/build-tracker/pipeline-stage-list.tsx b/src/components/build-tracker/pipeline-stage-list.tsx new file mode 100644 index 0000000..e534286 --- /dev/null +++ b/src/components/build-tracker/pipeline-stage-list.tsx @@ -0,0 +1,30 @@ +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import type { PipelineStageState } from "@/lib/types/domain"; + +interface PipelineStageListProps { + stages: PipelineStageState[]; +} + +export function PipelineStageList({ stages }: PipelineStageListProps): JSX.Element { + return ( +
+ {stages.map((stage) => ( +
+
+ {stage.name} + + {stage.status} + +
+

{stage.description}

+ +
+ ))} +
+ ); +} diff --git a/src/components/build-tracker/stat-card.tsx b/src/components/build-tracker/stat-card.tsx new file mode 100644 index 0000000..742b78d --- /dev/null +++ b/src/components/build-tracker/stat-card.tsx @@ -0,0 +1,15 @@ +import { componentClasses } from "@/lib/constants/design-tokens"; + +interface StatCardProps { + label: string; + value: string | number; +} + +export function StatCard({ label, value }: StatCardProps): JSX.Element { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/src/components/build-tracker/tracker-dashboard.tsx b/src/components/build-tracker/tracker-dashboard.tsx new file mode 100644 index 0000000..161b04e --- /dev/null +++ b/src/components/build-tracker/tracker-dashboard.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { StatCard } from "@/components/build-tracker/stat-card"; +import { PipelineStageList } from "@/components/build-tracker/pipeline-stage-list"; +import { LogFeed } from "@/components/build-tracker/log-feed"; +import { Progress } from "@/components/ui/progress"; +import { useSse } from "@/hooks/use-sse"; +import { usePipelineState } from "@/hooks/use-pipeline-state"; +import { startProjectPipeline } from "@/lib/api/client"; +import type { ProjectRecord } from "@/lib/types/domain"; + +export function TrackerDashboard({ project }: { project: ProjectRecord }): JSX.Element { + const { state, applyEvent } = usePipelineState(project.stages); + const [starting, setStarting] = useState(false); + const [statusText, setStatusText] = useState(project.status); + + const handleEvent = useCallback(applyEvent, [applyEvent]); + useSse(project.id, handleEvent); + + useEffect(() => { + setStatusText(state.status); + }, [state.status]); + + const handleStart = async (): Promise => { + try { + setStarting(true); + await startProjectPipeline(project.id); + } catch (error) { + setStatusText("failed"); + } finally { + setStarting(false); + } + }; + + return ( +
+
+
+
+

Build Tracker

+

Live 6-stage pipeline execution

+
+
+ {statusText} + +
+
+ +
+ + + + +
+ +
+
+ + +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/project-brief/project-brief-form.tsx b/src/components/project-brief/project-brief-form.tsx new file mode 100644 index 0000000..0d4f927 --- /dev/null +++ b/src/components/project-brief/project-brief-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { projectBriefSchema, type ProjectBriefInput } from "@/lib/validation/project-brief-schema"; +import { createProject } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select } from "@/components/ui/select"; + +const steps = ["Contact", "Project", "Review"]; + +export function ProjectBriefForm(): JSX.Element { + const [step, setStep] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(projectBriefSchema), + defaultValues: { + contactName: "", + email: "", + company: "", + projectName: "", + targetUsers: "", + goals: "", + scope: "", + constraints: "", + timeline: "2-4 weeks", + budgetRange: "$25k-$50k" + } + }); + + const onSubmit = async (values: ProjectBriefInput): Promise => { + try { + setIsSubmitting(true); + setError(""); + const project = await createProject(values); + router.push(`/tracker/${project.id}`); + } catch (submitError) { + setError("Unable to submit brief. Please retry."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
Step {step} of {steps.length}: {steps[step - 1]}
+ {step === 1 ? ( + <> + + + + + ) : null} + {step === 2 ? ( + <> + +