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:
@@ -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/` |
|
| 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/` |
|
| 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/` |
|
| 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/` |
|
| Testing | [docs/testing.md](docs/testing.md) | `src/test/` |
|
||||||
| Database Migrations | [docs/database-migrations.md](docs/database-migrations.md) | `drizzle/` |
|
| Database Migrations | [docs/database-migrations.md](docs/database-migrations.md) | `drizzle/` |
|
||||||
| Logging Guide | [docs/logging.md](docs/logging.md) | `src/logger/` |
|
| Logging Guide | [docs/logging.md](docs/logging.md) | `src/logger/` |
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ CLI (cw)
|
|||||||
│ └── LifecycleController (retry, signal recovery)
|
│ └── LifecycleController (retry, signal recovery)
|
||||||
├── DispatchManager (task queue, dependency resolution)
|
├── DispatchManager (task queue, dependency resolution)
|
||||||
├── PhaseDispatchManager (phase queue, DAG ordering)
|
├── PhaseDispatchManager (phase queue, DAG ordering)
|
||||||
└── CoordinationManager (merge queue, conflict resolution)
|
├── CoordinationManager (merge queue, conflict resolution)
|
||||||
|
└── PreviewManager (Docker-based preview deployments)
|
||||||
|
|
||||||
Web UI (packages/web/)
|
Web UI (packages/web/)
|
||||||
└── React 19 + TanStack Router + tRPC React Query
|
└── 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Tests | `src/test/` | E2E, integration, fixtures | [testing.md](testing.md) |
|
||||||
|
|
||||||
## Entity Relationships
|
## Entity Relationships
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ Uses **Commander.js** for command parsing.
|
|||||||
| `list` | List projects |
|
| `list` | List projects |
|
||||||
| `delete <id>` | Delete project |
|
| `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`)
|
### Accounts (`cw account`)
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
|
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
|
||||||
- All events implement `BaseEvent { type, timestamp, payload }`
|
- All events implement `BaseEvent { type, timestamp, payload }`
|
||||||
|
|
||||||
### Event Types (48)
|
### Event Types (52)
|
||||||
|
|
||||||
| Category | Events | Count |
|
| Category | Events | Count |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
| **Server** | `server:started`, `server:stopped` | 2 |
|
| **Server** | `server:started`, `server:stopped` | 2 |
|
||||||
| **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 |
|
| **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 |
|
||||||
| **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 |
|
| **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 |
|
| **Log** | `log:entry` | 1 |
|
||||||
|
|
||||||
### Key Event Payloads
|
### 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/`)
|
### Review Components (`src/components/review/`)
|
||||||
| Component | Purpose |
|
| 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 |
|
| `ProposalCard` | Individual proposal display |
|
||||||
|
|
||||||
### UI Primitives (`src/components/ui/`)
|
### 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
|
- **ConflictResolutionService**: creates resolution tasks for merge conflicts
|
||||||
- Merge flow: queue → check deps → merge via WorktreeManager → handle conflicts
|
- Merge flow: queue → check deps → merge via WorktreeManager → handle conflicts
|
||||||
- Events: `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted`
|
- 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.
|
||||||
|
|||||||
49
package-lock.json
generated
49
package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"execa": "^9.5.2",
|
"execa": "^9.5.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"simple-git": "^3.30.0",
|
"simple-git": "^3.30.0",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -3559,6 +3561,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
@@ -5423,6 +5432,28 @@
|
|||||||
"node": ">=6.0"
|
"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": {
|
"node_modules/happy-dom": {
|
||||||
"version": "20.5.0",
|
"version": "20.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.0.tgz",
|
||||||
@@ -5652,27 +5683,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "3.14.2",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^1.0.7",
|
"argparse": "^2.0.1"
|
||||||
"esprima": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"execa": "^9.5.2",
|
"execa": "^9.5.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"simple-git": "^3.30.0",
|
"simple-git": "^3.30.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
176
packages/web/src/components/review/PreviewPanel.tsx
Normal file
176
packages/web/src/components/review/PreviewPanel.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
|
Building preview...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600/70 dark:text-blue-400/70">
|
||||||
|
Building containers and starting services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running state
|
||||||
|
if (preview && (preview.status === "running" || preview.status === "building")) {
|
||||||
|
const url = `http://localhost:${preview.port}`;
|
||||||
|
const isBuilding = preview.status === "building";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
||||||
|
isBuilding
|
||||||
|
? "border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-950/20"
|
||||||
|
: "border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isBuilding ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<CircleDot className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{isBuilding ? "Building..." : "Preview running"}
|
||||||
|
</p>
|
||||||
|
{!isBuilding && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={stopMutation.isPending}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 mr-1" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed state
|
||||||
|
if (preview && preview.status === "failed") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20 px-4 py-3">
|
||||||
|
<CircleX className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
Preview failed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleStart}>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No preview — show start button
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={startMutation.isPending}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Start Preview
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { trpc } from "@/lib/trpc";
|
|||||||
import { parseUnifiedDiff } from "./parse-diff";
|
import { parseUnifiedDiff } from "./parse-diff";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { ReviewSidebar } from "./ReviewSidebar";
|
import { ReviewSidebar } from "./ReviewSidebar";
|
||||||
|
import { PreviewPanel } from "./PreviewPanel";
|
||||||
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
|
||||||
|
|
||||||
interface ReviewTabProps {
|
interface ReviewTabProps {
|
||||||
@@ -26,6 +27,10 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||||
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? 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
|
// Fetch diff for active phase
|
||||||
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
|
||||||
{ phaseId: activePhaseId! },
|
{ 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 activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase";
|
||||||
|
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -128,6 +134,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Preview panel */}
|
||||||
|
{firstProjectId && sourceBranch && (
|
||||||
|
<PreviewPanel
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
phaseId={activePhaseId ?? undefined}
|
||||||
|
projectId={firstProjectId}
|
||||||
|
branch={sourceBranch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{diffQuery.isLoading ? (
|
{diffQuery.isLoading ? (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
Loading diff...
|
Loading diff...
|
||||||
@@ -165,7 +181,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
|
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
|
||||||
author="system"
|
author="system"
|
||||||
status={status}
|
status={status}
|
||||||
sourceBranch={diffQuery.data?.sourceBranch ?? ""}
|
sourceBranch={sourceBranch}
|
||||||
targetBranch={diffQuery.data?.targetBranch ?? ""}
|
targetBranch={diffQuery.data?.targetBranch ?? ""}
|
||||||
files={files}
|
files={files}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
|
|||||||
105
src/cli/index.ts
105
src/cli/index.ts
@@ -48,7 +48,7 @@ async function startServer(port?: number, debug?: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Install graceful shutdown handlers
|
// 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();
|
shutdown.install();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,6 +1259,109 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Preview command group
|
||||||
|
const previewCommand = program
|
||||||
|
.command('preview')
|
||||||
|
.description('Manage Docker-based preview deployments');
|
||||||
|
|
||||||
|
// cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
|
||||||
|
previewCommand
|
||||||
|
.command('start')
|
||||||
|
.description('Start a preview deployment')
|
||||||
|
.requiredOption('--initiative <id>', 'Initiative ID')
|
||||||
|
.requiredOption('--project <id>', 'Project ID')
|
||||||
|
.requiredOption('--branch <branch>', 'Branch to deploy')
|
||||||
|
.option('--phase <id>', '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 <previewId>
|
||||||
|
previewCommand
|
||||||
|
.command('stop <previewId>')
|
||||||
|
.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 <id>]
|
||||||
|
previewCommand
|
||||||
|
.command('list')
|
||||||
|
.description('List active preview deployments')
|
||||||
|
.option('--initiative <id>', '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 <previewId>
|
||||||
|
previewCommand
|
||||||
|
.command('status <previewId>')
|
||||||
|
.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;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
|
|||||||
import type { BranchManager } from './git/branch-manager.js';
|
import type { BranchManager } from './git/branch-manager.js';
|
||||||
import { ExecutionOrchestrator } from './execution/orchestrator.js';
|
import { ExecutionOrchestrator } from './execution/orchestrator.js';
|
||||||
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
|
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
|
||||||
|
import { PreviewManager } from './preview/index.js';
|
||||||
import { findWorkspaceRoot } from './config/index.js';
|
import { findWorkspaceRoot } from './config/index.js';
|
||||||
import { createModuleLogger } from './logger/index.js';
|
import { createModuleLogger } from './logger/index.js';
|
||||||
import type { ServerContextDeps } from './server/index.js';
|
import type { ServerContextDeps } from './server/index.js';
|
||||||
@@ -106,6 +107,7 @@ export interface Container extends Repositories {
|
|||||||
phaseDispatchManager: PhaseDispatchManager;
|
phaseDispatchManager: PhaseDispatchManager;
|
||||||
branchManager: BranchManager;
|
branchManager: BranchManager;
|
||||||
executionOrchestrator: ExecutionOrchestrator;
|
executionOrchestrator: ExecutionOrchestrator;
|
||||||
|
previewManager: PreviewManager;
|
||||||
|
|
||||||
/** Extract the subset of deps that CoordinationServer needs. */
|
/** Extract the subset of deps that CoordinationServer needs. */
|
||||||
toContextDeps(): ServerContextDeps;
|
toContextDeps(): ServerContextDeps;
|
||||||
@@ -220,6 +222,14 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
executionOrchestrator.start();
|
executionOrchestrator.start();
|
||||||
log.info('execution orchestrator started');
|
log.info('execution orchestrator started');
|
||||||
|
|
||||||
|
// Preview manager
|
||||||
|
const previewManager = new PreviewManager(
|
||||||
|
repos.projectRepository,
|
||||||
|
eventBus,
|
||||||
|
workspaceRoot,
|
||||||
|
);
|
||||||
|
log.info('preview manager created');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -232,6 +242,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
phaseDispatchManager,
|
phaseDispatchManager,
|
||||||
branchManager,
|
branchManager,
|
||||||
executionOrchestrator,
|
executionOrchestrator,
|
||||||
|
previewManager,
|
||||||
...repos,
|
...repos,
|
||||||
|
|
||||||
toContextDeps(): ServerContextDeps {
|
toContextDeps(): ServerContextDeps {
|
||||||
@@ -242,6 +253,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
phaseDispatchManager,
|
phaseDispatchManager,
|
||||||
branchManager,
|
branchManager,
|
||||||
executionOrchestrator,
|
executionOrchestrator,
|
||||||
|
previewManager,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
...repos,
|
...repos,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -446,6 +446,48 @@ export interface ChangeSetRevertedEvent extends DomainEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PreviewBuildingEvent extends DomainEvent {
|
||||||
|
type: 'preview:building';
|
||||||
|
payload: {
|
||||||
|
previewId: string;
|
||||||
|
initiativeId: string;
|
||||||
|
branch: string;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewReadyEvent extends DomainEvent {
|
||||||
|
type: 'preview:ready';
|
||||||
|
payload: {
|
||||||
|
previewId: string;
|
||||||
|
initiativeId: string;
|
||||||
|
branch: string;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewStoppedEvent extends DomainEvent {
|
||||||
|
type: 'preview:stopped';
|
||||||
|
payload: {
|
||||||
|
previewId: string;
|
||||||
|
initiativeId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewFailedEvent extends DomainEvent {
|
||||||
|
type: 'preview:failed';
|
||||||
|
payload: {
|
||||||
|
previewId: string;
|
||||||
|
initiativeId: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account Credential Events
|
* Account Credential Events
|
||||||
*/
|
*/
|
||||||
@@ -523,7 +565,11 @@ export type DomainEventMap =
|
|||||||
| ChangeSetRevertedEvent
|
| ChangeSetRevertedEvent
|
||||||
| AccountCredentialsRefreshedEvent
|
| AccountCredentialsRefreshedEvent
|
||||||
| AccountCredentialsExpiredEvent
|
| AccountCredentialsExpiredEvent
|
||||||
| AccountCredentialsValidatedEvent;
|
| AccountCredentialsValidatedEvent
|
||||||
|
| PreviewBuildingEvent
|
||||||
|
| PreviewReadyEvent
|
||||||
|
| PreviewStoppedEvent
|
||||||
|
| PreviewFailedEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event type literal union for type checking
|
* Event type literal union for type checking
|
||||||
|
|||||||
207
src/preview/compose-generator.test.ts
Normal file
207
src/preview/compose-generator.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import {
|
||||||
|
generateComposeFile,
|
||||||
|
generateCaddyfile,
|
||||||
|
generateLabels,
|
||||||
|
} from './compose-generator.js';
|
||||||
|
import type { PreviewConfig } from './types.js';
|
||||||
|
|
||||||
|
describe('generateComposeFile', () => {
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
191
src/preview/compose-generator.ts
Normal file
191
src/preview/compose-generator.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposeService {
|
||||||
|
build?: { context: string; dockerfile: string } | string;
|
||||||
|
image?: string;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
volumes?: string[];
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
networks?: string[];
|
||||||
|
depends_on?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposeFile {
|
||||||
|
services: Record<string, ComposeService>;
|
||||||
|
networks: Record<string, { driver: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>).ports = [`${opts.port}:80`];
|
||||||
|
|
||||||
|
// Mount Caddyfile via inline config
|
||||||
|
(caddyService as Record<string, unknown>).command = ['caddy', 'run', '--config', '/etc/caddy/Caddyfile'];
|
||||||
|
|
||||||
|
// Caddy config will be written to the deployment directory and mounted
|
||||||
|
(caddyService as Record<string, unknown>).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<string, string> {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[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;
|
||||||
|
}
|
||||||
115
src/preview/config-reader.test.ts
Normal file
115
src/preview/config-reader.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
164
src/preview/config-reader.ts
Normal file
164
src/preview/config-reader.ts
Normal file
@@ -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<PreviewConfig> {
|
||||||
|
// 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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, PreviewServiceConfig> = {};
|
||||||
|
const rawServices = parsed.services as Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
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<string, string> }),
|
||||||
|
...(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<string, unknown>;
|
||||||
|
return {
|
||||||
|
context: (b.context as string) ?? '.',
|
||||||
|
dockerfile: (b.dockerfile as string) ?? 'Dockerfile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/preview/docker-client.ts
Normal file
206
src/preview/docker-client.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ServiceStatus[]> {
|
||||||
|
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<ComposeProject[]> {
|
||||||
|
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<Record<string, string>> {
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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<number[]> {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/preview/health-checker.ts
Normal file
102
src/preview/health-checker.ts
Normal file
@@ -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<HealthResult[]> {
|
||||||
|
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<string, HealthResult>();
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
32
src/preview/index.ts
Normal file
32
src/preview/index.ts
Normal file
@@ -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';
|
||||||
342
src/preview/manager.ts
Normal file
342
src/preview/manager.ts
Normal file
@@ -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/<id>/
|
||||||
|
* 6. Run composeUp, wait for healthy
|
||||||
|
* 7. Emit events and return status
|
||||||
|
*/
|
||||||
|
async start(options: StartPreviewOptions): Promise<PreviewStatus> {
|
||||||
|
// 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<PreviewBuildingEvent>({
|
||||||
|
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<PreviewFailedEvent>({
|
||||||
|
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<PreviewFailedEvent>({
|
||||||
|
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<PreviewReadyEvent>({
|
||||||
|
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<void> {
|
||||||
|
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<PreviewStoppedEvent>({
|
||||||
|
type: 'preview:stopped',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { previewId, initiativeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active preview deployments, optionally filtered by initiative.
|
||||||
|
*/
|
||||||
|
async list(initiativeId?: string): Promise<PreviewStatus[]> {
|
||||||
|
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<PreviewStatus | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/preview/port-allocator.test.ts
Normal file
55
src/preview/port-allocator.test.ts
Normal file
@@ -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<void>((resolve) => {
|
||||||
|
server.listen(9100, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const port = await allocatePort();
|
||||||
|
expect(port).toBe(9101);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/preview/port-allocator.ts
Normal file
63
src/preview/port-allocator.ts
Normal file
@@ -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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = createServer();
|
||||||
|
|
||||||
|
server.once('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
server.close(() => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
src/preview/types.ts
Normal file
86
src/preview/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
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<string, PreviewServiceConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { CoordinationServer } from './index.js';
|
import type { CoordinationServer } from './index.js';
|
||||||
import type { ProcessManager } from '../process/index.js';
|
import type { ProcessManager } from '../process/index.js';
|
||||||
import type { LogManager } from '../logging/index.js';
|
import type { LogManager } from '../logging/index.js';
|
||||||
|
import type { PreviewManager } from '../preview/index.js';
|
||||||
|
|
||||||
/** Timeout before force exit in milliseconds */
|
/** Timeout before force exit in milliseconds */
|
||||||
const SHUTDOWN_TIMEOUT_MS = 10000;
|
const SHUTDOWN_TIMEOUT_MS = 10000;
|
||||||
@@ -30,17 +31,20 @@ export class GracefulShutdown {
|
|||||||
private readonly server: CoordinationServer;
|
private readonly server: CoordinationServer;
|
||||||
private readonly processManager: ProcessManager;
|
private readonly processManager: ProcessManager;
|
||||||
private readonly logManager: LogManager;
|
private readonly logManager: LogManager;
|
||||||
|
private readonly previewManager?: PreviewManager;
|
||||||
private isShuttingDown = false;
|
private isShuttingDown = false;
|
||||||
private forceExitCount = 0;
|
private forceExitCount = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: CoordinationServer,
|
server: CoordinationServer,
|
||||||
processManager: ProcessManager,
|
processManager: ProcessManager,
|
||||||
logManager: LogManager
|
logManager: LogManager,
|
||||||
|
previewManager?: PreviewManager,
|
||||||
) {
|
) {
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.processManager = processManager;
|
this.processManager = processManager;
|
||||||
this.logManager = logManager;
|
this.logManager = logManager;
|
||||||
|
this.previewManager = previewManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,7 +118,13 @@ export class GracefulShutdown {
|
|||||||
console.log(' Stopping managed processes...');
|
console.log(' Stopping managed processes...');
|
||||||
await this.processManager.stopAll();
|
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
|
// Currently LogManager doesn't maintain persistent handles that need closing
|
||||||
// This is a placeholder for future cleanup needs
|
// This is a placeholder for future cleanup needs
|
||||||
console.log(' Cleaning up resources...');
|
console.log(' Cleaning up resources...');
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js
|
|||||||
import type { CoordinationManager } from '../coordination/types.js';
|
import type { CoordinationManager } from '../coordination/types.js';
|
||||||
import type { BranchManager } from '../git/branch-manager.js';
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||||
|
import type { PreviewManager } from '../preview/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating the tRPC request handler.
|
* Options for creating the tRPC request handler.
|
||||||
@@ -67,6 +68,8 @@ export interface TrpcAdapterOptions {
|
|||||||
branchManager?: BranchManager;
|
branchManager?: BranchManager;
|
||||||
/** Execution orchestrator for phase merge/review workflow */
|
/** Execution orchestrator for phase merge/review workflow */
|
||||||
executionOrchestrator?: ExecutionOrchestrator;
|
executionOrchestrator?: ExecutionOrchestrator;
|
||||||
|
/** Preview manager for Docker-based preview deployments */
|
||||||
|
previewManager?: PreviewManager;
|
||||||
/** Absolute path to the workspace root (.cwrc directory) */
|
/** Absolute path to the workspace root (.cwrc directory) */
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
@@ -146,6 +149,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
|
|||||||
credentialManager: options.credentialManager,
|
credentialManager: options.credentialManager,
|
||||||
branchManager: options.branchManager,
|
branchManager: options.branchManager,
|
||||||
executionOrchestrator: options.executionOrchestrator,
|
executionOrchestrator: options.executionOrchestrator,
|
||||||
|
previewManager: options.previewManager,
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js
|
|||||||
import type { CoordinationManager } from '../coordination/types.js';
|
import type { CoordinationManager } from '../coordination/types.js';
|
||||||
import type { BranchManager } from '../git/branch-manager.js';
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||||
|
import type { PreviewManager } from '../preview/index.js';
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
export type { EventBus, DomainEvent };
|
export type { EventBus, DomainEvent };
|
||||||
@@ -67,6 +68,8 @@ export interface TRPCContext {
|
|||||||
branchManager?: BranchManager;
|
branchManager?: BranchManager;
|
||||||
/** Execution orchestrator for phase merge/review workflow */
|
/** Execution orchestrator for phase merge/review workflow */
|
||||||
executionOrchestrator?: ExecutionOrchestrator;
|
executionOrchestrator?: ExecutionOrchestrator;
|
||||||
|
/** Preview manager for Docker-based preview deployments */
|
||||||
|
previewManager?: PreviewManager;
|
||||||
/** Absolute path to the workspace root (.cwrc directory) */
|
/** Absolute path to the workspace root (.cwrc directory) */
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,7 @@ export interface CreateContextOptions {
|
|||||||
credentialManager?: AccountCredentialManager;
|
credentialManager?: AccountCredentialManager;
|
||||||
branchManager?: BranchManager;
|
branchManager?: BranchManager;
|
||||||
executionOrchestrator?: ExecutionOrchestrator;
|
executionOrchestrator?: ExecutionOrchestrator;
|
||||||
|
previewManager?: PreviewManager;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
|
|||||||
credentialManager: options.credentialManager,
|
credentialManager: options.credentialManager,
|
||||||
branchManager: options.branchManager,
|
branchManager: options.branchManager,
|
||||||
executionOrchestrator: options.executionOrchestrator,
|
executionOrchestrator: options.executionOrchestrator,
|
||||||
|
previewManager: options.previewManager,
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { pageProcedures } from './routers/page.js';
|
|||||||
import { accountProcedures } from './routers/account.js';
|
import { accountProcedures } from './routers/account.js';
|
||||||
import { changeSetProcedures } from './routers/change-set.js';
|
import { changeSetProcedures } from './routers/change-set.js';
|
||||||
import { subscriptionProcedures } from './routers/subscription.js';
|
import { subscriptionProcedures } from './routers/subscription.js';
|
||||||
|
import { previewProcedures } from './routers/preview.js';
|
||||||
|
|
||||||
// Re-export tRPC primitives (preserves existing import paths)
|
// Re-export tRPC primitives (preserves existing import paths)
|
||||||
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
|
||||||
@@ -57,6 +58,7 @@ export const appRouter = router({
|
|||||||
...accountProcedures(publicProcedure),
|
...accountProcedures(publicProcedure),
|
||||||
...changeSetProcedures(publicProcedure),
|
...changeSetProcedures(publicProcedure),
|
||||||
...subscriptionProcedures(publicProcedure),
|
...subscriptionProcedures(publicProcedure),
|
||||||
|
...previewProcedures(publicProcedure),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types
|
|||||||
import type { CoordinationManager } from '../../coordination/types.js';
|
import type { CoordinationManager } from '../../coordination/types.js';
|
||||||
import type { BranchManager } from '../../git/branch-manager.js';
|
import type { BranchManager } from '../../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
|
||||||
|
import type { PreviewManager } from '../../preview/index.js';
|
||||||
|
|
||||||
export function requireAgentManager(ctx: TRPCContext) {
|
export function requireAgentManager(ctx: TRPCContext) {
|
||||||
if (!ctx.agentManager) {
|
if (!ctx.agentManager) {
|
||||||
@@ -170,3 +171,13 @@ export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchest
|
|||||||
}
|
}
|
||||||
return ctx.executionOrchestrator;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
51
src/trpc/routers/preview.ts
Normal file
51
src/trpc/routers/preview.ts
Normal file
@@ -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);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user