feat: Replace per-preview Caddy sidecars with shared gateway architecture

Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
This commit is contained in:
Lukas May
2026-03-05 12:22:29 +01:00
parent 0ff65b0b02
commit 143aad58e8
21 changed files with 1198 additions and 721 deletions

View File

@@ -1353,8 +1353,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
phaseId: options.phase,
});
console.log(`Preview started: ${preview.id}`);
console.log(` URL: http://localhost:${preview.port}`);
console.log(` URL: ${preview.url}`);
console.log(` Branch: ${preview.branch}`);
console.log(` Mode: ${preview.mode}`);
console.log(` Status: ${preview.status}`);
console.log(` Services: ${preview.services.map(s => `${s.name} (${s.state})`).join(', ')}`);
} catch (error) {
@@ -1394,7 +1395,7 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
return;
}
for (const p of previews) {
console.log(`${p.id} http://localhost:${p.port} ${p.branch} [${p.status.toUpperCase()}]`);
console.log(`${p.id} ${p.url} ${p.branch} ${p.mode} [${p.status.toUpperCase()}]`);
}
} catch (error) {
console.error('Failed to list previews:', (error as Error).message);
@@ -1415,8 +1416,9 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
return;
}
console.log(`Preview: ${preview.id}`);
console.log(` URL: http://localhost:${preview.port}`);
console.log(` URL: ${preview.url}`);
console.log(` Branch: ${preview.branch}`);
console.log(` Mode: ${preview.mode}`);
console.log(` Status: ${preview.status}`);
console.log(` Initiative: ${preview.initiativeId}`);
console.log(` Project: ${preview.projectId}`);

View File

@@ -255,6 +255,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
repos.projectRepository,
eventBus,
workspaceRoot,
repos.phaseRepository,
repos.initiativeRepository,
);
log.info('preview manager created');

View File

@@ -467,7 +467,9 @@ export interface PreviewBuildingEvent extends DomainEvent {
previewId: string;
initiativeId: string;
branch: string;
port: number;
gatewayPort: number;
mode: 'preview' | 'dev';
phaseId?: string;
};
}
@@ -477,8 +479,10 @@ export interface PreviewReadyEvent extends DomainEvent {
previewId: string;
initiativeId: string;
branch: string;
port: number;
gatewayPort: number;
url: string;
mode: 'preview' | 'dev';
phaseId?: string;
};
}

View File

@@ -2,24 +2,26 @@ import { describe, it, expect } from 'vitest';
import yaml from 'js-yaml';
import {
generateComposeFile,
generateCaddyfile,
generateLabels,
} from './compose-generator.js';
import { generateGatewayCaddyfile } from './gateway.js';
import type { PreviewConfig } from './types.js';
import { GATEWAY_NETWORK } from './types.js';
import type { GatewayRoute } from './gateway.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',
},
mode: 'preview' as const,
};
it('generates valid compose YAML with user services and Caddy proxy', () => {
it('generates valid compose YAML with user services (no Caddy sidecar)', () => {
const config: PreviewConfig = {
version: 1,
services: {
@@ -34,18 +36,26 @@ describe('generateComposeFile', () => {
const result = generateComposeFile(config, baseOpts);
const parsed = yaml.load(result) as Record<string, any>;
// Has both user service and caddy
// Has user service but NOT caddy-proxy
expect(parsed.services.app).toBeDefined();
expect(parsed.services['caddy-proxy']).toBeDefined();
expect(parsed.services['caddy-proxy']).toBeUndefined();
// Network present
expect(parsed.networks.preview).toBeDefined();
// External network + internal network
expect(parsed.networks[GATEWAY_NETWORK]).toEqual({ external: true });
expect(parsed.networks.internal).toEqual({ driver: 'bridge' });
// Caddy publishes port
expect(parsed.services['caddy-proxy'].ports).toContain('9100:80');
// No published ports on user service
expect(parsed.services.app.ports).toBeUndefined();
// Container name set for DNS resolution
expect(parsed.services.app.container_name).toBe('cw-preview-test123-app');
// Labels propagated
expect(parsed.services.app.labels['cw.preview']).toBe('true');
// Public service on both networks
expect(parsed.services.app.networks).toContain(GATEWAY_NETWORK);
expect(parsed.services.app.networks).toContain('internal');
});
it('handles object build config with context path joining', () => {
@@ -90,84 +100,117 @@ describe('generateComposeFile', () => {
expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test');
});
it('caddy depends on all user services', () => {
it('internal services only get internal network', () => {
const config: PreviewConfig = {
version: 1,
services: {
frontend: { name: 'frontend', build: '.', port: 3000 },
backend: { name: 'backend', build: '.', port: 8080 },
db: {
name: 'db',
image: 'postgres:16',
port: 5432,
internal: true,
},
},
};
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');
});
expect(parsed.services.db.networks).toEqual(['internal']);
expect(parsed.services.db.networks).not.toContain(GATEWAY_NETWORK);
});
describe('generateCaddyfile', () => {
it('generates simple single-service Caddyfile', () => {
it('dev mode uses image + volumes + command instead of build', () => {
const config: PreviewConfig = {
version: 1,
services: {
app: { name: 'app', build: '.', port: 3000 },
frontend: {
name: 'frontend',
build: '.',
port: 3000,
dev: {
image: 'node:20-alpine',
command: 'npm run dev -- --host 0.0.0.0',
workdir: '/app',
},
},
},
};
const caddyfile = generateCaddyfile(config);
expect(caddyfile).toContain(':80 {');
expect(caddyfile).toContain('reverse_proxy app:3000');
expect(caddyfile).toContain('}');
const devOpts = { ...baseOpts, mode: 'dev' as const };
const result = generateComposeFile(config, devOpts);
const parsed = yaml.load(result) as Record<string, any>;
// Should use dev image, not build
expect(parsed.services.frontend.image).toBe('node:20-alpine');
expect(parsed.services.frontend.build).toBeUndefined();
expect(parsed.services.frontend.command).toBe('npm run dev -- --host 0.0.0.0');
expect(parsed.services.frontend.working_dir).toBe('/app');
// Should have volume mount + node_modules anonymous volume
expect(parsed.services.frontend.volumes).toContain(
`${baseOpts.projectPath}:/app`,
);
expect(parsed.services.frontend.volumes).toContain('/app/node_modules');
});
});
describe('generateGatewayCaddyfile', () => {
it('generates single-preview Caddyfile with subdomain routing', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc123', [
{ containerName: 'cw-preview-abc123-app', port: 3000, route: '/' },
]);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('auto_https off');
expect(caddyfile).toContain('abc123.localhost:9100 {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
});
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 previews = new Map<string, GatewayRoute[]>();
previews.set('abc123', [
{ containerName: 'cw-preview-abc123-frontend', port: 3000, route: '/' },
{ containerName: 'cw-preview-abc123-backend', port: 8080, route: '/api' },
]);
const caddyfile = generateCaddyfile(config);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('handle_path /api/*');
expect(caddyfile).toContain('reverse_proxy backend:8080');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
expect(caddyfile).toContain('handle {');
expect(caddyfile).toContain('reverse_proxy frontend:3000');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-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 },
},
};
it('generates multi-preview Caddyfile with separate subdomain blocks', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
]);
previews.set('xyz', [
{ containerName: 'cw-preview-xyz-app', port: 5000, route: '/' },
]);
const caddyfile = generateCaddyfile(config);
expect(caddyfile).not.toContain('postgres');
expect(caddyfile).not.toContain('db:5432');
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('abc.localhost:9100 {');
expect(caddyfile).toContain('xyz.localhost:9100 {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
});
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 previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
{ containerName: 'cw-preview-abc-api', port: 8080, route: '/api' },
{ containerName: 'cw-preview-abc-auth', port: 9090, route: '/api/auth' },
]);
const caddyfile = generateCaddyfile(config);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
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);
});
@@ -180,8 +223,9 @@ describe('generateLabels', () => {
phaseId: 'phase-1',
projectId: 'proj-1',
branch: 'feature/test',
port: 9100,
gatewayPort: 9100,
previewId: 'abc123',
mode: 'preview',
});
expect(labels['cw.preview']).toBe('true');
@@ -191,6 +235,7 @@ describe('generateLabels', () => {
expect(labels['cw.branch']).toBe('feature/test');
expect(labels['cw.port']).toBe('9100');
expect(labels['cw.preview-id']).toBe('abc123');
expect(labels['cw.mode']).toBe('preview');
});
it('omits phaseId label when not provided', () => {
@@ -198,10 +243,12 @@ describe('generateLabels', () => {
initiativeId: 'init-1',
projectId: 'proj-1',
branch: 'main',
port: 9100,
gatewayPort: 9100,
previewId: 'abc123',
mode: 'dev',
});
expect(labels['cw.phase-id']).toBeUndefined();
expect(labels['cw.mode']).toBe('dev');
});
});

View File

@@ -1,19 +1,21 @@
/**
* 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.
* Generates per-preview docker-compose.yml files (no Caddy sidecar — the
* shared gateway handles routing). Services connect to the external
* `cw-preview-net` network for gateway access and an internal bridge
* network for inter-service communication.
*/
import yaml from 'js-yaml';
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
import { PREVIEW_LABELS } from './types.js';
import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js';
export interface ComposeGeneratorOptions {
projectPath: string;
port: number;
deploymentId: string;
labels: Record<string, string>;
mode: 'preview' | 'dev';
}
interface ComposeService {
@@ -24,20 +26,25 @@ interface ComposeService {
labels?: Record<string, string>;
networks?: string[];
depends_on?: string[];
container_name?: string;
command?: string;
working_dir?: string;
}
interface ComposeFile {
services: Record<string, ComposeService>;
networks: Record<string, { driver: string }>;
networks: Record<string, { driver?: string; external?: boolean }>;
}
/**
* Generate a Docker Compose YAML string for the preview deployment.
* Generate a Docker Compose YAML string for a preview deployment.
*
* Structure:
* - User-defined services with build contexts
* - Caddy reverse proxy publishing the single host port
* - Shared `preview` network
* - User-defined services with build contexts (no published ports)
* - Public services connect to both cw-preview-net and internal network
* - Internal services connect only to internal network
* - Container names: cw-preview-<id>-<service> for DNS resolution on shared network
* - Dev mode: uses image + volumes + command instead of build
*/
export function generateComposeFile(
config: PreviewConfig,
@@ -46,21 +53,35 @@ export function generateComposeFile(
const compose: ComposeFile = {
services: {},
networks: {
preview: { driver: 'bridge' },
[GATEWAY_NETWORK]: { external: true },
internal: { driver: 'bridge' },
},
};
const serviceNames: string[] = [];
// Add user-defined services
for (const [name, svc] of Object.entries(config.services)) {
serviceNames.push(name);
const containerName = `cw-preview-${opts.deploymentId}-${name}`;
const service: ComposeService = {
container_name: containerName,
labels: { ...opts.labels },
networks: ['preview'],
networks: svc.internal
? ['internal']
: [GATEWAY_NETWORK, 'internal'],
};
// Build config
if (opts.mode === 'dev' && svc.dev) {
// Dev mode: use dev image + mount worktree
service.image = svc.dev.image;
service.working_dir = svc.dev.workdir ?? '/app';
service.volumes = [
`${opts.projectPath}:${svc.dev.workdir ?? '/app'}`,
// Anonymous volume for node_modules to avoid host overwrite
`${svc.dev.workdir ?? '/app'}/node_modules`,
];
if (svc.dev.command) {
service.command = svc.dev.command;
}
} else {
// Preview mode: build from source
if (svc.build) {
if (typeof svc.build === 'string') {
service.build = {
@@ -78,91 +99,24 @@ export function generateComposeFile(
} 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) {
// Volumes (from config, not dev overrides)
if (opts.mode !== 'dev' && 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.
*/
@@ -171,16 +125,18 @@ export function generateLabels(opts: {
phaseId?: string;
projectId: string;
branch: string;
port: number;
gatewayPort: number;
previewId: string;
mode: 'preview' | 'dev';
}): 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.port]: String(opts.gatewayPort),
[PREVIEW_LABELS.previewId]: opts.previewId,
[PREVIEW_LABELS.mode]: opts.mode,
};
if (opts.phaseId) {

View File

@@ -112,4 +112,41 @@ services:
const config = parseCwPreviewConfig(raw);
expect(config.services.app.build).toBe('./app');
});
it('parses dev section with image, command, and workdir', () => {
const raw = `
version: 1
services:
frontend:
build: "."
port: 3000
route: /
dev:
image: node:20-alpine
command: npm run dev -- --host 0.0.0.0
workdir: /app
`;
const config = parseCwPreviewConfig(raw);
expect(config.services.frontend.dev).toBeDefined();
expect(config.services.frontend.dev!.image).toBe('node:20-alpine');
expect(config.services.frontend.dev!.command).toBe('npm run dev -- --host 0.0.0.0');
expect(config.services.frontend.dev!.workdir).toBe('/app');
});
it('parses dev section with only image', () => {
const raw = `
version: 1
services:
app:
build: "."
port: 3000
dev:
image: node:20-alpine
`;
const config = parseCwPreviewConfig(raw);
expect(config.services.app.dev).toBeDefined();
expect(config.services.app.dev!.image).toBe('node:20-alpine');
expect(config.services.app.dev!.command).toBeUndefined();
expect(config.services.app.dev!.workdir).toBeUndefined();
});
});

View File

@@ -101,6 +101,17 @@ export function parseCwPreviewConfig(raw: string): PreviewConfig {
...(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[] }),
...(svc.dev !== undefined && {
dev: {
image: (svc.dev as Record<string, unknown>).image as string,
...(typeof (svc.dev as Record<string, unknown>).command === 'string' && {
command: (svc.dev as Record<string, unknown>).command as string,
}),
...(typeof (svc.dev as Record<string, unknown>).workdir === 'string' && {
workdir: (svc.dev as Record<string, unknown>).workdir as string,
}),
},
}),
};
}

View File

@@ -41,6 +41,49 @@ export async function isDockerAvailable(): Promise<boolean> {
}
}
/**
* Ensure a Docker network exists. Creates it if missing.
*/
export async function ensureDockerNetwork(name: string): Promise<void> {
try {
await execa('docker', ['network', 'create', '--driver', 'bridge', name], {
timeout: 15000,
});
log.info({ name }, 'created docker network');
} catch (error) {
// Ignore "already exists" error
if ((error as Error).message?.includes('already exists')) {
log.debug({ name }, 'docker network already exists');
return;
}
throw error;
}
}
/**
* Remove a Docker network. Ignores errors (e.g., network in use or not found).
*/
export async function removeDockerNetwork(name: string): Promise<void> {
try {
await execa('docker', ['network', 'rm', name], { timeout: 15000 });
log.info({ name }, 'removed docker network');
} catch {
log.debug({ name }, 'failed to remove docker network (may not exist or be in use)');
}
}
/**
* Check if a Docker network exists.
*/
export async function dockerNetworkExists(name: string): Promise<boolean> {
try {
await execa('docker', ['network', 'inspect', name], { timeout: 15000 });
return true;
} catch {
return false;
}
}
/**
* Start a compose project (build and run in background).
*/
@@ -177,30 +220,3 @@ export async function getContainerLabels(projectName: string): Promise<Record<st
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 [];
}
}

View File

@@ -0,0 +1,240 @@
/**
* Gateway Manager
*
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
* subdomain requests to per-preview compose stacks on a shared Docker network.
*
* Architecture:
* .cw-previews/gateway/
* docker-compose.yml ← single Caddy container
* Caddyfile ← regenerated on each preview add/remove
*
* Caddy runs with `--watch` so it auto-reloads when the Caddyfile changes on disk.
*/
import { join } from 'node:path';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import yaml from 'js-yaml';
import { GATEWAY_PROJECT_NAME, GATEWAY_NETWORK } from './types.js';
import {
ensureDockerNetwork,
removeDockerNetwork,
composeUp,
composeDown,
composePs,
} from './docker-client.js';
import { allocatePort } from './port-allocator.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('preview:gateway');
/** Directory for preview deployment artifacts (relative to workspace root) */
const PREVIEWS_DIR = '.cw-previews';
/**
* A route entry for the gateway Caddyfile.
*/
export interface GatewayRoute {
containerName: string;
port: number;
route: string;
}
export class GatewayManager {
private readonly workspaceRoot: string;
private cachedPort: number | null = null;
constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
}
/**
* Ensure the gateway is running. Idempotent — returns the port if already up.
*
* 1. Create the shared Docker network
* 2. Check if gateway already running → return existing port
* 3. Allocate a port, write compose + empty Caddyfile, start
*/
async ensureGateway(): Promise<number> {
await ensureDockerNetwork(GATEWAY_NETWORK);
// Check if already running
const existingPort = await this.getPort();
if (existingPort !== null) {
this.cachedPort = existingPort;
log.info({ port: existingPort }, 'gateway already running');
return existingPort;
}
// Allocate a port for the gateway
const port = await allocatePort();
this.cachedPort = port;
// Write gateway compose + empty Caddyfile
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
await mkdir(gatewayDir, { recursive: true });
const composeContent = this.generateGatewayCompose(port);
await writeFile(join(gatewayDir, 'docker-compose.yml'), composeContent, 'utf-8');
// Start with an empty Caddyfile — will be populated by updateRoutes()
const emptyCaddyfile = '{\n auto_https off\n}\n';
await writeFile(join(gatewayDir, 'Caddyfile'), emptyCaddyfile, 'utf-8');
const composePath = join(gatewayDir, 'docker-compose.yml');
await composeUp(composePath, GATEWAY_PROJECT_NAME);
log.info({ port }, 'gateway started');
return port;
}
/**
* Regenerate the Caddyfile from all active previews.
* Caddy's `--watch` flag picks up the file change automatically.
*/
async updateRoutes(previews: Map<string, GatewayRoute[]>): Promise<void> {
const port = this.cachedPort ?? (await this.getPort());
if (port === null) {
log.warn('cannot update routes — gateway not running');
return;
}
const caddyfile = generateGatewayCaddyfile(previews, port);
const caddyfilePath = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway', 'Caddyfile');
await writeFile(caddyfilePath, caddyfile, 'utf-8');
log.info({ previewCount: previews.size }, 'gateway routes updated');
}
/**
* Stop the gateway and remove the shared network.
*/
async stopGateway(): Promise<void> {
await composeDown(GATEWAY_PROJECT_NAME).catch(() => {});
await removeDockerNetwork(GATEWAY_NETWORK);
const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway');
await rm(gatewayDir, { recursive: true, force: true }).catch(() => {});
this.cachedPort = null;
log.info('gateway stopped');
}
/**
* Check if the gateway compose project is running.
*/
async isRunning(): Promise<boolean> {
const services = await composePs(GATEWAY_PROJECT_NAME);
return services.some((s) => s.state === 'running');
}
/**
* Read the gateway port from the running container's labels.
* Returns null if the gateway isn't running.
*/
async getPort(): Promise<number | null> {
if (this.cachedPort !== null) {
// Verify the container is still running
if (await this.isRunning()) {
return this.cachedPort;
}
this.cachedPort = null;
}
try {
const { execa } = await import('execa');
const result = await execa('docker', [
'ps',
'--filter', `label=cw.gateway=true`,
'--format', `{{.Label "cw.gateway-port"}}`,
], { timeout: 15000 });
if (!result.stdout.trim()) {
return null;
}
const port = parseInt(result.stdout.trim().split('\n')[0], 10);
if (isNaN(port)) return null;
this.cachedPort = port;
return port;
} catch {
return null;
}
}
/**
* Generate the gateway docker-compose.yml content.
*/
private generateGatewayCompose(port: number): string {
const compose = {
services: {
caddy: {
image: 'caddy:2-alpine',
command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile', '--watch'],
ports: [`${port}:80`],
volumes: ['./Caddyfile:/etc/caddy/Caddyfile:rw'],
networks: [GATEWAY_NETWORK],
labels: {
'cw.gateway': 'true',
'cw.gateway-port': String(port),
},
},
},
networks: {
[GATEWAY_NETWORK]: {
external: true,
},
},
};
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
}
}
/**
* Generate a Caddyfile for the gateway from all active preview routes.
*
* Each preview gets a subdomain block: `<previewId>.localhost:<port>`
* Routes within a preview are sorted by specificity (longest path first).
*/
export function generateGatewayCaddyfile(
previews: Map<string, GatewayRoute[]>,
port: number,
): string {
const lines: string[] = [
'{',
' auto_https off',
'}',
'',
];
for (const [previewId, routes] of previews) {
// Sort routes by specificity (longer paths first, root last)
const sorted = [...routes].sort((a, b) => {
if (a.route === '/') return 1;
if (b.route === '/') return -1;
return b.route.length - a.route.length;
});
lines.push(`${previewId}.localhost:${port} {`);
for (const route of sorted) {
if (route.route === '/') {
lines.push(` handle {`);
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
lines.push(` }`);
} else {
const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route;
lines.push(` handle_path ${path}/* {`);
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
lines.push(` }`);
}
}
lines.push('}');
lines.push('');
}
return lines.join('\n');
}

View File

@@ -1,7 +1,7 @@
/**
* Health Checker
*
* Polls service healthcheck endpoints through the Caddy proxy port
* Polls service healthcheck endpoints through the gateway's subdomain routing
* to verify that preview services are ready.
*/
@@ -18,15 +18,17 @@ const DEFAULT_INTERVAL_MS = 3_000;
/**
* Wait for all non-internal services to become healthy by polling their
* healthcheck endpoints through the Caddy proxy.
* healthcheck endpoints through the gateway's subdomain routing.
*
* @param port - The host port where Caddy is listening
* @param previewId - The preview deployment ID (used as subdomain)
* @param gatewayPort - The gateway's host port
* @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,
previewId: string,
gatewayPort: number,
config: PreviewConfig,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<HealthResult[]> {
@@ -57,9 +59,8 @@ export async function waitForHealthy(
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}`;
const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`;
try {
const response = await fetch(url, {

View File

@@ -6,9 +6,17 @@ export { PreviewManager } from './manager.js';
export { discoverConfig, parseCwPreviewConfig } from './config-reader.js';
export {
generateComposeFile,
generateCaddyfile,
generateLabels,
} from './compose-generator.js';
export {
GatewayManager,
generateGatewayCaddyfile,
} from './gateway.js';
export type { GatewayRoute } from './gateway.js';
export {
createPreviewWorktree,
removePreviewWorktree,
} from './worktree.js';
export {
isDockerAvailable,
composeUp,
@@ -16,12 +24,16 @@ export {
composePs,
listPreviewProjects,
getContainerLabels,
ensureDockerNetwork,
removeDockerNetwork,
dockerNetworkExists,
} from './docker-client.js';
export { waitForHealthy } from './health-checker.js';
export { allocatePort } from './port-allocator.js';
export type {
PreviewConfig,
PreviewServiceConfig,
PreviewServiceDevConfig,
PreviewStatus,
StartPreviewOptions,
HealthResult,
@@ -29,4 +41,6 @@ export type {
export {
PREVIEW_LABELS,
COMPOSE_PROJECT_PREFIX,
GATEWAY_PROJECT_NAME,
GATEWAY_NETWORK,
} from './types.js';

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { EventBus, DomainEvent } from '../events/types.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js';
// Mock all external dependencies before imports
@@ -11,7 +13,9 @@ vi.mock('./docker-client.js', () => ({
composePs: vi.fn(),
listPreviewProjects: vi.fn(),
getContainerLabels: vi.fn(),
getPreviewPorts: vi.fn(),
ensureDockerNetwork: vi.fn(),
removeDockerNetwork: vi.fn(),
dockerNetworkExists: vi.fn(),
}));
vi.mock('./config-reader.js', () => ({
@@ -26,6 +30,35 @@ vi.mock('./health-checker.js', () => ({
waitForHealthy: vi.fn(),
}));
vi.mock('./worktree.js', () => ({
createPreviewWorktree: vi.fn().mockResolvedValue(undefined),
removePreviewWorktree: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('simple-git', () => ({
simpleGit: vi.fn(() => ({
fetch: vi.fn().mockResolvedValue(undefined),
})),
}));
// Mock gateway to prevent it from consuming docker-client mock values
const mockGatewayInstance = {
ensureGateway: vi.fn().mockResolvedValue(9100),
updateRoutes: vi.fn().mockResolvedValue(undefined),
stopGateway: vi.fn().mockResolvedValue(undefined),
isRunning: vi.fn().mockResolvedValue(false),
getPort: vi.fn().mockResolvedValue(null),
};
vi.mock('./gateway.js', () => {
const MockGatewayManager = function() {
return mockGatewayInstance;
};
return {
GatewayManager: MockGatewayManager,
generateGatewayCaddyfile: vi.fn().mockReturnValue(''),
};
});
vi.mock('node:fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
@@ -48,6 +81,7 @@ import {
import { discoverConfig } from './config-reader.js';
import { allocatePort } from './port-allocator.js';
import { waitForHealthy } from './health-checker.js';
import { createPreviewWorktree, removePreviewWorktree } from './worktree.js';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import type { PreviewConfig } from './types.js';
@@ -60,6 +94,7 @@ const mockGetContainerLabels = vi.mocked(getContainerLabels);
const mockDiscoverConfig = vi.mocked(discoverConfig);
const mockAllocatePort = vi.mocked(allocatePort);
const mockWaitForHealthy = vi.mocked(waitForHealthy);
const mockCreatePreviewWorktree = vi.mocked(createPreviewWorktree);
const mockMkdir = vi.mocked(mkdir);
const mockWriteFile = vi.mocked(writeFile);
const mockRm = vi.mocked(rm);
@@ -96,9 +131,33 @@ function createMockProjectRepo(project = {
delete: vi.fn(),
setInitiativeProjects: vi.fn(),
getInitiativeProjects: vi.fn(),
findProjectsByInitiativeId: vi.fn().mockResolvedValue([project]),
} as unknown as ProjectRepository;
}
function createMockPhaseRepo(): PhaseRepository {
return {
findById: vi.fn(),
findByInitiativeId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getNextNumber: vi.fn(),
findByNumber: vi.fn(),
} as unknown as PhaseRepository;
}
function createMockInitiativeRepo(): InitiativeRepository {
return {
findById: vi.fn(),
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findByStatus: vi.fn(),
} as unknown as InitiativeRepository;
}
const WORKSPACE_ROOT = '/tmp/test-workspace';
const SIMPLE_CONFIG: PreviewConfig = {
@@ -117,24 +176,33 @@ describe('PreviewManager', () => {
let manager: PreviewManager;
let eventBus: EventBus & { emitted: DomainEvent[] };
let projectRepo: ProjectRepository;
let phaseRepo: PhaseRepository;
let initiativeRepo: InitiativeRepository;
beforeEach(() => {
vi.clearAllMocks();
// Reset gateway mock to defaults after clearAllMocks wipes implementations
mockGatewayInstance.ensureGateway.mockResolvedValue(9100);
mockGatewayInstance.updateRoutes.mockResolvedValue(undefined);
mockGatewayInstance.stopGateway.mockResolvedValue(undefined);
mockGatewayInstance.isRunning.mockResolvedValue(false);
mockGatewayInstance.getPort.mockResolvedValue(null);
eventBus = createMockEventBus();
projectRepo = createMockProjectRepo();
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
phaseRepo = createMockPhaseRepo();
initiativeRepo = createMockInitiativeRepo();
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);
});
describe('start', () => {
it('completes the full start lifecycle for a healthy service', async () => {
it('completes the full start lifecycle with gateway architecture', async () => {
mockIsDockerAvailable.mockResolvedValue(true);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockAllocatePort.mockResolvedValue(9100);
mockComposeUp.mockResolvedValue(undefined);
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockCreatePreviewWorktree.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);
mockComposePs.mockResolvedValue([
{ name: 'app', state: 'running', health: 'healthy' },
]);
const result = await manager.start({
initiativeId: 'init-1',
@@ -142,57 +210,37 @@ describe('PreviewManager', () => {
branch: 'feature-x',
});
// Verify returned status
// Verify returned status uses gateway fields
expect(result.id).toBe('abc123test');
expect(result.projectName).toBe('cw-preview-abc123test');
expect(result.initiativeId).toBe('init-1');
expect(result.projectId).toBe('proj-1');
expect(result.branch).toBe('feature-x');
expect(result.port).toBe(9100);
expect(result.gatewayPort).toBe(9100);
expect(result.url).toBe('http://abc123test.localhost:9100');
expect(result.mode).toBe('preview');
expect(result.status).toBe('running');
expect(result.services).toHaveLength(1);
// Verify Docker was called
expect(mockIsDockerAvailable).toHaveBeenCalledOnce();
expect(mockComposeUp).toHaveBeenCalledWith(
expect.stringContaining('.cw-previews/abc123test/docker-compose.yml'),
'cw-preview-abc123test',
);
// Verify compose artifacts were written
expect(mockMkdir).toHaveBeenCalledWith(
expect.stringContaining('.cw-previews/abc123test'),
{ recursive: true },
);
expect(mockWriteFile).toHaveBeenCalledTimes(2); // compose + Caddyfile
// Verify worktree was created for preview mode
expect(mockCreatePreviewWorktree).toHaveBeenCalledOnce();
// Verify events: building then ready
expect(eventBus.emitted).toHaveLength(2);
expect(eventBus.emitted[0].type).toBe('preview:building');
expect(eventBus.emitted[0].payload).toEqual(
expect.objectContaining({
previewId: 'abc123test',
initiativeId: 'init-1',
branch: 'feature-x',
port: 9100,
}),
);
expect(eventBus.emitted[1].type).toBe('preview:ready');
expect(eventBus.emitted[1].payload).toEqual(
expect.objectContaining({
previewId: 'abc123test',
url: 'http://localhost:9100',
}),
const buildingEvent = eventBus.emitted.find((e) => e.type === 'preview:building');
const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
expect(buildingEvent).toBeDefined();
expect(readyEvent).toBeDefined();
expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
'http://abc123test.localhost:9100',
);
});
it('includes phaseId when provided', async () => {
mockIsDockerAvailable.mockResolvedValue(true);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockAllocatePort.mockResolvedValue(9100);
mockComposeUp.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([]);
mockComposePs.mockResolvedValue([]);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockCreatePreviewWorktree.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([]);
const result = await manager.start({
initiativeId: 'init-1',
@@ -223,7 +271,7 @@ describe('PreviewManager', () => {
mockIsDockerAvailable.mockResolvedValue(true);
projectRepo = createMockProjectRepo();
(projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(null);
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo);
await expect(
manager.start({
@@ -237,7 +285,7 @@ describe('PreviewManager', () => {
it('emits preview:failed and cleans up when compose up fails', async () => {
mockIsDockerAvailable.mockResolvedValue(true);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockAllocatePort.mockResolvedValue(9100);
mockCreatePreviewWorktree.mockResolvedValue(undefined);
mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found'));
mockComposeDown.mockResolvedValue(undefined);
@@ -247,52 +295,11 @@ describe('PreviewManager', () => {
projectId: 'proj-1',
branch: 'main',
}),
).rejects.toThrow('Preview build failed: build error: Dockerfile not found');
).rejects.toThrow('Preview build failed');
// Events: building, then failed
expect(eventBus.emitted).toHaveLength(2);
expect(eventBus.emitted[0].type).toBe('preview:building');
expect(eventBus.emitted[1].type).toBe('preview:failed');
expect((eventBus.emitted[1].payload as Record<string, unknown>).error).toBe(
'build error: Dockerfile not found',
);
// Cleanup was attempted
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test');
expect(mockRm).toHaveBeenCalledWith(
expect.stringContaining('.cw-previews/abc123test'),
{ recursive: true, force: true },
);
});
it('emits preview:failed and cleans up when health checks fail', async () => {
mockIsDockerAvailable.mockResolvedValue(true);
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
mockAllocatePort.mockResolvedValue(9101);
mockComposeUp.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([
{ name: 'app', healthy: false, error: 'health check timed out' },
]);
mockComposeDown.mockResolvedValue(undefined);
await expect(
manager.start({
initiativeId: 'init-1',
projectId: 'proj-1',
branch: 'main',
}),
).rejects.toThrow('Preview health checks failed for services: app');
// Events: building, then failed
expect(eventBus.emitted).toHaveLength(2);
expect(eventBus.emitted[1].type).toBe('preview:failed');
expect((eventBus.emitted[1].payload as Record<string, unknown>).error).toBe(
'Health checks failed for: app',
);
// Cleanup
expect(mockComposeDown).toHaveBeenCalled();
expect(mockRm).toHaveBeenCalled();
const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed');
expect(failedEvent).toBeDefined();
});
it('succeeds when no healthcheck endpoints are configured', async () => {
@@ -302,11 +309,11 @@ describe('PreviewManager', () => {
};
mockIsDockerAvailable.mockResolvedValue(true);
mockDiscoverConfig.mockResolvedValue(noHealthConfig);
mockAllocatePort.mockResolvedValue(9100);
mockComposeUp.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([]); // no health endpoints → empty results
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
mockDiscoverConfig.mockResolvedValue(noHealthConfig);
mockCreatePreviewWorktree.mockResolvedValue(undefined);
mockWaitForHealthy.mockResolvedValue([]);
const result = await manager.start({
initiativeId: 'init-1',
@@ -315,8 +322,8 @@ describe('PreviewManager', () => {
});
expect(result.status).toBe('running');
// Should succeed — empty health results means allHealthy is vacuously true
expect(eventBus.emitted[1].type).toBe('preview:ready');
const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready');
expect(readyEvent).toBeDefined();
});
});
@@ -325,6 +332,8 @@ describe('PreviewManager', () => {
mockGetContainerLabels.mockResolvedValue({
[PREVIEW_LABELS.preview]: 'true',
[PREVIEW_LABELS.initiativeId]: 'init-1',
[PREVIEW_LABELS.mode]: 'preview',
[PREVIEW_LABELS.projectId]: 'proj-1',
});
mockComposeDown.mockResolvedValue(undefined);
@@ -336,14 +345,10 @@ describe('PreviewManager', () => {
{ recursive: true, force: true },
);
expect(eventBus.emitted).toHaveLength(1);
expect(eventBus.emitted[0].type).toBe('preview:stopped');
expect(eventBus.emitted[0].payload).toEqual(
expect.objectContaining({
previewId: 'abc123test',
initiativeId: 'init-1',
}),
);
const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
expect(stoppedEvent).toBeDefined();
expect((stoppedEvent!.payload as Record<string, unknown>).previewId).toBe('abc123test');
expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('init-1');
});
it('emits empty initiativeId when labels are missing', async () => {
@@ -352,8 +357,9 @@ describe('PreviewManager', () => {
await manager.stop('xyz');
expect(eventBus.emitted).toHaveLength(1);
expect((eventBus.emitted[0].payload as Record<string, unknown>).initiativeId).toBe('');
const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped');
expect(stoppedEvent).toBeDefined();
expect((stoppedEvent!.payload as Record<string, unknown>).initiativeId).toBe('');
});
});
@@ -372,14 +378,16 @@ describe('PreviewManager', () => {
[PREVIEW_LABELS.branch]: 'feat-a',
[PREVIEW_LABELS.port]: '9100',
[PREVIEW_LABELS.previewId]: 'aaa',
[PREVIEW_LABELS.mode]: 'preview',
})
.mockResolvedValueOnce({
[PREVIEW_LABELS.preview]: 'true',
[PREVIEW_LABELS.initiativeId]: 'init-2',
[PREVIEW_LABELS.projectId]: 'proj-2',
[PREVIEW_LABELS.branch]: 'feat-b',
[PREVIEW_LABELS.port]: '9101',
[PREVIEW_LABELS.port]: '9100',
[PREVIEW_LABELS.previewId]: 'bbb',
[PREVIEW_LABELS.mode]: 'dev',
});
mockComposePs
@@ -390,10 +398,13 @@ describe('PreviewManager', () => {
expect(previews).toHaveLength(2);
expect(previews[0].id).toBe('aaa');
expect(previews[0].port).toBe(9100);
expect(previews[0].gatewayPort).toBe(9100);
expect(previews[0].url).toBe('http://aaa.localhost:9100');
expect(previews[0].mode).toBe('preview');
expect(previews[0].services).toHaveLength(1);
expect(previews[1].id).toBe('bbb');
expect(previews[1].port).toBe(9101);
expect(previews[1].gatewayPort).toBe(9100);
expect(previews[1].mode).toBe('dev');
});
it('filters by initiativeId when provided', async () => {
@@ -416,7 +427,7 @@ describe('PreviewManager', () => {
[PREVIEW_LABELS.initiativeId]: 'init-2',
[PREVIEW_LABELS.projectId]: 'proj-2',
[PREVIEW_LABELS.branch]: 'feat-b',
[PREVIEW_LABELS.port]: '9101',
[PREVIEW_LABELS.port]: '9100',
[PREVIEW_LABELS.previewId]: 'bbb',
});
@@ -428,14 +439,28 @@ describe('PreviewManager', () => {
expect(previews[0].initiativeId).toBe('init-1');
});
it('skips projects without cw.preview label', async () => {
it('skips gateway project from listing', async () => {
mockListPreviewProjects.mockResolvedValue([
{ Name: 'cw-preview-orphan', Status: 'running(1)', ConfigFiles: '' },
{ Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
{ Name: 'cw-preview-aaa', Status: 'running(1)', ConfigFiles: '' },
]);
mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label
mockGetContainerLabels.mockResolvedValue({
[PREVIEW_LABELS.preview]: 'true',
[PREVIEW_LABELS.initiativeId]: 'init-1',
[PREVIEW_LABELS.projectId]: 'proj-1',
[PREVIEW_LABELS.branch]: 'main',
[PREVIEW_LABELS.port]: '9100',
[PREVIEW_LABELS.previewId]: 'aaa',
});
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
const previews = await manager.list();
expect(previews).toHaveLength(0);
// Should only include actual previews, not gateway
expect(previews).toHaveLength(1);
expect(previews[0].id).toBe('aaa');
});
it('skips projects with incomplete labels', async () => {
@@ -460,13 +485,13 @@ describe('PreviewManager', () => {
[PREVIEW_LABELS.branch]: 'main',
[PREVIEW_LABELS.port]: '9100',
[PREVIEW_LABELS.previewId]: 'abc',
[PREVIEW_LABELS.mode]: 'preview',
};
it('returns running when all services are running', async () => {
mockGetContainerLabels.mockResolvedValue(labels);
mockComposePs.mockResolvedValue([
{ name: 'app', state: 'running', health: 'healthy' },
{ name: 'caddy-proxy', state: 'running', health: 'none' },
]);
const status = await manager.getStatus('abc');
@@ -474,14 +499,15 @@ describe('PreviewManager', () => {
expect(status).not.toBeNull();
expect(status!.status).toBe('running');
expect(status!.id).toBe('abc');
expect(status!.port).toBe(9100);
expect(status!.gatewayPort).toBe(9100);
expect(status!.url).toBe('http://abc.localhost:9100');
expect(status!.mode).toBe('preview');
});
it('returns failed when any service is exited', async () => {
mockGetContainerLabels.mockResolvedValue(labels);
mockComposePs.mockResolvedValue([
{ name: 'app', state: 'exited', health: 'none' },
{ name: 'caddy-proxy', state: 'running', health: 'none' },
]);
const status = await manager.getStatus('abc');
@@ -515,8 +541,9 @@ describe('PreviewManager', () => {
});
describe('stopAll', () => {
it('stops all preview projects', async () => {
it('stops all preview projects and the gateway', async () => {
mockListPreviewProjects.mockResolvedValue([
{ Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' },
{ Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
{ Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
]);
@@ -529,32 +556,12 @@ describe('PreviewManager', () => {
await manager.stopAll();
expect(mockComposeDown).toHaveBeenCalledTimes(2);
// Should stop preview projects but not call stop() on gateway directly
// (gateway is handled separately via stopGateway)
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa');
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb');
expect(eventBus.emitted.filter((e) => e.type === 'preview:stopped')).toHaveLength(2);
});
it('continues stopping other previews when one fails', async () => {
mockListPreviewProjects.mockResolvedValue([
{ Name: 'cw-preview-fail', Status: 'running(1)', ConfigFiles: '' },
{ Name: 'cw-preview-ok', Status: 'running(1)', ConfigFiles: '' },
]);
mockGetContainerLabels.mockResolvedValue({
[PREVIEW_LABELS.initiativeId]: 'init-1',
});
// First stop fails, second succeeds
mockComposeDown
.mockRejectedValueOnce(new Error('docker daemon not responding'))
.mockResolvedValueOnce(undefined);
// Should not throw — errors are caught per-project
await manager.stopAll();
// Second preview still stopped successfully
expect(mockComposeDown).toHaveBeenCalledTimes(2);
// Gateway is stopped via the mocked GatewayManager.stopGateway()
expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
});
it('handles empty project list gracefully', async () => {
@@ -562,8 +569,9 @@ describe('PreviewManager', () => {
await manager.stopAll();
// No preview composeDown calls, but gateway stopGateway still called
expect(mockComposeDown).not.toHaveBeenCalled();
expect(eventBus.emitted).toHaveLength(0);
expect(mockGatewayInstance.stopGateway).toHaveBeenCalled();
});
});
});

View File

@@ -2,21 +2,25 @@
* Preview Manager
*
* Orchestrates preview deployment lifecycle: start, stop, list, status.
* Uses Docker as the source of truth — no database persistence.
* Uses a shared gateway (single Caddy container) for subdomain-based routing.
* Docker is 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 { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { EventBus } from '../events/types.js';
import type {
PreviewStatus,
StartPreviewOptions,
PreviewConfig,
} 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 { generateComposeFile, generateLabels } from './compose-generator.js';
import {
isDockerAvailable,
composeUp,
@@ -26,14 +30,18 @@ import {
getContainerLabels,
} from './docker-client.js';
import { waitForHealthy } from './health-checker.js';
import { allocatePort } from './port-allocator.js';
import { GatewayManager } from './gateway.js';
import type { GatewayRoute } from './gateway.js';
import { createPreviewWorktree, removePreviewWorktree } from './worktree.js';
import { getProjectCloneDir } from '../git/project-clones.js';
import { phaseBranchName } from '../git/branch-naming.js';
import { createModuleLogger } from '../logger/index.js';
import type {
PreviewBuildingEvent,
PreviewReadyEvent,
PreviewStoppedEvent,
PreviewFailedEvent,
PhasePendingReviewEvent,
} from '../events/types.js';
const log = createModuleLogger('preview');
@@ -45,29 +53,44 @@ export class PreviewManager {
private readonly projectRepository: ProjectRepository;
private readonly eventBus: EventBus;
private readonly workspaceRoot: string;
private readonly phaseRepository: PhaseRepository;
private readonly initiativeRepository: InitiativeRepository;
private readonly gatewayManager: GatewayManager;
/** In-memory tracking of active preview routes for fast Caddyfile regeneration */
private readonly activeRoutes = new Map<string, GatewayRoute[]>();
constructor(
projectRepository: ProjectRepository,
eventBus: EventBus,
workspaceRoot: string,
phaseRepository: PhaseRepository,
initiativeRepository: InitiativeRepository,
) {
this.projectRepository = projectRepository;
this.eventBus = eventBus;
this.workspaceRoot = workspaceRoot;
this.phaseRepository = phaseRepository;
this.initiativeRepository = initiativeRepository;
this.gatewayManager = new GatewayManager(workspaceRoot);
this.setupEventListeners();
}
/**
* 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
* 2. Ensure gateway is running
* 3. Resolve project + clone path
* 4. Preview mode: fetch + worktree; Dev mode: use provided worktreePath
* 5. Discover config, generate compose (no Caddy sidecar)
* 6. composeUp, update gateway routes, health check
* 7. Emit events and return status
*/
async start(options: StartPreviewOptions): Promise<PreviewStatus> {
const mode = options.mode ?? 'preview';
// 1. Check Docker
if (!(await isDockerAvailable())) {
throw new Error(
@@ -75,7 +98,10 @@ export class PreviewManager {
);
}
// 2. Resolve project
// 2. Ensure gateway
const gatewayPort = await this.gatewayManager.ensureGateway();
// 3. Resolve project
const project = await this.projectRepository.findById(options.projectId);
if (!project) {
throw new Error(`Project '${options.projectId}' not found`);
@@ -86,74 +112,87 @@ export class PreviewManager {
getProjectCloneDir(project.name, project.id),
);
// 3. Discover config
const config = await discoverConfig(clonePath);
// 4. Allocate port and generate ID
const port = await allocatePort();
// 4. Generate ID and prepare deploy dir
const id = nanoid(10);
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
await mkdir(deployDir, { recursive: true });
// 5. Generate compose artifacts
let sourcePath: string;
let worktreeCreated = false;
try {
if (mode === 'preview') {
// Fetch latest and create a worktree for the target branch
try {
const { simpleGit } = await import('simple-git');
await simpleGit(clonePath).fetch();
} catch (fetchErr) {
log.warn({ err: fetchErr }, 'git fetch failed (may be offline)');
}
const worktreePath = join(deployDir, 'source');
await createPreviewWorktree(clonePath, options.branch, worktreePath);
worktreeCreated = true;
sourcePath = worktreePath;
} else {
// Dev mode: use the provided worktree path
if (!options.worktreePath) {
throw new Error('worktreePath is required for dev mode');
}
sourcePath = options.worktreePath;
}
// 5. Discover config from source
const config = await discoverConfig(sourcePath);
// 6. Generate compose artifacts
const labels = generateLabels({
initiativeId: options.initiativeId,
phaseId: options.phaseId,
projectId: options.projectId,
branch: options.branch,
port,
gatewayPort,
previewId: id,
mode,
});
const composeYaml = generateComposeFile(config, {
projectPath: clonePath,
port,
projectPath: sourcePath,
deploymentId: id,
labels,
mode,
});
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');
log.info({ id, projectName, gatewayPort, composePath, mode }, 'preview deployment prepared');
// 6. Emit building event
// 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,
branch: options.branch,
gatewayPort,
mode,
phaseId: options.phaseId,
},
});
// Clean up
await composeDown(projectName).catch(() => {});
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
// 7. Build and start
await composeUp(composePath, projectName);
throw new Error(`Preview build failed: ${(error as Error).message}`);
}
// 8. Build gateway routes and update Caddyfile
const routes = this.buildRoutes(id, config);
this.activeRoutes.set(id, routes);
await this.gatewayManager.updateRoutes(this.activeRoutes);
// 8. Health check
const healthResults = await waitForHealthy(port, config);
// 9. Health check
const healthResults = await waitForHealthy(id, gatewayPort, config);
const allHealthy = healthResults.every((r) => r.healthy);
if (!allHealthy && healthResults.length > 0) {
@@ -172,16 +211,27 @@ export class PreviewManager {
},
});
// Clean up
await composeDown(projectName).catch(() => {});
this.activeRoutes.delete(id);
await this.gatewayManager.updateRoutes(this.activeRoutes);
if (worktreeCreated) {
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
}
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
// Stop gateway if no more previews
if (this.activeRoutes.size === 0) {
await this.gatewayManager.stopGateway().catch(() => {});
}
throw new Error(
`Preview health checks failed for services: ${failedServices.join(', ')}`,
);
}
// 9. Success
const url = `http://localhost:${port}`;
// 10. Success
const url = `http://${id}.localhost:${gatewayPort}`;
log.info({ id, url }, 'preview deployment ready');
this.eventBus.emit<PreviewReadyEvent>({
@@ -191,8 +241,10 @@ export class PreviewManager {
previewId: id,
initiativeId: options.initiativeId,
branch: options.branch,
port,
gatewayPort,
url,
mode,
phaseId: options.phaseId,
},
});
@@ -205,11 +257,44 @@ export class PreviewManager {
phaseId: options.phaseId,
projectId: options.projectId,
branch: options.branch,
port,
gatewayPort,
url,
mode,
status: 'running',
services,
composePath,
};
} catch (error) {
// Clean up on any failure
if (worktreeCreated) {
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
}
// Only emit failed if we haven't already (health check path emits its own)
const isHealthCheckError = (error as Error).message?.includes('health checks failed');
if (!isHealthCheckError) {
this.eventBus.emit<PreviewFailedEvent>({
type: 'preview:failed',
timestamp: new Date(),
payload: {
previewId: id,
initiativeId: options.initiativeId,
error: (error as Error).message,
},
});
await composeDown(projectName).catch(() => {});
this.activeRoutes.delete(id);
await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {});
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
if (this.activeRoutes.size === 0) {
await this.gatewayManager.stopGateway().catch(() => {});
}
}
throw new Error(`Preview build failed: ${(error as Error).message}`);
}
}
/**
@@ -218,16 +303,41 @@ export class PreviewManager {
async stop(previewId: string): Promise<void> {
const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`;
// Get labels before stopping to emit event
// Get labels before stopping to emit event and check mode
const labels = await getContainerLabels(projectName);
const initiativeId = labels[PREVIEW_LABELS.initiativeId] ?? '';
const mode = labels[PREVIEW_LABELS.mode] as 'preview' | 'dev' | undefined;
await composeDown(projectName);
// Clean up deployment directory
// Remove worktree if preview mode
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, previewId);
if (mode === 'preview' || mode === undefined) {
const projectId = labels[PREVIEW_LABELS.projectId];
if (projectId) {
const project = await this.projectRepository.findById(projectId);
if (project) {
const clonePath = join(
this.workspaceRoot,
getProjectCloneDir(project.name, project.id),
);
await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {});
}
}
}
// Clean up deployment directory
await rm(deployDir, { recursive: true, force: true }).catch(() => {});
// Update gateway routes
this.activeRoutes.delete(previewId);
await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {});
// Stop gateway if no more active previews
if (this.activeRoutes.size === 0) {
await this.gatewayManager.stopGateway().catch(() => {});
}
log.info({ previewId, projectName }, 'preview stopped');
this.eventBus.emit<PreviewStoppedEvent>({
@@ -245,6 +355,9 @@ export class PreviewManager {
const previews: PreviewStatus[] = [];
for (const project of projects) {
// Skip the gateway project
if (project.Name === 'cw-preview-gateway') continue;
const labels = await getContainerLabels(project.Name);
if (!labels[PREVIEW_LABELS.preview]) continue;
@@ -292,20 +405,95 @@ export class PreviewManager {
}
/**
* Stop all preview deployments. Called on server shutdown.
* Stop all preview deployments, then stop the gateway.
*/
async stopAll(): Promise<void> {
const projects = await listPreviewProjects();
log.info({ count: projects.length }, 'stopping all preview deployments');
const previewProjects = projects.filter((p) => p.Name !== 'cw-preview-gateway');
log.info({ count: previewProjects.length }, 'stopping all preview deployments');
await Promise.all(
projects.map(async (project) => {
previewProjects.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');
});
}),
);
// Ensure gateway is stopped
await this.gatewayManager.stopGateway().catch(() => {});
}
/**
* Register event listener for auto-starting previews on phase:pending_review.
*/
private setupEventListeners(): void {
this.eventBus.on<PhasePendingReviewEvent>(
'phase:pending_review',
async (event) => {
try {
const { phaseId, initiativeId } = event.payload;
const initiative = await this.initiativeRepository.findById(initiativeId);
if (!initiative?.branch) {
log.debug({ initiativeId }, 'no initiative branch, skipping auto-preview');
return;
}
const phase = await this.phaseRepository.findById(phaseId);
if (!phase) {
log.debug({ phaseId }, 'phase not found, skipping auto-preview');
return;
}
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
if (projects.length !== 1) {
log.debug(
{ initiativeId, projectCount: projects.length },
'auto-preview requires exactly one project',
);
return;
}
const branch = phaseBranchName(initiative.branch, phase.name);
log.info(
{ initiativeId, phaseId, branch, projectId: projects[0].id },
'auto-starting preview for pending_review phase',
);
await this.start({
initiativeId,
phaseId,
projectId: projects[0].id,
branch,
mode: 'preview',
});
} catch (error) {
log.warn({ err: error }, 'auto-preview failed (best-effort)');
}
},
);
}
/**
* Build gateway routes from a preview config.
*/
private buildRoutes(previewId: string, config: PreviewConfig): GatewayRoute[] {
const routes: GatewayRoute[] = [];
for (const [name, svc] of Object.entries(config.services)) {
if (svc.internal) continue;
routes.push({
containerName: `cw-preview-${previewId}-${name}`,
port: svc.port,
route: svc.route ?? '/',
});
}
return routes;
}
/**
@@ -320,7 +508,8 @@ export class PreviewManager {
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);
const gatewayPort = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10);
const mode = (labels[PREVIEW_LABELS.mode] as 'preview' | 'dev') ?? 'preview';
if (!initiativeId || !projectId || !branch) {
return null;
@@ -333,7 +522,9 @@ export class PreviewManager {
phaseId: labels[PREVIEW_LABELS.phaseId],
projectId,
branch,
port,
gatewayPort,
url: `http://${previewId}.localhost:${gatewayPort}`,
mode,
status: 'running',
services: [],
composePath,

View File

@@ -1,42 +1,19 @@
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([]);
it('returns BASE_PORT (9100) when the port is available', async () => {
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) => {

View File

@@ -1,12 +1,12 @@
/**
* Port Allocator
*
* Finds the next available port for a preview deployment.
* Queries running preview containers and performs a bind test.
* Finds the next available port for the gateway.
* Only called once when the gateway first starts — subsequent previews
* reuse the same gateway port.
*/
import { createServer } from 'node:net';
import { getPreviewPorts } from './docker-client.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('preview:port');
@@ -18,22 +18,13 @@ const BASE_PORT = 9100;
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
* Allocate the next available port by performing a bind test.
*
* @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;

View File

@@ -5,6 +5,16 @@
* Docker IS the source of truth — no database table needed.
*/
/**
* Dev mode configuration for a service.
* Used when running from an agent's worktree with hot-reload.
*/
export interface PreviewServiceDevConfig {
image: string;
command?: string;
workdir?: string;
}
/**
* Service configuration within a preview deployment.
*/
@@ -18,6 +28,7 @@ export interface PreviewServiceConfig {
healthcheck?: { path: string; interval?: string; retries?: number };
env?: Record<string, string>;
volumes?: string[];
dev?: PreviewServiceDevConfig;
}
/**
@@ -41,7 +52,9 @@ export interface PreviewStatus {
phaseId?: string;
projectId: string;
branch: string;
port: number;
gatewayPort: number;
url: string;
mode: 'preview' | 'dev';
status: 'building' | 'running' | 'stopped' | 'failed';
services: Array<{ name: string; state: string; health: string }>;
composePath: string;
@@ -59,6 +72,7 @@ export const PREVIEW_LABELS = {
projectId: `${PREVIEW_LABEL_PREFIX}.project-id`,
port: `${PREVIEW_LABEL_PREFIX}.port`,
previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`,
mode: `${PREVIEW_LABEL_PREFIX}.mode`,
} as const;
/**
@@ -66,6 +80,12 @@ export const PREVIEW_LABELS = {
*/
export const COMPOSE_PROJECT_PREFIX = 'cw-preview-';
/**
* Gateway compose project name and shared Docker network.
*/
export const GATEWAY_PROJECT_NAME = 'cw-preview-gateway';
export const GATEWAY_NETWORK = 'cw-preview-net';
/**
* Options for starting a preview deployment.
*/
@@ -74,6 +94,8 @@ export interface StartPreviewOptions {
phaseId?: string;
projectId: string;
branch: string;
mode?: 'preview' | 'dev';
worktreePath?: string;
}
/**

View File

@@ -0,0 +1,49 @@
/**
* Preview Worktree Helper
*
* Creates and removes git worktrees for preview deployments.
* Preview mode checks out a specific branch into a temp directory
* so the Docker build runs against the correct code.
*/
import { simpleGit } from 'simple-git';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('preview:worktree');
/**
* Create a git worktree at the specified destination, checking out the given branch.
* Does NOT create a new branch — the branch must already exist.
*
* @param repoPath - Path to the git repository (bare clone)
* @param branch - Branch to check out
* @param destPath - Where to create the worktree
*/
export async function createPreviewWorktree(
repoPath: string,
branch: string,
destPath: string,
): Promise<void> {
log.info({ repoPath, branch, destPath }, 'creating preview worktree');
const git = simpleGit(repoPath);
await git.raw(['worktree', 'add', destPath, branch]);
}
/**
* Remove a git worktree.
*
* @param repoPath - Path to the git repository
* @param worktreePath - Path of the worktree to remove
*/
export async function removePreviewWorktree(
repoPath: string,
worktreePath: string,
): Promise<void> {
log.info({ repoPath, worktreePath }, 'removing preview worktree');
try {
const git = simpleGit(repoPath);
await git.raw(['worktree', 'remove', worktreePath, '--force']);
} catch (error) {
log.warn({ worktreePath, err: error }, 'failed to remove worktree (may not exist)');
}
}

View File

@@ -14,6 +14,8 @@ export function previewProcedures(publicProcedure: ProcedureBuilder) {
phaseId: z.string().min(1).optional(),
projectId: z.string().min(1),
branch: z.string().min(1),
mode: z.enum(['preview', 'dev']).default('preview'),
worktreePath: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const previewManager = requirePreviewManager(ctx);

View File

@@ -1,176 +0,0 @@
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-status-active-border bg-status-active-bg px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
<div className="flex-1">
<p className="text-sm font-medium text-status-active-fg">
Building preview...
</p>
<p className="text-xs text-status-active-fg/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-status-active-border bg-status-active-bg"
: "border-status-success-border bg-status-success-bg"
}`}
>
{isBuilding ? (
<Loader2 className="h-4 w-4 animate-spin text-status-active-dot" />
) : (
<CircleDot className="h-4 w-4 text-status-success-dot" />
)}
<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-primary 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-status-error-border bg-status-error-bg px-4 py-3">
<CircleX className="h-4 w-4 text-status-error-dot" />
<div className="flex-1">
<p className="text-sm font-medium text-status-error-fg">
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>
);
}

View File

@@ -94,7 +94,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const startPreview = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
toast.success(`Preview running at http://localhost:${data.port}`);
toast.success(`Preview running at ${data.url}`);
},
onError: (err) => toast.error(`Preview failed: ${err.message}`),
});
@@ -119,7 +119,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
: preview?.status === "failed"
? ("failed" as const)
: ("idle" as const),
url: preview?.port ? `http://localhost:${preview.port}` : undefined,
url: preview?.url ?? undefined,
onStart: () =>
startPreview.mutate({
initiativeId,

View File

@@ -4,27 +4,92 @@
## 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.
Preview deployments let reviewers spin up a branch in local Docker containers. A single shared **Caddy gateway** handles subdomain routing for all active previews, accessed at `http://<previewId>.localhost:<gatewayPort>`.
Two modes:
- **Preview mode**: Checks out target branch into a git worktree, builds Docker images, serves production-like output.
- **Dev mode**: Mounts the agent's worktree into a container with a dev server image (e.g. `node:20-alpine`), enabling hot reload.
**Auto-start**: When a phase enters `pending_review`, a preview is automatically started for the phase's branch (if the initiative has exactly one project).
**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
```
.cw-previews/
gateway/
docker-compose.yml ← single Caddy container, one port
Caddyfile ← regenerated on each preview add/remove
<previewId>/
docker-compose.yml ← per-preview stack (no published ports)
source/ ← git worktree (preview mode only)
Docker:
network: cw-preview-net ← external, shared by gateway + all previews
cw-preview-gateway ← Caddy on one port, subdomain routing
cw-preview-<id> ← per-preview compose project (services only)
Routing:
<previewId>.localhost:<gatewayPort> → cw-preview-<id>-<service>:<port>
```
```
PreviewManager
├── GatewayManager (shared Caddy gateway lifecycle + Caddyfile generation)
├── 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)
├── ComposeGenerator (generate per-preview docker-compose.yml)
├── DockerClient (thin wrapper around docker compose CLI + network ops)
├── HealthChecker (poll service healthcheck endpoints via subdomain URL)
── PortAllocator (find next available port 9100-9200 for gateway)
└── Worktree helper (git worktree add/remove for preview mode)
```
### 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
1. **Start**: ensure gateway → discover config → create worktree (preview) or use provided path (dev) → generate compose → `docker compose up --build -d` update gateway routes → health check → emit `preview:ready`
2. **Stop**: `docker compose down --volumes --remove-orphans` remove worktree → clean up `.cw-previews/<id>/` update gateway routes → stop gateway if no more previews → emit `preview:stopped`
3. **List**: `docker compose ls --filter name=cw-preview` skip gateway project → parse container labels → reconstruct status
4. **Shutdown**: `stopAll()` called on server shutdown — stops all previews, then stops gateway
### Gateway
The `GatewayManager` class manages a single shared Caddy container:
- **`ensureGateway()`** — idempotent. Creates the `cw-preview-net` Docker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with `--watch` flag.
- **`updateRoutes()`** — regenerates the full Caddyfile from all active previews. Caddy's `--watch` flag auto-reloads on file change (no `docker exec` needed).
- **`stopGateway()`** — composes down the gateway, removes the Docker network, cleans up the gateway directory.
Gateway Caddyfile format:
```
{
auto_https off
}
abc123.localhost:9100 {
handle_path /api/* {
reverse_proxy cw-preview-abc123-backend:8080
}
handle {
reverse_proxy cw-preview-abc123-frontend:3000
}
}
xyz789.localhost:9100 {
handle {
reverse_proxy cw-preview-xyz789-app:3000
}
}
```
Routes are sorted by specificity (longer paths first) to ensure correct matching.
### Subdomain Routing
Previews are accessed at `http://<previewId>.localhost:<gatewayPort>`.
- **Chrome / Firefox**: Resolve `*.localhost` to `127.0.0.1` natively. No DNS config needed.
- **Safari**: Requires a `/etc/hosts` entry: `127.0.0.1 <previewId>.localhost` for each preview.
### Docker Labels
@@ -37,12 +102,21 @@ All preview containers get `cw.*` labels for metadata retrieval:
| `cw.phase-id` | Phase ID (optional) |
| `cw.project-id` | Project ID |
| `cw.branch` | Branch name |
| `cw.port` | Host port |
| `cw.port` | Gateway port |
| `cw.preview-id` | Nanoid for this deployment |
| `cw.mode` | `"preview"` or `"dev"` |
### Compose Project Naming
Project names follow `cw-preview-<nanoid>` convention. This enables filtering via `docker compose ls --filter name=cw-preview`.
- **Gateway**: `cw-preview-gateway` (single instance)
- **Previews**: `cw-preview-<nanoid>` — filtered via `docker compose ls --filter name=cw-preview`, gateway excluded from listings
- **Container names**: `cw-preview-<id>-<service>` — unique DNS names on the shared network
### Networking
- **`cw-preview-net`** — external Docker bridge network shared by gateway + all preview stacks
- **`internal`** — per-preview bridge network for inter-service communication
- Public services join both networks; internal services (e.g. databases) only join `internal`
## Configuration
@@ -65,6 +139,10 @@ services:
retries: 10
env:
VITE_API_URL: /api
dev:
image: node:20-alpine
command: npm run dev -- --host 0.0.0.0
workdir: /app
backend:
build:
@@ -85,64 +163,68 @@ services:
POSTGRES_PASSWORD: preview
```
The `dev` section is optional per service. When present and mode is `dev`:
- `image` (required) — Docker image to run
- `command` — override entrypoint
- `workdir` — container working directory (default `/app`)
In dev mode, the project directory is volume-mounted into the container and `node_modules` gets an anonymous volume to prevent host overwrite.
### 2. `docker-compose.yml` / `compose.yml` (existing compose passthrough)
If found, the existing compose file is wrapped with a Caddy sidecar.
If found, the existing compose file is used with gateway network injection.
### 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) |
| `types.ts` | PreviewConfig, PreviewStatus, labels, constants, dev config types |
| `config-reader.ts` | Discovery + YAML parsing (including `dev` section) |
| `compose-generator.ts` | Per-preview Docker Compose YAML + label generation |
| `gateway.ts` | GatewayManager class + Caddyfile generation |
| `worktree.ts` | Git worktree create/remove helpers |
| `docker-client.ts` | Docker CLI wrapper (execa) + network operations |
| `health-checker.ts` | Service readiness polling via subdomain URL |
| `port-allocator.ts` | Port 9100-9200 allocation with TCP bind test |
| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll + auto-start) |
| `index.ts` | Barrel exports |
## Events
| Event | Payload |
|-------|---------|
| `preview:building` | `{previewId, initiativeId, branch, port}` |
| `preview:ready` | `{previewId, initiativeId, branch, port, url}` |
| `preview:building` | `{previewId, initiativeId, branch, gatewayPort, mode, phaseId?}` |
| `preview:ready` | `{previewId, initiativeId, branch, gatewayPort, url, mode, phaseId?}` |
| `preview:stopped` | `{previewId, initiativeId}` |
| `preview:failed` | `{previewId, initiativeId, error}` |
## Auto-Start
`PreviewManager.setupEventListeners()` listens for `phase:pending_review` events:
1. Loads the initiative and its projects
2. If exactly one project: auto-starts a preview in `preview` mode
3. Branch is derived from `phaseBranchName(initiative.branch, phase.name)`
4. Errors are caught and logged (best-effort, never blocks the phase transition)
## tRPC Procedures
| Procedure | Type | Input |
|-----------|------|-------|
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch}` |
| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}` |
| `stopPreview` | mutation | `{previewId}` |
| `listPreviews` | query | `{initiativeId?}` |
| `getPreviewStatus` | query | `{previewId}` |
`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode.
## CLI Commands
```
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>] [--mode preview|dev]
cw preview stop <previewId>
cw preview list [--initiative <id>]
cw preview status <previewId>
@@ -150,17 +232,17 @@ cw preview status <previewId>
## Frontend
`PreviewPanel` component in the Review tab:
The Review tab shows preview status inline:
- **No preview**: "Start Preview" button
- **Building**: Spinner + "Building preview..."
- **Running**: Green dot + `http://localhost:<port>` link + Stop button
- **Running**: Green dot + `http://<id>.localhost:<port>` link + Stop button
- **Failed**: Error message + Retry button
Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
## Container Wiring
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot)`
- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)`
- Added to `Container` interface and `toContextDeps()`
- `GracefulShutdown` calls `previewManager.stopAll()` during shutdown
- `requirePreviewManager(ctx)` helper in `apps/server/trpc/routers/_helpers.ts`
@@ -168,4 +250,5 @@ Polls `getPreviewStatus` with `refetchInterval: 3000` while active.
## Dependencies
- `js-yaml` + `@types/js-yaml` — for parsing `.cw-preview.yml`
- `simple-git` — for git worktree operations
- Docker must be installed and running on the host