Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
74 changes: 73 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
};

module.exports = nextConfig;
41 changes: 41 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 6 additions & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
69 changes: 69 additions & 0 deletions src/app/api/projects/[projectId]/events/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<Uint8Array>({
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" }
});
}
}
38 changes: 38 additions & 0 deletions src/app/api/projects/[projectId]/pipeline/start/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
19 changes: 19 additions & 0 deletions src/app/api/projects/[projectId]/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
19 changes: 19 additions & 0 deletions src/app/api/projects/[projectId]/stats/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 20 additions & 0 deletions src/app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
16 changes: 16 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 19 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en" className={manrope.variable}>
<body className="font-sans">{children}</body>
</html>
);
}
23 changes: 23 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_#1e293b_0%,_#0b1020_45%,_#05070f_100%)] text-foreground">
<main className="mx-auto w-full max-w-7xl px-4 py-8 md:px-8 md:py-12">
<div className="grid gap-6 lg:grid-cols-[1.1fr_1fr]">
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl">
<h1 className="text-2xl font-bold tracking-tight">Start Your Forge Build</h1>
<p className="mt-2 text-sm text-slate-300">Tell us what to build in a guided brief.</p>
<div className="mt-6">
<StepProgress currentStep={1} totalSteps={3} />
</div>
</section>
<section className="rounded-2xl border border-white/20 bg-white/10 p-4 shadow-[0_10px_40px_-12px_rgba(2,6,23,0.85)] backdrop-blur-xl md:p-6">
<ProjectBriefForm />
</section>
</div>
</main>
</div>
);
}
15 changes: 15 additions & 0 deletions src/app/tracker/[projectId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <TrackerDashboard project={project} />;
}
Loading