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:
Lukas May
2026-02-10 13:24:56 +01:00
parent 783a07bfb7
commit 270a5cb21d
31 changed files with 2243 additions and 22 deletions

View File

@@ -48,7 +48,7 @@ async function startServer(port?: number, debug?: boolean): Promise<void> {
}
// Install graceful shutdown handlers
const shutdown = new GracefulShutdown(server, container.processManager, container.logManager);
const shutdown = new GracefulShutdown(server, container.processManager, container.logManager, container.previewManager);
shutdown.install();
}
@@ -1259,6 +1259,109 @@ export function createCli(serverHandler?: (port?: number) => Promise<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;
}

View File

@@ -44,6 +44,7 @@ import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
import type { BranchManager } from './git/branch-manager.js';
import { ExecutionOrchestrator } from './execution/orchestrator.js';
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
import { PreviewManager } from './preview/index.js';
import { findWorkspaceRoot } from './config/index.js';
import { createModuleLogger } from './logger/index.js';
import type { ServerContextDeps } from './server/index.js';
@@ -106,6 +107,7 @@ export interface Container extends Repositories {
phaseDispatchManager: PhaseDispatchManager;
branchManager: BranchManager;
executionOrchestrator: ExecutionOrchestrator;
previewManager: PreviewManager;
/** Extract the subset of deps that CoordinationServer needs. */
toContextDeps(): ServerContextDeps;
@@ -220,6 +222,14 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
executionOrchestrator.start();
log.info('execution orchestrator started');
// Preview manager
const previewManager = new PreviewManager(
repos.projectRepository,
eventBus,
workspaceRoot,
);
log.info('preview manager created');
return {
db,
eventBus,
@@ -232,6 +242,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
phaseDispatchManager,
branchManager,
executionOrchestrator,
previewManager,
...repos,
toContextDeps(): ServerContextDeps {
@@ -242,6 +253,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
phaseDispatchManager,
branchManager,
executionOrchestrator,
previewManager,
workspaceRoot,
...repos,
};

View File

@@ -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
*/
@@ -523,7 +565,11 @@ export type DomainEventMap =
| ChangeSetRevertedEvent
| AccountCredentialsRefreshedEvent
| AccountCredentialsExpiredEvent
| AccountCredentialsValidatedEvent;
| AccountCredentialsValidatedEvent
| PreviewBuildingEvent
| PreviewReadyEvent
| PreviewStoppedEvent
| PreviewFailedEvent;
/**
* Event type literal union for type checking

View 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();
});
});

View 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;
}

View 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');
});
});

View 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;
}
}

View 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 [];
}
}

View 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
View 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
View 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,
};
}
}

View 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());
});
}
});
});

View 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
View 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;
}

View File

@@ -8,6 +8,7 @@
import type { CoordinationServer } from './index.js';
import type { ProcessManager } from '../process/index.js';
import type { LogManager } from '../logging/index.js';
import type { PreviewManager } from '../preview/index.js';
/** Timeout before force exit in milliseconds */
const SHUTDOWN_TIMEOUT_MS = 10000;
@@ -30,17 +31,20 @@ export class GracefulShutdown {
private readonly server: CoordinationServer;
private readonly processManager: ProcessManager;
private readonly logManager: LogManager;
private readonly previewManager?: PreviewManager;
private isShuttingDown = false;
private forceExitCount = 0;
constructor(
server: CoordinationServer,
processManager: ProcessManager,
logManager: LogManager
logManager: LogManager,
previewManager?: PreviewManager,
) {
this.server = server;
this.processManager = processManager;
this.logManager = logManager;
this.previewManager = previewManager;
}
/**
@@ -114,7 +118,13 @@ export class GracefulShutdown {
console.log(' Stopping managed processes...');
await this.processManager.stopAll();
// Step 3: Clean up log manager resources (future: close open file handles)
// Step 3: Stop all preview deployments
if (this.previewManager) {
console.log(' Stopping preview deployments...');
await this.previewManager.stopAll();
}
// Step 4: Clean up log manager resources (future: close open file handles)
// Currently LogManager doesn't maintain persistent handles that need closing
// This is a placeholder for future cleanup needs
console.log(' Cleaning up resources...');

View File

@@ -24,6 +24,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js
import type { CoordinationManager } from '../coordination/types.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
import type { PreviewManager } from '../preview/index.js';
/**
* Options for creating the tRPC request handler.
@@ -67,6 +68,8 @@ export interface TrpcAdapterOptions {
branchManager?: BranchManager;
/** Execution orchestrator for phase merge/review workflow */
executionOrchestrator?: ExecutionOrchestrator;
/** Preview manager for Docker-based preview deployments */
previewManager?: PreviewManager;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -146,6 +149,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
credentialManager: options.credentialManager,
branchManager: options.branchManager,
executionOrchestrator: options.executionOrchestrator,
previewManager: options.previewManager,
workspaceRoot: options.workspaceRoot,
}),
});

View File

@@ -21,6 +21,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js
import type { CoordinationManager } from '../coordination/types.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
import type { PreviewManager } from '../preview/index.js';
// Re-export for convenience
export type { EventBus, DomainEvent };
@@ -67,6 +68,8 @@ export interface TRPCContext {
branchManager?: BranchManager;
/** Execution orchestrator for phase merge/review workflow */
executionOrchestrator?: ExecutionOrchestrator;
/** Preview manager for Docker-based preview deployments */
previewManager?: PreviewManager;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -94,6 +97,7 @@ export interface CreateContextOptions {
credentialManager?: AccountCredentialManager;
branchManager?: BranchManager;
executionOrchestrator?: ExecutionOrchestrator;
previewManager?: PreviewManager;
workspaceRoot?: string;
}
@@ -124,6 +128,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
credentialManager: options.credentialManager,
branchManager: options.branchManager,
executionOrchestrator: options.executionOrchestrator,
previewManager: options.previewManager,
workspaceRoot: options.workspaceRoot,
};
}

View File

@@ -21,6 +21,7 @@ import { pageProcedures } from './routers/page.js';
import { accountProcedures } from './routers/account.js';
import { changeSetProcedures } from './routers/change-set.js';
import { subscriptionProcedures } from './routers/subscription.js';
import { previewProcedures } from './routers/preview.js';
// Re-export tRPC primitives (preserves existing import paths)
export { router, publicProcedure, middleware, createCallerFactory } from './trpc.js';
@@ -57,6 +58,7 @@ export const appRouter = router({
...accountProcedures(publicProcedure),
...changeSetProcedures(publicProcedure),
...subscriptionProcedures(publicProcedure),
...previewProcedures(publicProcedure),
});
export type AppRouter = typeof appRouter;

View File

@@ -20,6 +20,7 @@ import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
import type { PreviewManager } from '../../preview/index.js';
export function requireAgentManager(ctx: TRPCContext) {
if (!ctx.agentManager) {
@@ -170,3 +171,13 @@ export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchest
}
return ctx.executionOrchestrator;
}
export function requirePreviewManager(ctx: TRPCContext): PreviewManager {
if (!ctx.previewManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Preview manager not available',
});
}
return ctx.previewManager;
}

View 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);
}),
};
}