feat: Add Docker-based preview deployments for phase review
Preview deployments let reviewers spin up the app at a specific branch in local Docker containers, accessible through a single Caddy reverse proxy port. Docker is the source of truth — no database table needed. New module: src/preview/ with config discovery (.cw-preview.yml → compose → Dockerfile fallback), compose generation, Docker CLI wrapper, health checking, and port allocation (9100-9200 range).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -99,6 +99,14 @@ Uses **Commander.js** for command parsing.
|
||||
| `list` | List projects |
|
||||
| `delete <id>` | Delete project |
|
||||
|
||||
### Preview Deployments (`cw preview`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `start --initiative <id> --project <id> --branch <branch> [--phase <id>]` | Start Docker preview |
|
||||
| `stop <previewId>` | Stop and clean up preview |
|
||||
| `list [--initiative <id>]` | List active previews |
|
||||
| `status <previewId>` | Get preview status with service details |
|
||||
|
||||
### Accounts (`cw account`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
171
docs/preview.md
Normal file
171
docs/preview.md
Normal file
@@ -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/<id>/` → 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-<nanoid>` 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 <id> --project <id> --branch <branch> [--phase <id>]
|
||||
cw preview stop <previewId>
|
||||
cw preview list [--initiative <id>]
|
||||
cw preview status <previewId>
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
`PreviewPanel` component in the Review tab:
|
||||
- **No preview**: "Start Preview" button
|
||||
- **Building**: Spinner + "Building preview..."
|
||||
- **Running**: Green dot + `http://localhost:<port>` 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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user