diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 5065e91..dde6b9b 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -37,9 +37,10 @@ import type { ProcessCrashedEvent, } from '../events/index.js'; import { writeInputFiles } from './file-io.js'; -import { buildWorkspaceLayout, buildInterAgentCommunication } from './prompts/index.js'; +import { buildWorkspaceLayout, buildInterAgentCommunication, buildPreviewInstructions } from './prompts/index.js'; import { getProvider } from './providers/registry.js'; import { createModuleLogger } from '../logger/index.js'; +import { getProjectCloneDir } from '../git/project-clones.js'; import { join } from 'node:path'; import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises'; import { existsSync } from 'node:fs'; @@ -282,7 +283,15 @@ export class MultiProviderAgentManager implements AgentManager { // 3a. Append inter-agent communication instructions with actual agent ID prompt = prompt + buildInterAgentCommunication(agentId, mode); - // 3b. Write input files (after agent creation so we can include agentId/agentName) + // 3b. Append preview deployment instructions if applicable + if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) { + const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId); + if (shouldInject) { + prompt = prompt + buildPreviewInstructions(agentId); + } + } + + // 3c. Write input files (after agent creation so we can include agentId/agentName) if (options.inputContext) { await writeInputFiles({ agentWorkdir: agentCwd, ...options.inputContext, agentId, agentName: alias }); log.debug({ alias }, 'input files written'); @@ -1038,6 +1047,23 @@ export class MultiProviderAgentManager implements AgentManager { } } + /** + * Check whether preview instructions should be injected for this initiative. + * Returns true if exactly one project linked and it has .cw-preview.yml. + */ + private async shouldInjectPreviewInstructions(initiativeId: string): Promise { + try { + const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); + if (projects.length !== 1) return false; + + const project = projects[0]; + const cloneDir = join(this.workspaceRoot, getProjectCloneDir(project.name, project.id)); + return existsSync(join(cloneDir, '.cw-preview.yml')); + } catch { + return false; + } + } + /** * Convert database agent record to AgentInfo. */ diff --git a/apps/server/agent/prompts/index.ts b/apps/server/agent/prompts/index.ts index 8085811..7722872 100644 --- a/apps/server/agent/prompts/index.ts +++ b/apps/server/agent/prompts/index.ts @@ -14,3 +14,4 @@ export { buildRefinePrompt } from './refine.js'; export { buildChatPrompt } from './chat.js'; export type { ChatHistoryEntry } from './chat.js'; export { buildWorkspaceLayout } from './workspace.js'; +export { buildPreviewInstructions } from './preview.js'; diff --git a/apps/server/agent/prompts/preview.ts b/apps/server/agent/prompts/preview.ts new file mode 100644 index 0000000..bc43a64 --- /dev/null +++ b/apps/server/agent/prompts/preview.ts @@ -0,0 +1,37 @@ +/** + * Preview Deployment Prompt Instructions + * + * Conditional prompt section injected when the agent's initiative has + * a project with `.cw-preview.yml`. Provides prefilled commands using + * the agent's own ID so the server can resolve everything else. + */ + +export function buildPreviewInstructions(agentId: string): string { + return ` + + +This project supports preview deployments via Docker. You can spin up a running +instance to verify your changes visually or explore the app. + +## Start a Preview +cw preview start --agent ${agentId} + +Automatically uses your worktree (dev mode with hot reload). The URL is printed +to stdout (e.g. http://abc123.localhost:9100). + +## Check Preview Status +cw preview list + +## Stop a Preview +cw preview stop --agent ${agentId} + +## When to Use +- After implementing a UI change, spin up a preview to verify rendering. +- After API changes, verify the frontend still works end-to-end. +- To explore the existing app state via Chrome DevTools or browser. + +## When NOT to Use +- Backend-only changes with no visual component — run tests instead. +- If Docker is not available (the command will fail gracefully). +`; +} diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index afc0494..2e117ec 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1038,6 +1038,11 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com console.log(`Registered project: ${project.id}`); console.log(` Name: ${project.name}`); console.log(` URL: ${project.url}`); + if (project.hasPreviewConfig) { + console.log(' Preview: .cw-preview.yml detected — preview deployments ready'); + } else { + console.log(' Preview: No .cw-preview.yml found. Run `cw preview setup` for instructions.'); + } } catch (error) { console.error('Failed to register project:', (error as Error).message); process.exit(1); @@ -1335,23 +1340,36 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com .description('Manage Docker-based preview deployments'); // cw preview start --initiative --project --branch [--phase ] + // cw preview start --agent (agent-simplified: server resolves everything) previewCommand .command('start') .description('Start a preview deployment') - .requiredOption('--initiative ', 'Initiative ID') - .requiredOption('--project ', 'Project ID') - .requiredOption('--branch ', 'Branch to deploy') + .option('--initiative ', 'Initiative ID') + .option('--project ', 'Project ID') + .option('--branch ', 'Branch to deploy') .option('--phase ', 'Phase ID') - .action(async (options: { initiative: string; project: string; branch: string; phase?: string }) => { + .option('--agent ', 'Agent ID (server resolves initiative/project/branch)') + .action(async (options: { initiative?: string; project?: string; branch?: string; phase?: string; agent?: 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, - }); + + let preview; + if (options.agent) { + preview = await client.startPreviewForAgent.mutate({ agentId: options.agent }); + } else { + if (!options.initiative || !options.project || !options.branch) { + console.error('Either --agent or all of --initiative, --project, --branch are required'); + process.exit(1); + } + 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: ${preview.url}`); console.log(` Branch: ${preview.branch}`); @@ -1365,14 +1383,24 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com }); // cw preview stop + // cw preview stop --agent previewCommand - .command('stop ') + .command('stop [previewId]') .description('Stop a preview deployment') - .action(async (previewId: string) => { + .option('--agent ', 'Stop preview by agent ID') + .action(async (previewId: string | undefined, options: { agent?: string }) => { try { const client = createDefaultTrpcClient(); - await client.stopPreview.mutate({ previewId }); - console.log(`Preview '${previewId}' stopped`); + if (options.agent) { + await client.stopPreviewByAgent.mutate({ agentId: options.agent }); + console.log(`Previews for agent '${options.agent}' stopped`); + } else if (previewId) { + await client.stopPreview.mutate({ previewId }); + console.log(`Preview '${previewId}' stopped`); + } else { + console.error('Either or --agent is required'); + process.exit(1); + } } catch (error) { console.error('Failed to stop preview:', (error as Error).message); process.exit(1); @@ -1434,6 +1462,138 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // cw preview setup [--auto --project ] + previewCommand + .command('setup') + .description('Show preview deployment setup instructions') + .option('--auto', 'Auto-create initiative and spawn agent to set up preview config') + .option('--project ', 'Project ID (required with --auto)') + .option('--provider ', 'Agent provider (optional, with --auto)') + .action(async (options: { auto?: boolean; project?: string; provider?: string }) => { + if (!options.auto) { + console.log(`Preview Deployment Setup +======================== + +Prerequisites: + - Docker installed and running + - Project registered in Codewalkers (cw project register) + +Step 1: Add .cw-preview.yml to your project root + + Minimal (single service with Dockerfile): + + version: 1 + services: + app: + build: . + port: 3000 + + Multi-service (frontend + API + database): + + version: 1 + services: + frontend: + build: + context: . + dockerfile: apps/web/Dockerfile + port: 3000 + route: / + healthcheck: + path: / + backend: + build: + context: . + dockerfile: packages/api/Dockerfile + port: 8080 + route: /api + healthcheck: + path: /health + db: + image: postgres:16-alpine + internal: true + env: + POSTGRES_PASSWORD: preview + +Step 2: Commit .cw-preview.yml to your repo + +Step 3: Start a preview + cw preview start --initiative --project --branch + +Without .cw-preview.yml, previews auto-discover: + 1. docker-compose.yml / compose.yml + 2. Dockerfile (single service, port 3000) + +Full reference: docs/preview.md`); + return; + } + + // --auto mode + if (!options.project) { + console.error('--project is required with --auto'); + process.exit(1); + } + + try { + const client = createDefaultTrpcClient(); + + // Look up project + const projects = await client.listProjects.query(); + const project = projects.find((p: { id: string }) => p.id === options.project); + if (!project) { + console.error(`Project '${options.project}' not found`); + process.exit(1); + } + + // Create initiative + const initiative = await client.createInitiative.mutate({ + name: `Preview setup: ${project.name}`, + projectIds: [project.id], + }); + + // Create root page with setup guide + const rootPage = await client.getRootPage.query({ initiativeId: initiative.id }); + + const { markdownToTiptapJson } = await import('../agent/markdown-to-tiptap.js'); + const setupMarkdown = `# Preview Setup for ${project.name} + +Analyze the project structure and create a \`.cw-preview.yml\` configuration file for Docker-based preview deployments. + +## What to do + +1. Examine the project's build system, frameworks, and service architecture +2. Identify all services (frontend, backend, database, etc.) +3. Create a \`.cw-preview.yml\` at the project root with appropriate service definitions +4. Include dev mode configuration for hot-reload support +5. Add healthcheck endpoints where applicable +6. Commit the file to the repository + +## Reference + +See the Codewalkers documentation for .cw-preview.yml format and options.`; + + await client.updatePage.mutate({ + id: rootPage.id, + content: JSON.stringify(markdownToTiptapJson(setupMarkdown)), + }); + + // Spawn refine agent + const spawnInput: { initiativeId: string; instruction: string; provider?: string } = { + initiativeId: initiative.id, + instruction: 'Analyze the project and create a .cw-preview.yml for preview deployments. Follow the setup guide in the initiative pages.', + }; + if (options.provider) { + spawnInput.provider = options.provider; + } + const agent = await client.spawnArchitectRefine.mutate(spawnInput); + + console.log(`Created initiative: ${initiative.id}`); + console.log(`Spawned agent: ${agent.id} (${agent.name})`); + } catch (error) { + console.error('Failed to set up preview:', (error as Error).message); + process.exit(1); + } + }); + // ========================================================================= // Inter-agent conversation commands // ========================================================================= diff --git a/apps/server/preview/compose-generator.ts b/apps/server/preview/compose-generator.ts index ea4991b..186d6d0 100644 --- a/apps/server/preview/compose-generator.ts +++ b/apps/server/preview/compose-generator.ts @@ -128,6 +128,7 @@ export function generateLabels(opts: { gatewayPort: number; previewId: string; mode: 'preview' | 'dev'; + agentId?: string; }): Record { const labels: Record = { [PREVIEW_LABELS.preview]: 'true', @@ -143,5 +144,9 @@ export function generateLabels(opts: { labels[PREVIEW_LABELS.phaseId] = opts.phaseId; } + if (opts.agentId) { + labels[PREVIEW_LABELS.agentId] = opts.agentId; + } + return labels; } diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index 30214de..418e9ba 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -43,7 +43,9 @@ import type { PreviewStoppedEvent, PreviewFailedEvent, PhasePendingReviewEvent, + AgentStoppedEvent, } from '../events/types.js'; +import type { AgentManager } from '../agent/types.js'; const log = createModuleLogger('preview'); @@ -156,6 +158,7 @@ export class PreviewManager { gatewayPort, previewId: id, mode, + agentId: options.agentId, }); const composeYaml = generateComposeFile(config, { @@ -429,6 +432,60 @@ export class PreviewManager { 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. */ @@ -479,6 +536,11 @@ export class PreviewManager { } }, ); + + // Auto-teardown previews when their owning agent stops + this.eventBus.on('agent:stopped', async (event) => { + await this.stopByAgentId(event.payload.agentId); + }); } /** diff --git a/apps/server/preview/types.ts b/apps/server/preview/types.ts index 8b95e21..5d3e2bd 100644 --- a/apps/server/preview/types.ts +++ b/apps/server/preview/types.ts @@ -74,6 +74,7 @@ export const PREVIEW_LABELS = { port: `${PREVIEW_LABEL_PREFIX}.port`, previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`, mode: `${PREVIEW_LABEL_PREFIX}.mode`, + agentId: `${PREVIEW_LABEL_PREFIX}.agent-id`, } as const; /** @@ -97,6 +98,7 @@ export interface StartPreviewOptions { branch: string; mode?: 'preview' | 'dev'; worktreePath?: string; + agentId?: string; } /** diff --git a/apps/server/trpc/routers/preview.ts b/apps/server/trpc/routers/preview.ts index 206bda6..81e4b1e 100644 --- a/apps/server/trpc/routers/preview.ts +++ b/apps/server/trpc/routers/preview.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { ProcedureBuilder } from '../trpc.js'; -import { requirePreviewManager } from './_helpers.js'; +import { requirePreviewManager, requireAgentManager } from './_helpers.js'; export function previewProcedures(publicProcedure: ProcedureBuilder) { return { @@ -16,12 +16,29 @@ export function previewProcedures(publicProcedure: ProcedureBuilder) { branch: z.string().min(1), mode: z.enum(['preview', 'dev']).default('preview'), worktreePath: z.string().optional(), + agentId: z.string().min(1).optional(), })) .mutation(async ({ ctx, input }) => { const previewManager = requirePreviewManager(ctx); return previewManager.start(input); }), + startPreviewForAgent: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + const agentManager = requireAgentManager(ctx); + return previewManager.startForAgent(input.agentId, agentManager); + }), + + stopPreviewByAgent: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const previewManager = requirePreviewManager(ctx); + await previewManager.stopByAgentId(input.agentId); + return { success: true }; + }), + stopPreview: publicProcedure .input(z.object({ previewId: z.string().min(1), diff --git a/apps/server/trpc/routers/project.ts b/apps/server/trpc/routers/project.ts index 63f3474..5d79400 100644 --- a/apps/server/trpc/routers/project.ts +++ b/apps/server/trpc/routers/project.ts @@ -5,7 +5,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { join } from 'node:path'; -import { rm } from 'node:fs/promises'; +import { rm, access } from 'node:fs/promises'; import type { ProcedureBuilder } from '../trpc.js'; import { requireProjectRepository, requireProjectSyncManager } from './_helpers.js'; import { cloneProject } from '../../git/clone.js'; @@ -69,7 +69,17 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) { } } - return project; + // Check for preview config + let hasPreviewConfig = false; + if (ctx.workspaceRoot) { + const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id)); + try { + await access(join(clonePath, '.cw-preview.yml')); + hasPreviewConfig = true; + } catch { /* no config */ } + } + + return { ...project, hasPreviewConfig }; }), listProjects: publicProcedure diff --git a/docs/preview.md b/docs/preview.md index c1482d7..0ea428d 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -268,6 +268,7 @@ All preview containers get `cw.*` labels for metadata retrieval: | `cw.port` | Gateway port | | `cw.preview-id` | Nanoid for this deployment | | `cw.mode` | `"preview"` or `"dev"` | +| `cw.agent-id` | Agent ID (optional, set when started via `--agent`) | ### Compose Project Naming @@ -321,24 +322,62 @@ See [Setting Up Preview Deployments](#setting-up-preview-deployments-for-a-proje 3. Branch is derived from `phaseBranchName(initiative.branch, phase.name)` 4. Errors are caught and logged (best-effort, never blocks the phase transition) +## Agent Integration + +Agents can spin up and tear down preview deployments using simplified commands that only require their agent ID — the server resolves initiative, project, branch, and mode automatically. + +### Agent-Simplified Commands + +When an agent has a `` section in its prompt (injected automatically if the initiative's project has `.cw-preview.yml`), it can use: + +``` +cw preview start --agent # Server resolves everything, starts dev mode +cw preview stop --agent # Stops all previews for this agent +``` + +### Prompt Injection + +Preview instructions are automatically appended to agent prompts when all conditions are met: +1. Agent mode is `execute`, `refine`, or `discuss` +2. Agent has an `initiativeId` +3. Initiative has exactly one linked project +4. Project clone directory contains `.cw-preview.yml` + +The injected `` block includes prefilled `cw preview start/stop --agent ` commands. + +### Agent ID Label + +Previews started with `--agent` receive a `cw.agent-id` Docker container label. This enables: +- **Auto-teardown**: When an agent stops (`agent:stopped` event), all previews labeled with its ID are automatically torn down (best-effort). +- **Agent-scoped stop**: `cw preview stop --agent ` finds and stops previews by label. + +### Setup Command + +`cw preview setup` prints inline setup instructions for `.cw-preview.yml`. With `--auto --project `, it creates an initiative and spawns a refine agent to analyze the project and generate the config file. + ## tRPC Procedures | Procedure | Type | Input | |-----------|------|-------| -| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}` | +| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?, agentId?}` | +| `startPreviewForAgent` | mutation | `{agentId}` | | `stopPreview` | mutation | `{previewId}` | +| `stopPreviewByAgent` | mutation | `{agentId}` | | `listPreviews` | query | `{initiativeId?}` | | `getPreviewStatus` | query | `{previewId}` | -`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode. +`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode. `startPreviewForAgent` always uses dev mode. ## CLI Commands ``` -cw preview start --initiative --project --branch [--phase ] [--mode preview|dev] +cw preview start --initiative --project --branch [--phase ] +cw preview start --agent cw preview stop +cw preview stop --agent cw preview list [--initiative ] cw preview status +cw preview setup [--auto --project [--provider ]] ``` ## Frontend