diff --git a/CLAUDE.md b/CLAUDE.md index 2aa3d33..ea41ef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Multi-agent workspace for orchestrating multiple AI coding agents working in par | CLI & Config | [docs/cli-config.md](docs/cli-config.md) | `src/cli/`, `src/config/` | | Dispatch & Events | [docs/dispatch-events.md](docs/dispatch-events.md) | `src/dispatch/`, `src/events/` | | Git, Process, Logging | [docs/git-process-logging.md](docs/git-process-logging.md) | `src/git/`, `src/process/`, `src/logger/`, `src/logging/` | +| Preview (Docker deployments) | [docs/preview.md](docs/preview.md) | `src/preview/` | | Testing | [docs/testing.md](docs/testing.md) | `src/test/` | | Database Migrations | [docs/database-migrations.md](docs/database-migrations.md) | `drizzle/` | | Logging Guide | [docs/logging.md](docs/logging.md) | `src/logger/` | diff --git a/docs/architecture.md b/docs/architecture.md index a6ea205..cd0ed0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,8 @@ CLI (cw) │ └── LifecycleController (retry, signal recovery) ├── DispatchManager (task queue, dependency resolution) ├── PhaseDispatchManager (phase queue, DAG ordering) - └── CoordinationManager (merge queue, conflict resolution) + ├── CoordinationManager (merge queue, conflict resolution) + └── PreviewManager (Docker-based preview deployments) Web UI (packages/web/) └── React 19 + TanStack Router + tRPC React Query @@ -66,6 +67,7 @@ Agent providers (Claude, Codex, etc.) are defined as configuration objects, not | Logging | `src/logger/`, `src/logging/` | Structured logging, file capture | [git-process-logging.md](git-process-logging.md) | | Events | `src/events/` | EventBus, typed event system | [dispatch-events.md](dispatch-events.md) | | Shared | `packages/shared/` | Types shared between frontend/backend | [frontend.md](frontend.md) | +| Preview | `src/preview/` | Docker-based preview deployments | [preview.md](preview.md) | | Tests | `src/test/` | E2E, integration, fixtures | [testing.md](testing.md) | ## Entity Relationships diff --git a/docs/cli-config.md b/docs/cli-config.md index 50bfbee..ec5f449 100644 --- a/docs/cli-config.md +++ b/docs/cli-config.md @@ -99,6 +99,14 @@ Uses **Commander.js** for command parsing. | `list` | List projects | | `delete ` | Delete project | +### Preview Deployments (`cw preview`) +| Command | Description | +|---------|-------------| +| `start --initiative --project --branch [--phase ]` | Start Docker preview | +| `stop ` | Stop and clean up preview | +| `list [--initiative ]` | List active previews | +| `status ` | Get preview status with service details | + ### Accounts (`cw account`) | Command | Description | |---------|-------------| diff --git a/docs/dispatch-events.md b/docs/dispatch-events.md index 0ab305d..20b6c61 100644 --- a/docs/dispatch-events.md +++ b/docs/dispatch-events.md @@ -11,7 +11,7 @@ - **Adapter**: `TypedEventBus` using Node.js `EventEmitter` - All events implement `BaseEvent { type, timestamp, payload }` -### Event Types (48) +### Event Types (52) | Category | Events | Count | |----------|--------|-------| @@ -24,6 +24,7 @@ | **Server** | `server:started`, `server:stopped` | 2 | | **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 | | **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 | +| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 | | **Log** | `log:entry` | 1 | ### Key Event Payloads diff --git a/docs/frontend.md b/docs/frontend.md index 74cf24a..f40c0c1 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -77,7 +77,10 @@ The initiative detail page has three tabs managed via local state (not URL param ### Review Components (`src/components/review/`) | Component | Purpose | |-----------|---------| -| `ReviewTab` | Review tab container | +| `ReviewTab` | Review tab container with diff viewer and preview integration | +| `ReviewSidebar` | Review info, actions, file list, comment summary | +| `DiffViewer` | Unified diff renderer with inline comments | +| `PreviewPanel` | Docker preview status: building/running/failed with start/stop | | `ProposalCard` | Individual proposal display | ### UI Primitives (`src/components/ui/`) diff --git a/docs/preview.md b/docs/preview.md new file mode 100644 index 0000000..2ce50be --- /dev/null +++ b/docs/preview.md @@ -0,0 +1,171 @@ +# Preview Deployments + +`src/preview/` — Docker-based preview deployments for reviewing changes in a running application. + +## Overview + +When a phase enters `pending_review`, reviewers can spin up the app at a specific branch in local Docker containers, accessible through a single port via a Caddy reverse proxy. + +**Key design decision: No database table.** Docker IS the source of truth. Instead of persisting rows, we query Docker directly via compose project names, container labels, and `docker compose` CLI commands. + +## Architecture + +``` +PreviewManager + ├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile) + ├── ComposeGenerator (generate docker-compose.yml + Caddyfile) + ├── DockerClient (thin wrapper around docker compose CLI) + ├── HealthChecker (poll service healthcheck endpoints) + └── PortAllocator (find next available port 9100-9200) +``` + +### Lifecycle + +1. **Start**: discover config → allocate port → generate compose + Caddyfile → `docker compose up --build -d` → health check → emit `preview:ready` +2. **Stop**: `docker compose down --volumes --remove-orphans` → clean up `.cw-previews//` → emit `preview:stopped` +3. **List**: `docker compose ls --filter name=cw-preview` → parse container labels → reconstruct status +4. **Shutdown**: `stopAll()` called on server shutdown to prevent orphaned containers + +### Docker Labels + +All preview containers get `cw.*` labels for metadata retrieval: + +| Label | Purpose | +|-------|---------| +| `cw.preview` | `"true"` — marker for filtering | +| `cw.initiative-id` | Initiative ID | +| `cw.phase-id` | Phase ID (optional) | +| `cw.project-id` | Project ID | +| `cw.branch` | Branch name | +| `cw.port` | Host port | +| `cw.preview-id` | Nanoid for this deployment | + +### Compose Project Naming + +Project names follow `cw-preview-` convention. This enables filtering via `docker compose ls --filter name=cw-preview`. + +## Configuration + +Preview configuration is discovered from the project directory in this order: + +### 1. `.cw-preview.yml` (explicit CW config) + +```yaml +version: 1 +services: + frontend: + build: + context: . + dockerfile: packages/web/Dockerfile + port: 3000 + route: / + healthcheck: + path: / + interval: 5s + retries: 10 + env: + VITE_API_URL: /api + + backend: + build: + context: . + dockerfile: packages/api/Dockerfile + port: 8080 + route: /api + healthcheck: + path: /health + env: + DATABASE_URL: postgres://db:5432/app + + db: + image: postgres:16-alpine + port: 5432 + internal: true # not exposed through proxy + env: + POSTGRES_PASSWORD: preview +``` + +### 2. `docker-compose.yml` / `compose.yml` (existing compose passthrough) + +If found, the existing compose file is wrapped with a Caddy sidecar. + +### 3. `Dockerfile` (single-service fallback) + +If only a Dockerfile exists, creates a single `app` service building from `.` with port 3000. + +## Reverse Proxy: Caddy + +Caddy runs as a container in the same Docker network. Only Caddy publishes a port to the host. Generated Caddyfile: + +``` +:80 { + handle_path /api/* { + reverse_proxy backend:8080 + } + handle { + reverse_proxy frontend:3000 + } +} +``` + +## Module Files + +| File | Purpose | +|------|---------| +| `types.ts` | PreviewConfig, PreviewStatus, labels, constants | +| `config-reader.ts` | Discovery + YAML parsing | +| `compose-generator.ts` | Docker Compose YAML + Caddyfile generation | +| `docker-client.ts` | Docker CLI wrapper (execa) | +| `health-checker.ts` | Service readiness polling | +| `port-allocator.ts` | Port 9100-9200 allocation with bind test | +| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll) | +| `index.ts` | Barrel exports | + +## Events + +| Event | Payload | +|-------|---------| +| `preview:building` | `{previewId, initiativeId, branch, port}` | +| `preview:ready` | `{previewId, initiativeId, branch, port, url}` | +| `preview:stopped` | `{previewId, initiativeId}` | +| `preview:failed` | `{previewId, initiativeId, error}` | + +## tRPC Procedures + +| Procedure | Type | Input | +|-----------|------|-------| +| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch}` | +| `stopPreview` | mutation | `{previewId}` | +| `listPreviews` | query | `{initiativeId?}` | +| `getPreviewStatus` | query | `{previewId}` | + +## CLI Commands + +``` +cw preview start --initiative --project --branch [--phase ] +cw preview stop +cw preview list [--initiative ] +cw preview status +``` + +## Frontend + +`PreviewPanel` component in the Review tab: +- **No preview**: "Start Preview" button +- **Building**: Spinner + "Building preview..." +- **Running**: Green dot + `http://localhost:` link + Stop button +- **Failed**: Error message + Retry button + +Polls `getPreviewStatus` with `refetchInterval: 3000` while active. + +## Container Wiring + +- `PreviewManager` instantiated in `src/container.ts` with `(projectRepository, eventBus, workspaceRoot)` +- Added to `Container` interface and `toContextDeps()` +- `GracefulShutdown` calls `previewManager.stopAll()` during shutdown +- `requirePreviewManager(ctx)` helper in `src/trpc/routers/_helpers.ts` + +## Dependencies + +- `js-yaml` + `@types/js-yaml` — for parsing `.cw-preview.yml` +- Docker must be installed and running on the host diff --git a/docs/server-api.md b/docs/server-api.md index a27daa3..7c11bab 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -199,3 +199,16 @@ Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 - **ConflictResolutionService**: creates resolution tasks for merge conflicts - Merge flow: queue → check deps → merge via WorktreeManager → handle conflicts - Events: `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted` + +## Preview Procedures + +Docker-based preview deployments. No database table — Docker is the source of truth. + +| Procedure | Type | Description | +|-----------|------|-------------| +| `startPreview` | mutation | Start preview: `{initiativeId, phaseId?, projectId, branch}` → PreviewStatus | +| `stopPreview` | mutation | Stop preview: `{previewId}` | +| `listPreviews` | query | List active previews: `{initiativeId?}` → PreviewStatus[] | +| `getPreviewStatus` | query | Get preview status: `{previewId}` → PreviewStatus | + +Context dependency: `requirePreviewManager(ctx)` — requires `PreviewManager` from container. diff --git a/package-lock.json b/package-lock.json index c2449d4..81b56b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "drizzle-orm": "^0.45.1", "execa": "^9.5.2", "gray-matter": "^4.0.3", + "js-yaml": "^4.1.1", "nanoid": "^5.1.6", "pino": "^10.3.0", "simple-git": "^3.30.0", @@ -34,6 +35,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.7", "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3", @@ -3559,6 +3561,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -5423,6 +5432,28 @@ "node": ">=6.0" } }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/happy-dom": { "version": "20.5.0", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.0.tgz", @@ -5652,27 +5683,17 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, - "node_modules/js-yaml/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", diff --git a/package.json b/package.json index 44d4392..ab5f2e8 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "drizzle-orm": "^0.45.1", "execa": "^9.5.2", "gray-matter": "^4.0.3", + "js-yaml": "^4.1.1", "nanoid": "^5.1.6", "pino": "^10.3.0", "simple-git": "^3.30.0", @@ -47,6 +48,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.7", "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3", diff --git a/packages/web/src/components/review/PreviewPanel.tsx b/packages/web/src/components/review/PreviewPanel.tsx new file mode 100644 index 0000000..4aedf09 --- /dev/null +++ b/packages/web/src/components/review/PreviewPanel.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { + Loader2, + ExternalLink, + Square, + RotateCcw, + CircleDot, + CircleX, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +interface PreviewPanelProps { + initiativeId: string; + phaseId?: string; + projectId: string; + branch: string; +} + +export function PreviewPanel({ + initiativeId, + phaseId, + projectId, + branch, +}: PreviewPanelProps) { + const [activePreviewId, setActivePreviewId] = useState(null); + + // Check for existing previews for this initiative + const previewsQuery = trpc.listPreviews.useQuery( + { initiativeId }, + { refetchInterval: activePreviewId ? 3000 : false }, + ); + + const existingPreview = previewsQuery.data?.find( + (p) => p.phaseId === phaseId || (!phaseId && p.initiativeId === initiativeId), + ); + + const previewStatusQuery = trpc.getPreviewStatus.useQuery( + { previewId: activePreviewId ?? existingPreview?.id ?? "" }, + { + enabled: !!(activePreviewId ?? existingPreview?.id), + refetchInterval: 3000, + }, + ); + + const preview = previewStatusQuery.data ?? existingPreview; + + const startMutation = trpc.startPreview.useMutation({ + onSuccess: (data) => { + setActivePreviewId(data.id); + toast.success(`Preview running at http://localhost:${data.port}`); + }, + onError: (err) => { + toast.error(`Preview failed: ${err.message}`); + }, + }); + + const stopMutation = trpc.stopPreview.useMutation({ + onSuccess: () => { + setActivePreviewId(null); + toast.success("Preview stopped"); + previewsQuery.refetch(); + }, + onError: (err) => { + toast.error(`Failed to stop preview: ${err.message}`); + }, + }); + + const handleStart = () => { + startMutation.mutate({ initiativeId, phaseId, projectId, branch }); + }; + + const handleStop = () => { + const id = activePreviewId ?? existingPreview?.id; + if (id) { + stopMutation.mutate({ previewId: id }); + } + }; + + // Building state + if (startMutation.isPending) { + return ( +
+ +
+

+ Building preview... +

+

+ Building containers and starting services +

+
+
+ ); + } + + // Running state + if (preview && (preview.status === "running" || preview.status === "building")) { + const url = `http://localhost:${preview.port}`; + const isBuilding = preview.status === "building"; + + return ( +
+ {isBuilding ? ( + + ) : ( + + )} +
+

+ {isBuilding ? "Building..." : "Preview running"} +

+ {!isBuilding && ( + + {url} + + + )} +
+ +
+ ); + } + + // Failed state + if (preview && preview.status === "failed") { + return ( +
+ +
+

+ Preview failed +

+
+ +
+ ); + } + + // No preview — show start button + return ( + + ); +} diff --git a/packages/web/src/components/review/ReviewTab.tsx b/packages/web/src/components/review/ReviewTab.tsx index a07924f..daac08c 100644 --- a/packages/web/src/components/review/ReviewTab.tsx +++ b/packages/web/src/components/review/ReviewTab.tsx @@ -4,6 +4,7 @@ import { trpc } from "@/lib/trpc"; import { parseUnifiedDiff } from "./parse-diff"; import { DiffViewer } from "./DiffViewer"; import { ReviewSidebar } from "./ReviewSidebar"; +import { PreviewPanel } from "./PreviewPanel"; import type { ReviewComment, ReviewStatus, DiffLine } from "./types"; interface ReviewTabProps { @@ -26,6 +27,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const [selectedPhaseId, setSelectedPhaseId] = useState(null); const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null; + // Fetch projects for this initiative (needed for preview) + const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId }); + const firstProjectId = projectsQuery.data?.[0]?.id ?? null; + // Fetch diff for active phase const diffQuery = trpc.getPhaseReviewDiff.useQuery( { phaseId: activePhaseId! }, @@ -106,6 +111,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { } const activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase"; + const sourceBranch = diffQuery.data?.sourceBranch ?? ""; return (
@@ -128,6 +134,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
)} + {/* Preview panel */} + {firstProjectId && sourceBranch && ( + + )} + {diffQuery.isLoading ? (
Loading diff... @@ -165,7 +181,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`} author="system" status={status} - sourceBranch={diffQuery.data?.sourceBranch ?? ""} + sourceBranch={sourceBranch} targetBranch={diffQuery.data?.targetBranch ?? ""} files={files} comments={comments} diff --git a/src/cli/index.ts b/src/cli/index.ts index 3f7b476..f5cb26b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -48,7 +48,7 @@ async function startServer(port?: number, debug?: boolean): Promise { } // Install graceful shutdown handlers - const shutdown = new GracefulShutdown(server, container.processManager, container.logManager); + const shutdown = new GracefulShutdown(server, container.processManager, container.logManager, container.previewManager); shutdown.install(); } @@ -1259,6 +1259,109 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // Preview command group + const previewCommand = program + .command('preview') + .description('Manage Docker-based preview deployments'); + + // cw preview start --initiative --project --branch [--phase ] + previewCommand + .command('start') + .description('Start a preview deployment') + .requiredOption('--initiative ', 'Initiative ID') + .requiredOption('--project ', 'Project ID') + .requiredOption('--branch ', 'Branch to deploy') + .option('--phase ', 'Phase ID') + .action(async (options: { initiative: string; project: string; branch: string; phase?: string }) => { + try { + const client = createDefaultTrpcClient(); + console.log('Starting preview deployment...'); + const preview = await client.startPreview.mutate({ + initiativeId: options.initiative, + projectId: options.project, + branch: options.branch, + phaseId: options.phase, + }); + console.log(`Preview started: ${preview.id}`); + console.log(` URL: http://localhost:${preview.port}`); + console.log(` Branch: ${preview.branch}`); + console.log(` Status: ${preview.status}`); + console.log(` Services: ${preview.services.map(s => `${s.name} (${s.state})`).join(', ')}`); + } catch (error) { + console.error('Failed to start preview:', (error as Error).message); + process.exit(1); + } + }); + + // cw preview stop + previewCommand + .command('stop ') + .description('Stop a preview deployment') + .action(async (previewId: string) => { + try { + const client = createDefaultTrpcClient(); + await client.stopPreview.mutate({ previewId }); + console.log(`Preview '${previewId}' stopped`); + } catch (error) { + console.error('Failed to stop preview:', (error as Error).message); + process.exit(1); + } + }); + + // cw preview list [--initiative ] + previewCommand + .command('list') + .description('List active preview deployments') + .option('--initiative ', 'Filter by initiative ID') + .action(async (options: { initiative?: string }) => { + try { + const client = createDefaultTrpcClient(); + const previews = await client.listPreviews.query( + options.initiative ? { initiativeId: options.initiative } : undefined, + ); + if (previews.length === 0) { + console.log('No active previews'); + return; + } + for (const p of previews) { + console.log(`${p.id} http://localhost:${p.port} ${p.branch} [${p.status.toUpperCase()}]`); + } + } catch (error) { + console.error('Failed to list previews:', (error as Error).message); + process.exit(1); + } + }); + + // cw preview status + previewCommand + .command('status ') + .description('Get preview deployment status') + .action(async (previewId: string) => { + try { + const client = createDefaultTrpcClient(); + const preview = await client.getPreviewStatus.query({ previewId }); + if (!preview) { + console.log(`Preview '${previewId}' not found`); + return; + } + console.log(`Preview: ${preview.id}`); + console.log(` URL: http://localhost:${preview.port}`); + console.log(` Branch: ${preview.branch}`); + console.log(` Status: ${preview.status}`); + console.log(` Initiative: ${preview.initiativeId}`); + console.log(` Project: ${preview.projectId}`); + if (preview.services.length > 0) { + console.log(' Services:'); + for (const svc of preview.services) { + console.log(` ${svc.name}: ${svc.state} (health: ${svc.health})`); + } + } + } catch (error) { + console.error('Failed to get preview status:', (error as Error).message); + process.exit(1); + } + }); + return program; } diff --git a/src/container.ts b/src/container.ts index c0adbd7..5012b2a 100644 --- a/src/container.ts +++ b/src/container.ts @@ -44,6 +44,7 @@ import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js'; import type { BranchManager } from './git/branch-manager.js'; import { ExecutionOrchestrator } from './execution/orchestrator.js'; import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js'; +import { PreviewManager } from './preview/index.js'; import { findWorkspaceRoot } from './config/index.js'; import { createModuleLogger } from './logger/index.js'; import type { ServerContextDeps } from './server/index.js'; @@ -106,6 +107,7 @@ export interface Container extends Repositories { phaseDispatchManager: PhaseDispatchManager; branchManager: BranchManager; executionOrchestrator: ExecutionOrchestrator; + previewManager: PreviewManager; /** Extract the subset of deps that CoordinationServer needs. */ toContextDeps(): ServerContextDeps; @@ -220,6 +222,14 @@ export async function createContainer(options?: ContainerOptions): Promise { + const baseOpts = { + projectPath: '/workspace/repos/my-project-abc123', + port: 9100, + deploymentId: 'test123', + labels: { + 'cw.preview': 'true', + 'cw.initiative-id': 'init-1', + 'cw.port': '9100', + }, + }; + + it('generates valid compose YAML with user services and Caddy proxy', () => { + const config: PreviewConfig = { + version: 1, + services: { + app: { + name: 'app', + build: '.', + port: 3000, + }, + }, + }; + + const result = generateComposeFile(config, baseOpts); + const parsed = yaml.load(result) as Record; + + // Has both user service and caddy + expect(parsed.services.app).toBeDefined(); + expect(parsed.services['caddy-proxy']).toBeDefined(); + + // Network present + expect(parsed.networks.preview).toBeDefined(); + + // Caddy publishes port + expect(parsed.services['caddy-proxy'].ports).toContain('9100:80'); + + // Labels propagated + expect(parsed.services.app.labels['cw.preview']).toBe('true'); + }); + + it('handles object build config with context path joining', () => { + const config: PreviewConfig = { + version: 1, + services: { + api: { + name: 'api', + build: { context: 'packages/api', dockerfile: 'Dockerfile.prod' }, + port: 8080, + }, + }, + }; + + const result = generateComposeFile(config, baseOpts); + const parsed = yaml.load(result) as Record; + + expect(parsed.services.api.build.context).toBe( + '/workspace/repos/my-project-abc123/packages/api', + ); + expect(parsed.services.api.build.dockerfile).toBe('Dockerfile.prod'); + }); + + it('handles image-based services', () => { + const config: PreviewConfig = { + version: 1, + services: { + db: { + name: 'db', + image: 'postgres:16', + port: 5432, + internal: true, + env: { POSTGRES_PASSWORD: 'test' }, + }, + }, + }; + + const result = generateComposeFile(config, baseOpts); + const parsed = yaml.load(result) as Record; + + expect(parsed.services.db.image).toBe('postgres:16'); + expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test'); + }); + + it('caddy depends on all user services', () => { + const config: PreviewConfig = { + version: 1, + services: { + frontend: { name: 'frontend', build: '.', port: 3000 }, + backend: { name: 'backend', build: '.', port: 8080 }, + }, + }; + + const result = generateComposeFile(config, baseOpts); + const parsed = yaml.load(result) as Record; + + expect(parsed.services['caddy-proxy'].depends_on).toContain('frontend'); + expect(parsed.services['caddy-proxy'].depends_on).toContain('backend'); + }); +}); + +describe('generateCaddyfile', () => { + it('generates simple single-service Caddyfile', () => { + const config: PreviewConfig = { + version: 1, + services: { + app: { name: 'app', build: '.', port: 3000 }, + }, + }; + + const caddyfile = generateCaddyfile(config); + expect(caddyfile).toContain(':80 {'); + expect(caddyfile).toContain('reverse_proxy app:3000'); + expect(caddyfile).toContain('}'); + }); + + it('generates multi-service Caddyfile with handle_path for non-root routes', () => { + const config: PreviewConfig = { + version: 1, + services: { + frontend: { name: 'frontend', build: '.', port: 3000, route: '/' }, + backend: { name: 'backend', build: '.', port: 8080, route: '/api' }, + }, + }; + + const caddyfile = generateCaddyfile(config); + expect(caddyfile).toContain('handle_path /api/*'); + expect(caddyfile).toContain('reverse_proxy backend:8080'); + expect(caddyfile).toContain('handle {'); + expect(caddyfile).toContain('reverse_proxy frontend:3000'); + }); + + it('excludes internal services from Caddyfile', () => { + const config: PreviewConfig = { + version: 1, + services: { + app: { name: 'app', build: '.', port: 3000 }, + db: { name: 'db', image: 'postgres', port: 5432, internal: true }, + }, + }; + + const caddyfile = generateCaddyfile(config); + expect(caddyfile).not.toContain('postgres'); + expect(caddyfile).not.toContain('db:5432'); + }); + + it('sorts routes by specificity (longer paths first)', () => { + const config: PreviewConfig = { + version: 1, + services: { + app: { name: 'app', build: '.', port: 3000, route: '/' }, + api: { name: 'api', build: '.', port: 8080, route: '/api' }, + auth: { name: 'auth', build: '.', port: 9090, route: '/api/auth' }, + }, + }; + + const caddyfile = generateCaddyfile(config); + const apiAuthIdx = caddyfile.indexOf('/api/auth'); + const apiIdx = caddyfile.indexOf('handle_path /api/*'); + const handleIdx = caddyfile.indexOf('handle {'); + + // /api/auth should come before /api which should come before / + expect(apiAuthIdx).toBeLessThan(apiIdx); + expect(apiIdx).toBeLessThan(handleIdx); + }); +}); + +describe('generateLabels', () => { + it('generates correct labels', () => { + const labels = generateLabels({ + initiativeId: 'init-1', + phaseId: 'phase-1', + projectId: 'proj-1', + branch: 'feature/test', + port: 9100, + previewId: 'abc123', + }); + + expect(labels['cw.preview']).toBe('true'); + expect(labels['cw.initiative-id']).toBe('init-1'); + expect(labels['cw.phase-id']).toBe('phase-1'); + expect(labels['cw.project-id']).toBe('proj-1'); + expect(labels['cw.branch']).toBe('feature/test'); + expect(labels['cw.port']).toBe('9100'); + expect(labels['cw.preview-id']).toBe('abc123'); + }); + + it('omits phaseId label when not provided', () => { + const labels = generateLabels({ + initiativeId: 'init-1', + projectId: 'proj-1', + branch: 'main', + port: 9100, + previewId: 'abc123', + }); + + expect(labels['cw.phase-id']).toBeUndefined(); + }); +}); diff --git a/src/preview/compose-generator.ts b/src/preview/compose-generator.ts new file mode 100644 index 0000000..6db2e6c --- /dev/null +++ b/src/preview/compose-generator.ts @@ -0,0 +1,191 @@ +/** + * Docker Compose Generator + * + * Generates docker-compose.preview.yml and Caddyfile for preview deployments. + * All services share a Docker network; only Caddy publishes a host port. + */ + +import yaml from 'js-yaml'; +import type { PreviewConfig, PreviewServiceConfig } from './types.js'; +import { PREVIEW_LABELS } from './types.js'; + +export interface ComposeGeneratorOptions { + projectPath: string; + port: number; + deploymentId: string; + labels: Record; +} + +interface ComposeService { + build?: { context: string; dockerfile: string } | string; + image?: string; + environment?: Record; + volumes?: string[]; + labels?: Record; + networks?: string[]; + depends_on?: string[]; +} + +interface ComposeFile { + services: Record; + networks: Record; +} + +/** + * Generate a Docker Compose YAML string for the preview deployment. + * + * Structure: + * - User-defined services with build contexts + * - Caddy reverse proxy publishing the single host port + * - Shared `preview` network + */ +export function generateComposeFile( + config: PreviewConfig, + opts: ComposeGeneratorOptions, +): string { + const compose: ComposeFile = { + services: {}, + networks: { + preview: { driver: 'bridge' }, + }, + }; + + const serviceNames: string[] = []; + + // Add user-defined services + for (const [name, svc] of Object.entries(config.services)) { + serviceNames.push(name); + const service: ComposeService = { + labels: { ...opts.labels }, + networks: ['preview'], + }; + + // Build config + if (svc.build) { + if (typeof svc.build === 'string') { + service.build = { + context: opts.projectPath, + dockerfile: svc.build === '.' ? 'Dockerfile' : svc.build, + }; + } else { + service.build = { + context: svc.build.context.startsWith('/') + ? svc.build.context + : `${opts.projectPath}/${svc.build.context}`, + dockerfile: svc.build.dockerfile, + }; + } + } else if (svc.image) { + service.image = svc.image; + } + + // Environment + if (svc.env && Object.keys(svc.env).length > 0) { + service.environment = svc.env; + } + + // Volumes + if (svc.volumes && svc.volumes.length > 0) { + service.volumes = svc.volumes; + } + + compose.services[name] = service; + } + + // Generate and add Caddy proxy service + const caddyfile = generateCaddyfile(config); + const caddyService: ComposeService = { + image: 'caddy:2-alpine', + networks: ['preview'], + labels: { ...opts.labels }, + }; + + // Caddy publishes the single host port + (caddyService as Record).ports = [`${opts.port}:80`]; + + // Mount Caddyfile via inline config + (caddyService as Record).command = ['caddy', 'run', '--config', '/etc/caddy/Caddyfile']; + + // Caddy config will be written to the deployment directory and mounted + (caddyService as Record).volumes = ['./Caddyfile:/etc/caddy/Caddyfile:ro']; + + if (serviceNames.length > 0) { + caddyService.depends_on = serviceNames; + } + + compose.services['caddy-proxy'] = caddyService; + + return yaml.dump(compose, { lineWidth: 120, noRefs: true }); +} + +/** + * Generate a Caddyfile from route mappings in the preview config. + * + * Routes are sorted by specificity (longest path first) to ensure + * more specific routes match before catch-all. + */ +export function generateCaddyfile(config: PreviewConfig): string { + const routes: Array<{ name: string; route: string; port: number }> = []; + + for (const [name, svc] of Object.entries(config.services)) { + if (svc.internal) continue; + routes.push({ + name, + route: svc.route ?? '/', + port: svc.port, + }); + } + + // Sort by route specificity (longer paths first, root last) + routes.sort((a, b) => { + if (a.route === '/') return 1; + if (b.route === '/') return -1; + return b.route.length - a.route.length; + }); + + const lines: string[] = [':80 {']; + + for (const route of routes) { + if (route.route === '/') { + lines.push(` handle {`); + lines.push(` reverse_proxy ${route.name}:${route.port}`); + lines.push(` }`); + } else { + // Strip trailing slash for handle_path + const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; + lines.push(` handle_path ${path}/* {`); + lines.push(` reverse_proxy ${route.name}:${route.port}`); + lines.push(` }`); + } + } + + lines.push('}'); + return lines.join('\n'); +} + +/** + * Generate compose labels for a preview deployment. + */ +export function generateLabels(opts: { + initiativeId: string; + phaseId?: string; + projectId: string; + branch: string; + port: number; + previewId: string; +}): Record { + const labels: Record = { + [PREVIEW_LABELS.preview]: 'true', + [PREVIEW_LABELS.initiativeId]: opts.initiativeId, + [PREVIEW_LABELS.projectId]: opts.projectId, + [PREVIEW_LABELS.branch]: opts.branch, + [PREVIEW_LABELS.port]: String(opts.port), + [PREVIEW_LABELS.previewId]: opts.previewId, + }; + + if (opts.phaseId) { + labels[PREVIEW_LABELS.phaseId] = opts.phaseId; + } + + return labels; +} diff --git a/src/preview/config-reader.test.ts b/src/preview/config-reader.test.ts new file mode 100644 index 0000000..6454a55 --- /dev/null +++ b/src/preview/config-reader.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { parseCwPreviewConfig } from './config-reader.js'; + +describe('parseCwPreviewConfig', () => { + it('parses minimal single-service config', () => { + const raw = ` +version: 1 +services: + app: + build: "." + port: 3000 +`; + const config = parseCwPreviewConfig(raw); + expect(config.version).toBe(1); + expect(Object.keys(config.services)).toEqual(['app']); + expect(config.services.app.port).toBe(3000); + expect(config.services.app.build).toBe('.'); + }); + + it('parses multi-service config with routes and healthchecks', () => { + const raw = ` +version: 1 +services: + frontend: + build: + context: "." + dockerfile: packages/web/Dockerfile + port: 3000 + route: / + healthcheck: + path: / + interval: 5s + retries: 10 + env: + VITE_API_URL: /api + backend: + build: + context: "." + dockerfile: packages/api/Dockerfile + port: 8080 + route: /api + healthcheck: + path: /health + env: + DATABASE_URL: "postgres://db:5432/app" + db: + image: postgres:16-alpine + port: 5432 + internal: true + env: + POSTGRES_PASSWORD: preview +`; + const config = parseCwPreviewConfig(raw); + + expect(Object.keys(config.services)).toHaveLength(3); + + // Frontend + expect(config.services.frontend.port).toBe(3000); + expect(config.services.frontend.route).toBe('/'); + expect(config.services.frontend.healthcheck?.path).toBe('/'); + expect(config.services.frontend.healthcheck?.retries).toBe(10); + expect(config.services.frontend.env?.VITE_API_URL).toBe('/api'); + expect(config.services.frontend.build).toEqual({ + context: '.', + dockerfile: 'packages/web/Dockerfile', + }); + + // Backend + expect(config.services.backend.port).toBe(8080); + expect(config.services.backend.route).toBe('/api'); + + // DB (internal) + expect(config.services.db.internal).toBe(true); + expect(config.services.db.image).toBe('postgres:16-alpine'); + }); + + it('rejects config without services', () => { + expect(() => parseCwPreviewConfig('version: 1\n')).toThrow('missing "services"'); + }); + + it('rejects service without port (unless internal)', () => { + const raw = ` +version: 1 +services: + app: + build: "." +`; + expect(() => parseCwPreviewConfig(raw)).toThrow('must specify a "port"'); + }); + + it('allows internal service without port', () => { + const raw = ` +version: 1 +services: + redis: + image: redis:7 + internal: true +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.redis.internal).toBe(true); + expect(config.services.redis.port).toBe(0); + }); + + it('normalizes string build to string', () => { + const raw = ` +version: 1 +services: + app: + build: "./app" + port: 3000 +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.app.build).toBe('./app'); + }); +}); diff --git a/src/preview/config-reader.ts b/src/preview/config-reader.ts new file mode 100644 index 0000000..5d60aee --- /dev/null +++ b/src/preview/config-reader.ts @@ -0,0 +1,164 @@ +/** + * Preview Config Reader + * + * Discovers and parses preview configuration from a project directory. + * Discovery order: .cw-preview.yml → docker-compose.yml/compose.yml → Dockerfile + */ + +import { readFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import yaml from 'js-yaml'; +import type { PreviewConfig, PreviewServiceConfig } from './types.js'; +import { createModuleLogger } from '../logger/index.js'; + +const log = createModuleLogger('preview:config'); + +/** Files to check for existing Docker Compose config */ +const COMPOSE_FILES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', +]; + +/** + * Discover and parse preview configuration from a project directory. + * + * Discovery order: + * 1. `.cw-preview.yml` — explicit CW preview config + * 2. `docker-compose.yml` / `compose.yml` (+ variants) — wrap existing compose + * 3. `Dockerfile` at root — single-service fallback (assumes port 3000) + * + * @param projectPath - Absolute path to the project directory (at the target branch) + * @returns Parsed and normalized PreviewConfig + * @throws If no config can be discovered + */ +export async function discoverConfig(projectPath: string): Promise { + // 1. Check for explicit .cw-preview.yml + const cwPreviewPath = join(projectPath, '.cw-preview.yml'); + if (await fileExists(cwPreviewPath)) { + log.info({ path: cwPreviewPath }, 'found .cw-preview.yml'); + const raw = await readFile(cwPreviewPath, 'utf-8'); + return parseCwPreviewConfig(raw); + } + + // 2. Check for existing compose files + for (const composeFile of COMPOSE_FILES) { + const composePath = join(projectPath, composeFile); + if (await fileExists(composePath)) { + log.info({ path: composePath }, 'found existing compose file'); + return parseExistingCompose(composePath, composeFile); + } + } + + // 3. Check for Dockerfile + const dockerfilePath = join(projectPath, 'Dockerfile'); + if (await fileExists(dockerfilePath)) { + log.info({ path: dockerfilePath }, 'found Dockerfile, using single-service fallback'); + return createDockerfileFallback(); + } + + throw new Error( + `No preview configuration found in ${projectPath}. ` + + `Expected one of: .cw-preview.yml, docker-compose.yml, compose.yml, or Dockerfile` + ); +} + +/** + * Parse a `.cw-preview.yml` file into a PreviewConfig. + */ +export function parseCwPreviewConfig(raw: string): PreviewConfig { + const parsed = yaml.load(raw) as Record; + + if (!parsed || typeof parsed !== 'object') { + throw new Error('Invalid .cw-preview.yml: expected a YAML object'); + } + + if (!parsed.services || typeof parsed.services !== 'object') { + throw new Error('Invalid .cw-preview.yml: missing "services" key'); + } + + const services: Record = {}; + const rawServices = parsed.services as Record>; + + for (const [name, svc] of Object.entries(rawServices)) { + if (!svc || typeof svc !== 'object') { + throw new Error(`Invalid service "${name}": expected an object`); + } + + const port = svc.port as number | undefined; + if (port === undefined && !svc.internal) { + throw new Error(`Service "${name}" must specify a "port" (or be marked "internal: true")`); + } + + services[name] = { + name, + port: port ?? 0, + ...(svc.build !== undefined && { build: normalizeBuild(svc.build) }), + ...(svc.image !== undefined && { image: svc.image as string }), + ...(svc.route !== undefined && { route: svc.route as string }), + ...(svc.internal !== undefined && { internal: svc.internal as boolean }), + ...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }), + ...(svc.env !== undefined && { env: svc.env as Record }), + ...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }), + }; + } + + return { + version: 1, + services, + }; +} + +/** + * Wrap an existing Docker Compose file as a passthrough config. + */ +function parseExistingCompose(composePath: string, composeFile: string): PreviewConfig { + return { + version: 1, + compose: composeFile, + services: {}, + }; +} + +/** + * Create a single-service fallback config from a Dockerfile. + */ +function createDockerfileFallback(): PreviewConfig { + return { + version: 1, + services: { + app: { + name: 'app', + build: '.', + port: 3000, + }, + }, + }; +} + +/** + * Normalize build config to a consistent format. + */ +function normalizeBuild(build: unknown): PreviewServiceConfig['build'] { + if (typeof build === 'string') { + return build; + } + if (typeof build === 'object' && build !== null) { + const b = build as Record; + return { + context: (b.context as string) ?? '.', + dockerfile: (b.dockerfile as string) ?? 'Dockerfile', + }; + } + return '.'; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/src/preview/docker-client.ts b/src/preview/docker-client.ts new file mode 100644 index 0000000..447665c --- /dev/null +++ b/src/preview/docker-client.ts @@ -0,0 +1,206 @@ +/** + * Docker Client + * + * Thin wrapper around Docker CLI via execa for preview lifecycle management. + * No SDK dependency — uses `docker compose` subprocess calls. + */ + +import { execa } from 'execa'; +import { createModuleLogger } from '../logger/index.js'; +import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js'; + +const log = createModuleLogger('preview:docker'); + +/** + * Service status from `docker compose ps`. + */ +export interface ServiceStatus { + name: string; + state: string; + health: string; +} + +/** + * Compose project from `docker compose ls`. + */ +export interface ComposeProject { + Name: string; + Status: string; + ConfigFiles: string; +} + +/** + * Check if Docker is available and running. + */ +export async function isDockerAvailable(): Promise { + try { + await execa('docker', ['info'], { timeout: 10000 }); + return true; + } catch { + return false; + } +} + +/** + * Start a compose project (build and run in background). + */ +export async function composeUp(composePath: string, projectName: string): Promise { + log.info({ composePath, projectName }, 'starting compose project'); + const cwd = composePath.substring(0, composePath.lastIndexOf('/')); + + await execa('docker', [ + 'compose', + '-p', projectName, + '-f', composePath, + 'up', + '--build', + '-d', + ], { + cwd, + timeout: 600000, // 10 minutes for builds + }); +} + +/** + * Stop and remove a compose project. + */ +export async function composeDown(projectName: string): Promise { + log.info({ projectName }, 'stopping compose project'); + await execa('docker', [ + 'compose', + '-p', projectName, + 'down', + '--volumes', + '--remove-orphans', + ], { + timeout: 60000, + }); +} + +/** + * Get service statuses for a compose project. + */ +export async function composePs(projectName: string): Promise { + try { + const result = await execa('docker', [ + 'compose', + '-p', projectName, + 'ps', + '--format', 'json', + ], { + timeout: 15000, + }); + + if (!result.stdout.trim()) { + return []; + } + + // docker compose ps --format json outputs one JSON object per line + const lines = result.stdout.trim().split('\n'); + return lines.map((line) => { + const container = JSON.parse(line); + return { + name: container.Service || container.Name || '', + state: container.State || 'unknown', + health: container.Health || 'none', + }; + }); + } catch (error) { + log.warn({ projectName, err: error }, 'failed to get compose ps'); + return []; + } +} + +/** + * List all preview compose projects. + */ +export async function listPreviewProjects(): Promise { + try { + const result = await execa('docker', [ + 'compose', + 'ls', + '--filter', `name=${COMPOSE_PROJECT_PREFIX}`, + '--format', 'json', + ], { + timeout: 15000, + }); + + if (!result.stdout.trim()) { + return []; + } + + return JSON.parse(result.stdout); + } catch (error) { + log.warn({ err: error }, 'failed to list preview projects'); + return []; + } +} + +/** + * Get container labels for a compose project. + * Returns labels from the first container that has cw.preview=true. + */ +export async function getContainerLabels(projectName: string): Promise> { + try { + const result = await execa('docker', [ + 'ps', + '--filter', `label=${PREVIEW_LABELS.preview}=true`, + '--filter', `label=com.docker.compose.project=${projectName}`, + '--format', '{{json .Labels}}', + ], { + timeout: 15000, + }); + + if (!result.stdout.trim()) { + return {}; + } + + // Parse the first line's label string: "key=val,key=val,..." + const firstLine = result.stdout.trim().split('\n')[0]; + const labelStr = firstLine.replace(/^"|"$/g, ''); + const labels: Record = {}; + + for (const pair of labelStr.split(',')) { + const eqIdx = pair.indexOf('='); + if (eqIdx > 0) { + const key = pair.substring(0, eqIdx); + const value = pair.substring(eqIdx + 1); + if (key.startsWith('cw.')) { + labels[key] = value; + } + } + } + + return labels; + } catch (error) { + log.warn({ projectName, err: error }, 'failed to get container labels'); + return {}; + } +} + +/** + * Get the ports of running preview containers by reading their cw.port labels. + */ +export async function getPreviewPorts(): Promise { + try { + const result = await execa('docker', [ + 'ps', + '--filter', `label=${PREVIEW_LABELS.preview}=true`, + '--format', `{{.Label "${PREVIEW_LABELS.port}"}}`, + ], { + timeout: 15000, + }); + + if (!result.stdout.trim()) { + return []; + } + + return result.stdout + .trim() + .split('\n') + .map((s) => parseInt(s, 10)) + .filter((n) => !isNaN(n)); + } catch { + return []; + } +} diff --git a/src/preview/health-checker.ts b/src/preview/health-checker.ts new file mode 100644 index 0000000..a0b96fd --- /dev/null +++ b/src/preview/health-checker.ts @@ -0,0 +1,102 @@ +/** + * Health Checker + * + * Polls service healthcheck endpoints through the Caddy proxy port + * to verify that preview services are ready. + */ + +import type { PreviewConfig, HealthResult } from './types.js'; +import { createModuleLogger } from '../logger/index.js'; + +const log = createModuleLogger('preview:health'); + +/** Default timeout for health checks (120 seconds) */ +const DEFAULT_TIMEOUT_MS = 120_000; + +/** Default polling interval (3 seconds) */ +const DEFAULT_INTERVAL_MS = 3_000; + +/** + * Wait for all non-internal services to become healthy by polling their + * healthcheck endpoints through the Caddy proxy. + * + * @param port - The host port where Caddy is listening + * @param config - Preview config with service definitions + * @param timeoutMs - Maximum time to wait (default: 120s) + * @returns Per-service health results + */ +export async function waitForHealthy( + port: number, + config: PreviewConfig, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + const services = Object.values(config.services).filter((svc) => { + if (svc.internal) return false; + if (!svc.healthcheck?.path) return false; + return true; + }); + + if (services.length === 0) { + log.info('no healthcheck endpoints configured, skipping health wait'); + return []; + } + + const deadline = Date.now() + timeoutMs; + const results = new Map(); + + // Initialize all as unhealthy + for (const svc of services) { + results.set(svc.name, { name: svc.name, healthy: false }); + } + + while (Date.now() < deadline) { + const pending = services.filter((svc) => !results.get(svc.name)!.healthy); + if (pending.length === 0) break; + + await Promise.all( + pending.map(async (svc) => { + const route = svc.route ?? '/'; + const healthPath = svc.healthcheck!.path; + // Build URL through proxy route + const basePath = route === '/' ? '' : route; + const url = `http://127.0.0.1:${port}${basePath}${healthPath}`; + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + if (response.ok) { + log.info({ service: svc.name, url }, 'service healthy'); + results.set(svc.name, { name: svc.name, healthy: true }); + } + } catch { + // Not ready yet + } + }), + ); + + const stillPending = services.filter((svc) => !results.get(svc.name)!.healthy); + if (stillPending.length === 0) break; + + log.debug( + { pending: stillPending.map((s) => s.name) }, + 'waiting for services to become healthy', + ); + await sleep(DEFAULT_INTERVAL_MS); + } + + // Mark timed-out services + for (const svc of services) { + const result = results.get(svc.name)!; + if (!result.healthy) { + result.error = 'health check timed out'; + log.warn({ service: svc.name }, 'service health check timed out'); + } + } + + return Array.from(results.values()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/preview/index.ts b/src/preview/index.ts new file mode 100644 index 0000000..6dae01d --- /dev/null +++ b/src/preview/index.ts @@ -0,0 +1,32 @@ +/** + * Preview Module — Barrel Exports + */ + +export { PreviewManager } from './manager.js'; +export { discoverConfig, parseCwPreviewConfig } from './config-reader.js'; +export { + generateComposeFile, + generateCaddyfile, + generateLabels, +} from './compose-generator.js'; +export { + isDockerAvailable, + composeUp, + composeDown, + composePs, + listPreviewProjects, + getContainerLabels, +} from './docker-client.js'; +export { waitForHealthy } from './health-checker.js'; +export { allocatePort } from './port-allocator.js'; +export type { + PreviewConfig, + PreviewServiceConfig, + PreviewStatus, + StartPreviewOptions, + HealthResult, +} from './types.js'; +export { + PREVIEW_LABELS, + COMPOSE_PROJECT_PREFIX, +} from './types.js'; diff --git a/src/preview/manager.ts b/src/preview/manager.ts new file mode 100644 index 0000000..a1f2d3d --- /dev/null +++ b/src/preview/manager.ts @@ -0,0 +1,342 @@ +/** + * Preview Manager + * + * Orchestrates preview deployment lifecycle: start, stop, list, status. + * Uses Docker as the source of truth — no database persistence. + */ + +import { join } from 'node:path'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { nanoid } from 'nanoid'; +import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { EventBus } from '../events/types.js'; +import type { + PreviewStatus, + StartPreviewOptions, +} from './types.js'; +import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js'; +import { discoverConfig } from './config-reader.js'; +import { generateComposeFile, generateCaddyfile, generateLabels } from './compose-generator.js'; +import { + isDockerAvailable, + composeUp, + composeDown, + composePs, + listPreviewProjects, + getContainerLabels, +} from './docker-client.js'; +import { waitForHealthy } from './health-checker.js'; +import { allocatePort } from './port-allocator.js'; +import { getProjectCloneDir } from '../git/project-clones.js'; +import { createModuleLogger } from '../logger/index.js'; +import type { + PreviewBuildingEvent, + PreviewReadyEvent, + PreviewStoppedEvent, + PreviewFailedEvent, +} from '../events/types.js'; + +const log = createModuleLogger('preview'); + +/** Directory for preview deployment artifacts (relative to workspace root) */ +const PREVIEWS_DIR = '.cw-previews'; + +export class PreviewManager { + private readonly projectRepository: ProjectRepository; + private readonly eventBus: EventBus; + private readonly workspaceRoot: string; + + constructor( + projectRepository: ProjectRepository, + eventBus: EventBus, + workspaceRoot: string, + ) { + this.projectRepository = projectRepository; + this.eventBus = eventBus; + this.workspaceRoot = workspaceRoot; + } + + /** + * Start a preview deployment. + * + * 1. Check Docker availability + * 2. Resolve project clone path + * 3. Discover config from project at target branch + * 4. Allocate port, generate ID + * 5. Generate compose + Caddyfile, write to .cw-previews// + * 6. Run composeUp, wait for healthy + * 7. Emit events and return status + */ + async start(options: StartPreviewOptions): Promise { + // 1. Check Docker + if (!(await isDockerAvailable())) { + throw new Error( + 'Docker is not available. Please ensure Docker is installed and running.', + ); + } + + // 2. Resolve project + const project = await this.projectRepository.findById(options.projectId); + if (!project) { + throw new Error(`Project '${options.projectId}' not found`); + } + + const clonePath = join( + this.workspaceRoot, + getProjectCloneDir(project.name, project.id), + ); + + // 3. Discover config + const config = await discoverConfig(clonePath); + + // 4. Allocate port and generate ID + const port = await allocatePort(); + const id = nanoid(10); + const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`; + + // 5. Generate compose artifacts + const labels = generateLabels({ + initiativeId: options.initiativeId, + phaseId: options.phaseId, + projectId: options.projectId, + branch: options.branch, + port, + previewId: id, + }); + + const composeYaml = generateComposeFile(config, { + projectPath: clonePath, + port, + deploymentId: id, + labels, + }); + const caddyfile = generateCaddyfile(config); + + // Write artifacts + const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id); + await mkdir(deployDir, { recursive: true }); + + const composePath = join(deployDir, 'docker-compose.yml'); + await writeFile(composePath, composeYaml, 'utf-8'); + await writeFile(join(deployDir, 'Caddyfile'), caddyfile, 'utf-8'); + + log.info({ id, projectName, port, composePath }, 'preview deployment prepared'); + + // 6. Emit building event + this.eventBus.emit({ + type: 'preview:building', + timestamp: new Date(), + payload: { previewId: id, initiativeId: options.initiativeId, branch: options.branch, port }, + }); + + // 7. Build and start + try { + await composeUp(composePath, projectName); + } catch (error) { + log.error({ id, err: error }, 'compose up failed'); + + this.eventBus.emit({ + type: 'preview:failed', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + error: (error as Error).message, + }, + }); + + // Clean up + await composeDown(projectName).catch(() => {}); + await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + + throw new Error(`Preview build failed: ${(error as Error).message}`); + } + + // 8. Health check + const healthResults = await waitForHealthy(port, config); + const allHealthy = healthResults.every((r) => r.healthy); + + if (!allHealthy && healthResults.length > 0) { + const failedServices = healthResults + .filter((r) => !r.healthy) + .map((r) => r.name); + log.warn({ id, failedServices }, 'some services failed health checks'); + + this.eventBus.emit({ + type: 'preview:failed', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + error: `Health checks failed for: ${failedServices.join(', ')}`, + }, + }); + + await composeDown(projectName).catch(() => {}); + await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + + throw new Error( + `Preview health checks failed for services: ${failedServices.join(', ')}`, + ); + } + + // 9. Success + const url = `http://localhost:${port}`; + log.info({ id, url }, 'preview deployment ready'); + + this.eventBus.emit({ + type: 'preview:ready', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + branch: options.branch, + port, + url, + }, + }); + + const services = await composePs(projectName); + + return { + id, + projectName, + initiativeId: options.initiativeId, + phaseId: options.phaseId, + projectId: options.projectId, + branch: options.branch, + port, + status: 'running', + services, + composePath, + }; + } + + /** + * Stop a preview deployment and clean up artifacts. + */ + async stop(previewId: string): Promise { + const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; + + // Get labels before stopping to emit event + const labels = await getContainerLabels(projectName); + const initiativeId = labels[PREVIEW_LABELS.initiativeId] ?? ''; + + await composeDown(projectName); + + // Clean up deployment directory + const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, previewId); + await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + + log.info({ previewId, projectName }, 'preview stopped'); + + this.eventBus.emit({ + type: 'preview:stopped', + timestamp: new Date(), + payload: { previewId, initiativeId }, + }); + } + + /** + * List all active preview deployments, optionally filtered by initiative. + */ + async list(initiativeId?: string): Promise { + const projects = await listPreviewProjects(); + const previews: PreviewStatus[] = []; + + for (const project of projects) { + const labels = await getContainerLabels(project.Name); + if (!labels[PREVIEW_LABELS.preview]) continue; + + const preview = this.labelsToStatus(project.Name, labels, project.ConfigFiles); + if (!preview) continue; + + if (initiativeId && preview.initiativeId !== initiativeId) continue; + + // Get service statuses + preview.services = await composePs(project.Name); + previews.push(preview); + } + + return previews; + } + + /** + * Get the status of a specific preview deployment. + */ + async getStatus(previewId: string): Promise { + const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; + const labels = await getContainerLabels(projectName); + + if (!labels[PREVIEW_LABELS.preview]) { + return null; + } + + const preview = this.labelsToStatus(projectName, labels, ''); + if (!preview) return null; + + preview.services = await composePs(projectName); + + // Determine status from service states + if (preview.services.length === 0) { + preview.status = 'stopped'; + } else if (preview.services.every((s) => s.state === 'running')) { + preview.status = 'running'; + } else if (preview.services.some((s) => s.state === 'exited' || s.state === 'dead')) { + preview.status = 'failed'; + } else { + preview.status = 'building'; + } + + return preview; + } + + /** + * Stop all preview deployments. Called on server shutdown. + */ + async stopAll(): Promise { + const projects = await listPreviewProjects(); + log.info({ count: projects.length }, 'stopping all preview deployments'); + + await Promise.all( + projects.map(async (project) => { + const id = project.Name.replace(COMPOSE_PROJECT_PREFIX, ''); + await this.stop(id).catch((err) => { + log.warn({ projectName: project.Name, err }, 'failed to stop preview'); + }); + }), + ); + } + + /** + * Reconstruct PreviewStatus from Docker container labels. + */ + private labelsToStatus( + projectName: string, + labels: Record, + composePath: string, + ): PreviewStatus | null { + const previewId = labels[PREVIEW_LABELS.previewId] ?? projectName.replace(COMPOSE_PROJECT_PREFIX, ''); + const initiativeId = labels[PREVIEW_LABELS.initiativeId]; + const projectId = labels[PREVIEW_LABELS.projectId]; + const branch = labels[PREVIEW_LABELS.branch]; + const port = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10); + + if (!initiativeId || !projectId || !branch) { + return null; + } + + return { + id: previewId, + projectName, + initiativeId, + phaseId: labels[PREVIEW_LABELS.phaseId], + projectId, + branch, + port, + status: 'running', + services: [], + composePath, + }; + } +} diff --git a/src/preview/port-allocator.test.ts b/src/preview/port-allocator.test.ts new file mode 100644 index 0000000..d01c6d2 --- /dev/null +++ b/src/preview/port-allocator.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createServer } from 'node:net'; + +// Mock the docker-client module to avoid actual Docker calls +vi.mock('./docker-client.js', () => ({ + getPreviewPorts: vi.fn(), +})); + +import { allocatePort } from './port-allocator.js'; +import { getPreviewPorts } from './docker-client.js'; + +const mockedGetPreviewPorts = vi.mocked(getPreviewPorts); + +describe('allocatePort', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns BASE_PORT (9100) when no ports are in use', async () => { + mockedGetPreviewPorts.mockResolvedValue([]); + const port = await allocatePort(); + expect(port).toBe(9100); + }); + + it('skips ports already used by previews', async () => { + mockedGetPreviewPorts.mockResolvedValue([9100, 9101]); + const port = await allocatePort(); + expect(port).toBe(9102); + }); + + it('skips non-contiguous used ports', async () => { + mockedGetPreviewPorts.mockResolvedValue([9100, 9103]); + const port = await allocatePort(); + expect(port).toBe(9101); + }); + + it('skips a port that is bound by another process', async () => { + mockedGetPreviewPorts.mockResolvedValue([]); + + // Bind port 9100 to simulate external use + const server = createServer(); + await new Promise((resolve) => { + server.listen(9100, '127.0.0.1', () => resolve()); + }); + + try { + const port = await allocatePort(); + expect(port).toBe(9101); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); +}); diff --git a/src/preview/port-allocator.ts b/src/preview/port-allocator.ts new file mode 100644 index 0000000..5475cf8 --- /dev/null +++ b/src/preview/port-allocator.ts @@ -0,0 +1,63 @@ +/** + * Port Allocator + * + * Finds the next available port for a preview deployment. + * Queries running preview containers and performs a bind test. + */ + +import { createServer } from 'node:net'; +import { getPreviewPorts } from './docker-client.js'; +import { createModuleLogger } from '../logger/index.js'; + +const log = createModuleLogger('preview:port'); + +/** Starting port for preview deployments */ +const BASE_PORT = 9100; + +/** Maximum port to try before giving up */ +const MAX_PORT = 9200; + +/** + * Allocate the next available port for a preview deployment. + * + * 1. Queries running preview containers for their cw.port labels + * 2. Finds the next port >= BASE_PORT that isn't in use + * 3. Performs a bind test to verify no external conflict + * + * @returns An available port number + * @throws If no port is available in the range + */ +export async function allocatePort(): Promise { + const usedPorts = new Set(await getPreviewPorts()); + log.debug({ usedPorts: Array.from(usedPorts) }, 'ports in use by previews'); + + for (let port = BASE_PORT; port < MAX_PORT; port++) { + if (usedPorts.has(port)) continue; + + if (await isPortAvailable(port)) { + log.info({ port }, 'allocated port'); + return port; + } + } + + throw new Error(`No available ports in range ${BASE_PORT}-${MAX_PORT}`); +} + +/** + * Test if a port is available by attempting to bind to it. + */ +async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + + server.once('error', () => { + resolve(false); + }); + + server.listen(port, '127.0.0.1', () => { + server.close(() => { + resolve(true); + }); + }); + }); +} diff --git a/src/preview/types.ts b/src/preview/types.ts new file mode 100644 index 0000000..b691c32 --- /dev/null +++ b/src/preview/types.ts @@ -0,0 +1,86 @@ +/** + * Preview Deployment Types + * + * Configuration and status types for Docker-based preview deployments. + * Docker IS the source of truth — no database table needed. + */ + +/** + * Service configuration within a preview deployment. + */ +export interface PreviewServiceConfig { + name: string; + build?: { context: string; dockerfile: string } | string; + image?: string; + port: number; + route?: string; + internal?: boolean; + healthcheck?: { path: string; interval?: string; retries?: number }; + env?: Record; + volumes?: string[]; +} + +/** + * Preview deployment configuration. + * Parsed from `.cw-preview.yml`, existing compose file, or inferred from Dockerfile. + */ +export interface PreviewConfig { + version: 1; + compose?: string; + services: Record; +} + +/** + * Runtime status of a preview deployment. + * Reconstructed from Docker state + container labels. + */ +export interface PreviewStatus { + id: string; + projectName: string; + initiativeId: string; + phaseId?: string; + projectId: string; + branch: string; + port: number; + status: 'building' | 'running' | 'stopped' | 'failed'; + services: Array<{ name: string; state: string; health: string }>; + composePath: string; +} + +/** + * Docker labels applied to preview containers for metadata retrieval. + */ +export const PREVIEW_LABEL_PREFIX = 'cw'; +export const PREVIEW_LABELS = { + preview: `${PREVIEW_LABEL_PREFIX}.preview`, + initiativeId: `${PREVIEW_LABEL_PREFIX}.initiative-id`, + phaseId: `${PREVIEW_LABEL_PREFIX}.phase-id`, + branch: `${PREVIEW_LABEL_PREFIX}.branch`, + projectId: `${PREVIEW_LABEL_PREFIX}.project-id`, + port: `${PREVIEW_LABEL_PREFIX}.port`, + previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`, +} as const; + +/** + * Compose project name prefix for all preview deployments. + */ +export const COMPOSE_PROJECT_PREFIX = 'cw-preview-'; + +/** + * Options for starting a preview deployment. + */ +export interface StartPreviewOptions { + initiativeId: string; + phaseId?: string; + projectId: string; + branch: string; +} + +/** + * Health check result for a single service. + */ +export interface HealthResult { + name: string; + healthy: boolean; + error?: string; +} diff --git a/src/server/shutdown.ts b/src/server/shutdown.ts index 18c1b0f..00b1fda 100644 --- a/src/server/shutdown.ts +++ b/src/server/shutdown.ts @@ -8,6 +8,7 @@ import type { CoordinationServer } from './index.js'; import type { ProcessManager } from '../process/index.js'; import type { LogManager } from '../logging/index.js'; +import type { PreviewManager } from '../preview/index.js'; /** Timeout before force exit in milliseconds */ const SHUTDOWN_TIMEOUT_MS = 10000; @@ -30,17 +31,20 @@ export class GracefulShutdown { private readonly server: CoordinationServer; private readonly processManager: ProcessManager; private readonly logManager: LogManager; + private readonly previewManager?: PreviewManager; private isShuttingDown = false; private forceExitCount = 0; constructor( server: CoordinationServer, processManager: ProcessManager, - logManager: LogManager + logManager: LogManager, + previewManager?: PreviewManager, ) { this.server = server; this.processManager = processManager; this.logManager = logManager; + this.previewManager = previewManager; } /** @@ -114,7 +118,13 @@ export class GracefulShutdown { console.log(' Stopping managed processes...'); await this.processManager.stopAll(); - // Step 3: Clean up log manager resources (future: close open file handles) + // Step 3: Stop all preview deployments + if (this.previewManager) { + console.log(' Stopping preview deployments...'); + await this.previewManager.stopAll(); + } + + // Step 4: Clean up log manager resources (future: close open file handles) // Currently LogManager doesn't maintain persistent handles that need closing // This is a placeholder for future cleanup needs console.log(' Cleaning up resources...'); diff --git a/src/server/trpc-adapter.ts b/src/server/trpc-adapter.ts index 648f7b0..e94e163 100644 --- a/src/server/trpc-adapter.ts +++ b/src/server/trpc-adapter.ts @@ -24,6 +24,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js import type { CoordinationManager } from '../coordination/types.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../execution/orchestrator.js'; +import type { PreviewManager } from '../preview/index.js'; /** * Options for creating the tRPC request handler. @@ -67,6 +68,8 @@ export interface TrpcAdapterOptions { branchManager?: BranchManager; /** Execution orchestrator for phase merge/review workflow */ executionOrchestrator?: ExecutionOrchestrator; + /** Preview manager for Docker-based preview deployments */ + previewManager?: PreviewManager; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; } @@ -146,6 +149,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) { credentialManager: options.credentialManager, branchManager: options.branchManager, executionOrchestrator: options.executionOrchestrator, + previewManager: options.previewManager, workspaceRoot: options.workspaceRoot, }), }); diff --git a/src/trpc/context.ts b/src/trpc/context.ts index f78236c..fcd91f1 100644 --- a/src/trpc/context.ts +++ b/src/trpc/context.ts @@ -21,6 +21,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js import type { CoordinationManager } from '../coordination/types.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../execution/orchestrator.js'; +import type { PreviewManager } from '../preview/index.js'; // Re-export for convenience export type { EventBus, DomainEvent }; @@ -67,6 +68,8 @@ export interface TRPCContext { branchManager?: BranchManager; /** Execution orchestrator for phase merge/review workflow */ executionOrchestrator?: ExecutionOrchestrator; + /** Preview manager for Docker-based preview deployments */ + previewManager?: PreviewManager; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; } @@ -94,6 +97,7 @@ export interface CreateContextOptions { credentialManager?: AccountCredentialManager; branchManager?: BranchManager; executionOrchestrator?: ExecutionOrchestrator; + previewManager?: PreviewManager; workspaceRoot?: string; } @@ -124,6 +128,7 @@ export function createContext(options: CreateContextOptions): TRPCContext { credentialManager: options.credentialManager, branchManager: options.branchManager, executionOrchestrator: options.executionOrchestrator, + previewManager: options.previewManager, workspaceRoot: options.workspaceRoot, }; } diff --git a/src/trpc/router.ts b/src/trpc/router.ts index a9418ac..b7cb3ea 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -21,6 +21,7 @@ import { pageProcedures } from './routers/page.js'; import { accountProcedures } from './routers/account.js'; import { changeSetProcedures } from './routers/change-set.js'; import { subscriptionProcedures } from './routers/subscription.js'; +import { previewProcedures } from './routers/preview.js'; // Re-export tRPC primitives (preserves existing import paths) export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js'; @@ -57,6 +58,7 @@ export const appRouter = router({ ...accountProcedures(publicProcedure), ...changeSetProcedures(publicProcedure), ...subscriptionProcedures(publicProcedure), + ...previewProcedures(publicProcedure), }); export type AppRouter = typeof appRouter; diff --git a/src/trpc/routers/_helpers.ts b/src/trpc/routers/_helpers.ts index e3b38b2..5c39f59 100644 --- a/src/trpc/routers/_helpers.ts +++ b/src/trpc/routers/_helpers.ts @@ -20,6 +20,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types import type { CoordinationManager } from '../../coordination/types.js'; import type { BranchManager } from '../../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../../execution/orchestrator.js'; +import type { PreviewManager } from '../../preview/index.js'; export function requireAgentManager(ctx: TRPCContext) { if (!ctx.agentManager) { @@ -170,3 +171,13 @@ export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchest } return ctx.executionOrchestrator; } + +export function requirePreviewManager(ctx: TRPCContext): PreviewManager { + if (!ctx.previewManager) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Preview manager not available', + }); + } + return ctx.previewManager; +} diff --git a/src/trpc/routers/preview.ts b/src/trpc/routers/preview.ts new file mode 100644 index 0000000..e7b8ebd --- /dev/null +++ b/src/trpc/routers/preview.ts @@ -0,0 +1,51 @@ +/** + * Preview Router — start, stop, list, status for Docker-based preview deployments + */ + +import { z } from 'zod'; +import type { ProcedureBuilder } from '../trpc.js'; +import { requirePreviewManager } from './_helpers.js'; + +export function previewProcedures(publicProcedure: ProcedureBuilder) { + return { + startPreview: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1), + phaseId: z.string().min(1).optional(), + projectId: z.string().min(1), + branch: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + return previewManager.start(input); + }), + + stopPreview: publicProcedure + .input(z.object({ + previewId: z.string().min(1), + })) + .mutation(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + await previewManager.stop(input.previewId); + return { success: true }; + }), + + listPreviews: publicProcedure + .input(z.object({ + initiativeId: z.string().min(1).optional(), + }).optional()) + .query(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + return previewManager.list(input?.initiativeId); + }), + + getPreviewStatus: publicProcedure + .input(z.object({ + previewId: z.string().min(1), + })) + .query(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + return previewManager.getStatus(input.previewId); + }), + }; +}