feat: initial project scaffold
- Next.js 14 with App Router - Prisma schema for Pipeline and Stage models - Pipeline state machine (manager.ts) - API routes: health, pipelines CRUD, start - Landing page with pipeline visualization - Dark theme with Tailwind CSS - Environment setup with .env.example
This commit is contained in:
25
.env.example
Normal file
25
.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Environment variables
|
||||
# Copy to .env.local and fill in values
|
||||
|
||||
# Database (Vercel Postgres)
|
||||
DATABASE_URL="postgresql://..."
|
||||
|
||||
# Vercel Blob Storage
|
||||
BLOB_READ_WRITE_TOKEN="..."
|
||||
|
||||
# ElevenLabs (Voice synthesis)
|
||||
ELEVENLABS_API_KEY="..."
|
||||
ELEVENLABS_VOICE_ID="..."
|
||||
|
||||
# OpenAI (Script generation)
|
||||
OPENAI_API_KEY="..."
|
||||
|
||||
# Pexels (Stock footage)
|
||||
PEXELS_API_KEY="..."
|
||||
|
||||
# Base Chain (Token operations)
|
||||
BASE_RPC_URL="https://mainnet.base.org"
|
||||
WALLET_PRIVATE_KEY="..." # Only needed for token deployment
|
||||
|
||||
# Openwork (Hackathon API)
|
||||
OPENWORK_API_KEY="..."
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
8
next.config.js
Normal file
8
next.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client'],
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "cutroom",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.1",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "14.2.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
70
prisma/schema.prisma
Normal file
70
prisma/schema.prisma
Normal file
@@ -0,0 +1,70 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Pipeline {
|
||||
id String @id @default(cuid())
|
||||
topic String
|
||||
description String?
|
||||
status PipelineStatus @default(DRAFT)
|
||||
currentStage StageName @default(RESEARCH)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
stages Stage[]
|
||||
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Stage {
|
||||
id String @id @default(cuid())
|
||||
pipelineId String
|
||||
name StageName
|
||||
status StageStatus @default(PENDING)
|
||||
agentId String?
|
||||
agentName String?
|
||||
input Json?
|
||||
output Json?
|
||||
artifacts String[] @default([])
|
||||
error String?
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([pipelineId, name])
|
||||
@@index([status])
|
||||
@@index([agentId])
|
||||
}
|
||||
|
||||
enum PipelineStatus {
|
||||
DRAFT
|
||||
RUNNING
|
||||
COMPLETE
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum StageStatus {
|
||||
PENDING
|
||||
CLAIMED
|
||||
RUNNING
|
||||
COMPLETE
|
||||
FAILED
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
enum StageName {
|
||||
RESEARCH
|
||||
SCRIPT
|
||||
VOICE
|
||||
MUSIC
|
||||
VISUAL
|
||||
EDITOR
|
||||
PUBLISH
|
||||
}
|
||||
9
src/app/api/health/route.ts
Normal file
9
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
service: 'cutroom',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
27
src/app/api/pipelines/[id]/route.ts
Normal file
27
src/app/api/pipelines/[id]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPipeline, startPipeline } from '@/lib/pipeline/manager'
|
||||
|
||||
// GET /api/pipelines/[id] - Get pipeline detail
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const pipeline = await getPipeline(params.id)
|
||||
|
||||
if (!pipeline) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pipeline not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(pipeline)
|
||||
} catch (error) {
|
||||
console.error('Error getting pipeline:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get pipeline' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
src/app/api/pipelines/[id]/start/route.ts
Normal file
39
src/app/api/pipelines/[id]/start/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { startPipeline, getPipeline } from '@/lib/pipeline/manager'
|
||||
|
||||
// POST /api/pipelines/[id]/start - Start pipeline execution
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const existing = await getPipeline(params.id)
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pipeline not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing.status !== 'DRAFT') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pipeline already started' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipeline = await startPipeline(params.id)
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Pipeline started',
|
||||
pipeline
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error starting pipeline:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to start pipeline' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
src/app/api/pipelines/route.ts
Normal file
49
src/app/api/pipelines/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createPipeline, listPipelines } from '@/lib/pipeline/manager'
|
||||
|
||||
// GET /api/pipelines - List all pipelines
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const status = searchParams.get('status') as any
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
|
||||
const pipelines = await listPipelines(limit, status || undefined)
|
||||
|
||||
return NextResponse.json({
|
||||
pipelines,
|
||||
count: pipelines.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error listing pipelines:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list pipelines' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/pipelines - Create new pipeline
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { topic, description } = body
|
||||
|
||||
if (!topic) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Topic is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipeline = await createPipeline(topic, description)
|
||||
|
||||
return NextResponse.json(pipeline, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating pipeline:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create pipeline' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
src/app/globals.css
Normal file
32
src/app/globals.css
Normal file
@@ -0,0 +1,32 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground: 250 250 250;
|
||||
--background: 9 9 11;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground));
|
||||
background: rgb(var(--background));
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgb(39 39 42);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(63 63 70);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(82 82 91);
|
||||
}
|
||||
51
src/app/layout.tsx
Normal file
51
src/app/layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cutroom',
|
||||
description: 'Collaborative AI video production pipeline',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.className} bg-zinc-950 text-zinc-100 min-h-screen`}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<header className="border-b border-zinc-800 px-6 py-4">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl">🎬</span>
|
||||
<span className="font-bold text-xl">Cutroom</span>
|
||||
</a>
|
||||
<nav className="flex items-center gap-6">
|
||||
<a href="/pipelines" className="text-zinc-400 hover:text-zinc-100 transition-colors">
|
||||
Pipelines
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/openwork-hackathon/team-cutroom"
|
||||
target="_blank"
|
||||
className="text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t border-zinc-800 px-6 py-4 text-center text-zinc-500 text-sm">
|
||||
Built with 🦞 for the Openwork Clawathon
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
101
src/app/page.tsx
Normal file
101
src/app/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const STAGES = [
|
||||
{ name: 'Research', emoji: '🔍', desc: 'Find facts and sources' },
|
||||
{ name: 'Script', emoji: '📝', desc: 'Write the video script' },
|
||||
{ name: 'Voice', emoji: '🎙️', desc: 'Synthesize narration' },
|
||||
{ name: 'Music', emoji: '🎵', desc: 'Add background track' },
|
||||
{ name: 'Visual', emoji: '🎨', desc: 'Source b-roll and images' },
|
||||
{ name: 'Editor', emoji: '🎬', desc: 'Assemble final video' },
|
||||
{ name: 'Publish', emoji: '🚀', desc: 'Post to platforms' },
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold mb-4">
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-teal-400">
|
||||
Cutroom
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-400 mb-8">
|
||||
Collaborative AI video production pipeline
|
||||
</p>
|
||||
<p className="text-zinc-500 max-w-2xl mx-auto mb-8">
|
||||
Multiple specialized agents work together to create short-form video content.
|
||||
Each agent owns a stage — handoffs are structured, attribution is tracked,
|
||||
tokens are split on output.
|
||||
</p>
|
||||
<Link
|
||||
href="/pipelines"
|
||||
className="inline-flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-6 py-3 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
View Pipelines →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pipeline visualization */}
|
||||
<div className="mb-16">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center">How It Works</h2>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{STAGES.map((stage, i) => (
|
||||
<div key={stage.name} className="flex items-center gap-2">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 w-32 text-center">
|
||||
<div className="text-2xl mb-1">{stage.emoji}</div>
|
||||
<div className="font-medium text-sm">{stage.name}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{stage.desc}</div>
|
||||
</div>
|
||||
{i < STAGES.length - 1 && (
|
||||
<span className="text-zinc-600 text-xl">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-16">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<div className="text-2xl mb-2">🤝</div>
|
||||
<h3 className="font-bold mb-2">Multi-Agent Collaboration</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Different agents specialize in different stages. Research, writing,
|
||||
voice synthesis, editing — each handled by the best agent for the job.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<h3 className="font-bold mb-2">Attribution Tracking</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Every contribution is recorded. When videos generate value,
|
||||
tokens are split based on who did what.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<div className="text-2xl mb-2">⚡</div>
|
||||
<h3 className="font-bold mb-2">Structured Handoffs</h3>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
Each stage produces structured output for the next.
|
||||
No ambiguity, no dropped context, no miscommunication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center">
|
||||
<p className="text-zinc-500 mb-4">
|
||||
Built for the Openwork Clawathon
|
||||
</p>
|
||||
<a
|
||||
href="https://www.openwork.bot/hackathon"
|
||||
target="_blank"
|
||||
className="text-cyan-400 hover:text-cyan-300 underline"
|
||||
>
|
||||
Learn more about the hackathon →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/app/pipelines/page.tsx
Normal file
33
src/app/pipelines/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export default function PipelinesPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-12">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">Pipelines</h1>
|
||||
<button className="bg-cyan-600 hover:bg-cyan-500 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
+ New Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-12 text-center">
|
||||
<div className="text-4xl mb-4">🎬</div>
|
||||
<h2 className="text-xl font-bold mb-2">No pipelines yet</h2>
|
||||
<p className="text-zinc-500 mb-6">
|
||||
Create your first video production pipeline to get started.
|
||||
</p>
|
||||
<button className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 py-3 rounded-lg font-medium transition-colors">
|
||||
Create Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pipeline list (placeholder) */}
|
||||
{/*
|
||||
<div className="space-y-4">
|
||||
{pipelines.map(pipeline => (
|
||||
<PipelineCard key={pipeline.id} pipeline={pipeline} />
|
||||
))}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
src/lib/db/client.ts
Normal file
11
src/lib/db/client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
export default prisma
|
||||
213
src/lib/pipeline/manager.ts
Normal file
213
src/lib/pipeline/manager.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import prisma from '@/lib/db/client'
|
||||
import { PipelineStatus, StageStatus, StageName, Pipeline, Stage } from '@prisma/client'
|
||||
|
||||
// Stage execution order
|
||||
export const STAGE_ORDER: StageName[] = [
|
||||
'RESEARCH',
|
||||
'SCRIPT',
|
||||
'VOICE',
|
||||
'MUSIC',
|
||||
'VISUAL',
|
||||
'EDITOR',
|
||||
'PUBLISH'
|
||||
]
|
||||
|
||||
// Get next stage in order
|
||||
export function getNextStageName(current: StageName): StageName | null {
|
||||
const idx = STAGE_ORDER.indexOf(current)
|
||||
if (idx === -1 || idx === STAGE_ORDER.length - 1) return null
|
||||
return STAGE_ORDER[idx + 1]
|
||||
}
|
||||
|
||||
// Create a new pipeline with all stages
|
||||
export async function createPipeline(topic: string, description?: string): Promise<Pipeline & { stages: Stage[] }> {
|
||||
return prisma.pipeline.create({
|
||||
data: {
|
||||
topic,
|
||||
description,
|
||||
status: 'DRAFT',
|
||||
currentStage: 'RESEARCH',
|
||||
stages: {
|
||||
create: STAGE_ORDER.map(name => ({
|
||||
name,
|
||||
status: 'PENDING'
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { stages: true }
|
||||
})
|
||||
}
|
||||
|
||||
// Start pipeline execution
|
||||
export async function startPipeline(pipelineId: string): Promise<Pipeline> {
|
||||
return prisma.pipeline.update({
|
||||
where: { id: pipelineId },
|
||||
data: { status: 'RUNNING' }
|
||||
})
|
||||
}
|
||||
|
||||
// Claim a stage for an agent
|
||||
export async function claimStage(
|
||||
pipelineId: string,
|
||||
stageName: StageName,
|
||||
agentId: string,
|
||||
agentName: string
|
||||
): Promise<Stage> {
|
||||
// Verify pipeline is running
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id: pipelineId }
|
||||
})
|
||||
|
||||
if (!pipeline || pipeline.status !== 'RUNNING') {
|
||||
throw new Error('Pipeline not in running state')
|
||||
}
|
||||
|
||||
// Verify this is the current stage or previous is complete
|
||||
const stage = await prisma.stage.findUnique({
|
||||
where: { pipelineId_name: { pipelineId, name: stageName } }
|
||||
})
|
||||
|
||||
if (!stage) {
|
||||
throw new Error('Stage not found')
|
||||
}
|
||||
|
||||
if (stage.status !== 'PENDING') {
|
||||
throw new Error('Stage not available for claiming')
|
||||
}
|
||||
|
||||
// Check if previous stage is complete (if not first)
|
||||
const stageIdx = STAGE_ORDER.indexOf(stageName)
|
||||
if (stageIdx > 0) {
|
||||
const prevStageName = STAGE_ORDER[stageIdx - 1]
|
||||
const prevStage = await prisma.stage.findUnique({
|
||||
where: { pipelineId_name: { pipelineId, name: prevStageName } }
|
||||
})
|
||||
|
||||
if (!prevStage || (prevStage.status !== 'COMPLETE' && prevStage.status !== 'SKIPPED')) {
|
||||
throw new Error('Previous stage not complete')
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.stage.update({
|
||||
where: { id: stage.id },
|
||||
data: {
|
||||
status: 'CLAIMED',
|
||||
agentId,
|
||||
agentName,
|
||||
startedAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mark stage as running
|
||||
export async function startStage(stageId: string): Promise<Stage> {
|
||||
return prisma.stage.update({
|
||||
where: { id: stageId },
|
||||
data: { status: 'RUNNING' }
|
||||
})
|
||||
}
|
||||
|
||||
// Complete a stage with output
|
||||
export async function completeStage(
|
||||
stageId: string,
|
||||
output: unknown,
|
||||
artifacts: string[] = []
|
||||
): Promise<{ stage: Stage; pipeline: Pipeline }> {
|
||||
const stage = await prisma.stage.update({
|
||||
where: { id: stageId },
|
||||
data: {
|
||||
status: 'COMPLETE',
|
||||
output: output as any,
|
||||
artifacts,
|
||||
completedAt: new Date()
|
||||
},
|
||||
include: { pipeline: true }
|
||||
})
|
||||
|
||||
// Check if pipeline is complete
|
||||
const nextStageName = getNextStageName(stage.name)
|
||||
|
||||
let pipeline: Pipeline
|
||||
if (!nextStageName) {
|
||||
// This was the last stage
|
||||
pipeline = await prisma.pipeline.update({
|
||||
where: { id: stage.pipelineId },
|
||||
data: { status: 'COMPLETE' }
|
||||
})
|
||||
} else {
|
||||
// Update current stage pointer
|
||||
pipeline = await prisma.pipeline.update({
|
||||
where: { id: stage.pipelineId },
|
||||
data: { currentStage: nextStageName }
|
||||
})
|
||||
}
|
||||
|
||||
return { stage, pipeline }
|
||||
}
|
||||
|
||||
// Fail a stage
|
||||
export async function failStage(stageId: string, error: string): Promise<{ stage: Stage; pipeline: Pipeline }> {
|
||||
const stage = await prisma.stage.update({
|
||||
where: { id: stageId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
error,
|
||||
completedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
const pipeline = await prisma.pipeline.update({
|
||||
where: { id: stage.pipelineId },
|
||||
data: { status: 'FAILED' }
|
||||
})
|
||||
|
||||
return { stage, pipeline }
|
||||
}
|
||||
|
||||
// Get pipeline with all stages
|
||||
export async function getPipeline(pipelineId: string) {
|
||||
return prisma.pipeline.findUnique({
|
||||
where: { id: pipelineId },
|
||||
include: {
|
||||
stages: {
|
||||
orderBy: { createdAt: 'asc' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// List pipelines
|
||||
export async function listPipelines(limit = 20, status?: PipelineStatus) {
|
||||
return prisma.pipeline.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
include: { stages: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit
|
||||
})
|
||||
}
|
||||
|
||||
// Get available stages (for agents looking for work)
|
||||
export async function getAvailableStages() {
|
||||
// Find stages that are PENDING and whose previous stage is COMPLETE
|
||||
const runningPipelines = await prisma.pipeline.findMany({
|
||||
where: { status: 'RUNNING' },
|
||||
include: { stages: { orderBy: { createdAt: 'asc' } } }
|
||||
})
|
||||
|
||||
const available: (Stage & { pipeline: Pipeline })[] = []
|
||||
|
||||
for (const pipeline of runningPipelines) {
|
||||
for (let i = 0; i < pipeline.stages.length; i++) {
|
||||
const stage = pipeline.stages[i]
|
||||
|
||||
if (stage.status !== 'PENDING') continue
|
||||
|
||||
// Check if previous stage is complete (or this is first stage)
|
||||
if (i === 0 || ['COMPLETE', 'SKIPPED'].includes(pipeline.stages[i - 1].status)) {
|
||||
available.push({ ...stage, pipeline })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
18
tailwind.config.js
Normal file
18
tailwind.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'rgb(9, 9, 11)',
|
||||
foreground: 'rgb(250, 250, 250)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user