/** * Preview Manager * * Orchestrates preview deployment lifecycle: start, stop, list, status. * 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, generateLabels } from './compose-generator.js'; import { isDockerAvailable, composeUp, composeDown, execInContainer, composePs, listPreviewProjects, getContainerLabels, } from './docker-client.js'; import { waitForHealthy } from './health-checker.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, AgentStoppedEvent, } from '../events/types.js'; import type { AgentManager } from '../agent/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; 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(); 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. 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 { const mode = options.mode ?? 'preview'; // 1. Check Docker if (!(await isDockerAvailable())) { throw new Error( 'Docker is not available. Please ensure Docker is installed and running.', ); } // 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`); } const clonePath = join( this.workspaceRoot, getProjectCloneDir(project.name, project.id), ); // 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 }); 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, gatewayPort, previewId: id, mode, agentId: options.agentId, }); const composeYaml = generateComposeFile(config, { projectPath: sourcePath, deploymentId: id, labels, mode, }); const composePath = join(deployDir, 'docker-compose.yml'); await writeFile(composePath, composeYaml, 'utf-8'); log.info({ id, projectName, gatewayPort, composePath, mode }, 'preview deployment prepared'); // Emit building event this.eventBus.emit({ type: 'preview:building', timestamp: new Date(), payload: { previewId: id, initiativeId: options.initiativeId, branch: options.branch, gatewayPort, mode, phaseId: options.phaseId, }, }); // 7. Build and start await composeUp(composePath, projectName); // 8. Build gateway routes and update Caddyfile const routes = this.buildRoutes(id, config); this.activeRoutes.set(id, routes); await this.gatewayManager.updateRoutes(this.activeRoutes); // 9. Health check const healthResults = await waitForHealthy(id, gatewayPort, config); const allHealthy = healthResults.every((r) => r.healthy); if (!allHealthy && healthResults.length > 0) { const failedServices = healthResults .filter((r) => !r.healthy) .map((r) => r.name); log.warn({ id, failedServices }, 'some services failed health checks'); this.eventBus.emit({ type: 'preview:failed', timestamp: new Date(), payload: { previewId: id, initiativeId: options.initiativeId, error: `Health checks failed for: ${failedServices.join(', ')}`, }, }); // 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(', ')}`, ); } // 10. Run seed commands await this.runSeeds(projectName, config); // 11. Success const url = `http://${id}.localhost:${gatewayPort}`; log.info({ id, url }, 'preview deployment ready'); this.eventBus.emit({ type: 'preview:ready', timestamp: new Date(), payload: { previewId: id, initiativeId: options.initiativeId, branch: options.branch, gatewayPort, url, mode, phaseId: options.phaseId, }, }); const services = await composePs(projectName); return { id, projectName, initiativeId: options.initiativeId, phaseId: options.phaseId, projectId: options.projectId, branch: options.branch, 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({ 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}`); } } /** * Stop a preview deployment and clean up artifacts. */ async stop(previewId: string): Promise { const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; // Get labels before stopping to emit event 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); // 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({ type: 'preview:stopped', timestamp: new Date(), payload: { previewId, initiativeId }, }); } /** * List all active preview deployments, optionally filtered by initiative. */ async list(initiativeId?: string): Promise { const projects = await listPreviewProjects(); const previews: PreviewStatus[] = []; for (const project of projects) { // Skip the gateway project if (project.Name === 'cw-preview-gateway') continue; const labels = await getContainerLabels(project.Name); if (!labels[PREVIEW_LABELS.preview]) continue; const preview = this.labelsToStatus(project.Name, labels, project.ConfigFiles); if (!preview) continue; if (initiativeId && preview.initiativeId !== initiativeId) continue; // Get service statuses preview.services = await composePs(project.Name); previews.push(preview); } return previews; } /** * Get the status of a specific preview deployment. */ async getStatus(previewId: string): Promise { const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; const labels = await getContainerLabels(projectName); if (!labels[PREVIEW_LABELS.preview]) { return null; } const preview = this.labelsToStatus(projectName, labels, ''); if (!preview) return null; preview.services = await composePs(projectName); // Determine status from service states if (preview.services.length === 0) { preview.status = 'stopped'; } else if (preview.services.every((s) => s.state === 'running')) { preview.status = 'running'; } else if (preview.services.some((s) => s.state === 'exited' || s.state === 'dead')) { preview.status = 'failed'; } else { preview.status = 'building'; } return preview; } /** * Stop all preview deployments, then stop the gateway. */ async stopAll(): Promise { const projects = await listPreviewProjects(); const previewProjects = projects.filter((p) => p.Name !== 'cw-preview-gateway'); log.info({ count: previewProjects.length }, 'stopping all preview deployments'); await Promise.all( 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(() => {}); } /** * Start a preview for an agent, resolving initiative/project/branch automatically. * The agent's worktree determines the source path and branch. */ async startForAgent(agentId: string, agentManager: AgentManager): Promise { const agent = await agentManager.get(agentId); if (!agent) throw new Error(`Agent '${agentId}' not found`); if (!agent.initiativeId) throw new Error('Agent has no initiative'); const projects = await this.projectRepository.findProjectsByInitiativeId(agent.initiativeId); if (projects.length !== 1) { throw new Error(`Expected 1 project for initiative, found ${projects.length}`); } const project = projects[0]; const agentWorkdir = join(this.workspaceRoot, 'agent-workdirs', agent.worktreeId); const worktreePath = join(agentWorkdir, project.name); const { simpleGit } = await import('simple-git'); const git = simpleGit(worktreePath); const { current: branch } = await git.branchLocal(); return this.start({ initiativeId: agent.initiativeId, projectId: project.id, branch: branch || 'main', mode: 'dev', worktreePath, agentId, }); } /** * Stop all previews associated with a specific agent ID (best-effort). */ async stopByAgentId(agentId: string): Promise { try { const projects = await listPreviewProjects(); for (const project of projects) { if (project.Name === 'cw-preview-gateway') continue; const labels = await getContainerLabels(project.Name); if (labels[PREVIEW_LABELS.agentId] === agentId) { const previewId = labels[PREVIEW_LABELS.previewId] ?? project.Name.replace(COMPOSE_PROJECT_PREFIX, ''); log.info({ previewId, agentId }, 'stopping preview for agent'); await this.stop(previewId).catch((err) => { log.warn({ previewId, agentId, err }, 'failed to stop preview for agent'); }); } } } catch (err) { log.warn({ agentId, err }, 'stopByAgentId failed (best-effort)'); } } /** * Register event listener for auto-starting previews on phase:pending_review. */ private setupEventListeners(): void { this.eventBus.on( '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)'); } }, ); // Auto-teardown previews when their owning agent stops this.eventBus.on('agent:stopped', async (event) => { await this.stopByAgentId(event.payload.agentId); }); } /** * Run seed commands for each service that has them configured. * Executes after health checks pass, before the preview is marked ready. */ private async runSeeds(projectName: string, config: PreviewConfig): Promise { for (const [serviceName, svc] of Object.entries(config.services)) { if (!svc.seed?.length) continue; log.info({ projectName, service: serviceName, count: svc.seed.length }, 'running seed commands'); for (const cmd of svc.seed) { log.info({ service: serviceName, cmd }, 'executing seed'); await execInContainer(projectName, serviceName, cmd); } } } /** * 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; } /** * Reconstruct PreviewStatus from Docker container labels. */ private labelsToStatus( projectName: string, labels: Record, composePath: string, ): PreviewStatus | null { const previewId = labels[PREVIEW_LABELS.previewId] ?? projectName.replace(COMPOSE_PROJECT_PREFIX, ''); const initiativeId = labels[PREVIEW_LABELS.initiativeId]; const projectId = labels[PREVIEW_LABELS.projectId]; const branch = labels[PREVIEW_LABELS.branch]; const gatewayPort = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10); const mode = (labels[PREVIEW_LABELS.mode] as 'preview' | 'dev') ?? 'preview'; if (!initiativeId || !projectId || !branch) { return null; } return { id: previewId, projectName, initiativeId, phaseId: labels[PREVIEW_LABELS.phaseId], projectId, branch, gatewayPort, url: `http://${previewId}.localhost:${gatewayPort}`, mode, status: 'running', services: [], composePath, }; } }