feat: Add agent preview integration with auto-teardown and simplified commands

- Add agentId label to preview containers (cw.agent-id) for tracking
- Add startForAgent/stopByAgentId methods to PreviewManager
- Auto-teardown: previews torn down on agent:stopped event
- Conditional preview prompt injection for execute/refine/discuss agents
- Agent-simplified CLI: cw preview start/stop --agent <id>
- cw preview setup command with --auto mode for guided config generation
- hasPreviewConfig hint on cw project register output
- New tRPC procedures: startPreviewForAgent, stopPreviewByAgent
This commit is contained in:
Lukas May
2026-03-05 15:39:15 +01:00
parent 66605da30d
commit ebe186bd5e
10 changed files with 381 additions and 22 deletions

View File

@@ -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<boolean> {
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.
*/

View File

@@ -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';

View File

@@ -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 `
<preview_deployments>
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).
</preview_deployments>`;
}

View File

@@ -1038,6 +1038,11 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): 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<void>): Com
.description('Manage Docker-based preview deployments');
// cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
// cw preview start --agent <id> (agent-simplified: server resolves everything)
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('--initiative <id>', 'Initiative ID')
.option('--project <id>', 'Project ID')
.option('--branch <branch>', 'Branch to deploy')
.option('--phase <id>', 'Phase ID')
.action(async (options: { initiative: string; project: string; branch: string; phase?: string }) => {
.option('--agent <id>', '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<void>): Com
});
// cw preview stop <previewId>
// cw preview stop --agent <id>
previewCommand
.command('stop <previewId>')
.command('stop [previewId]')
.description('Stop a preview deployment')
.action(async (previewId: string) => {
.option('--agent <id>', '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 <previewId> or --agent <id> 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<void>): Com
}
});
// cw preview setup [--auto --project <id>]
previewCommand
.command('setup')
.description('Show preview deployment setup instructions')
.option('--auto', 'Auto-create initiative and spawn agent to set up preview config')
.option('--project <id>', 'Project ID (required with --auto)')
.option('--provider <name>', '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 <id> --project <id> --branch <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 <id> 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
// =========================================================================

View File

@@ -128,6 +128,7 @@ export function generateLabels(opts: {
gatewayPort: number;
previewId: string;
mode: 'preview' | 'dev';
agentId?: string;
}): Record<string, string> {
const labels: Record<string, string> = {
[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;
}

View File

@@ -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<PreviewStatus> {
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<void> {
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<AgentStoppedEvent>('agent:stopped', async (event) => {
await this.stopByAgentId(event.payload.agentId);
});
}
/**

View File

@@ -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;
}
/**

View File

@@ -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),

View File

@@ -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

View File

@@ -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 `<preview_deployments>` section in its prompt (injected automatically if the initiative's project has `.cw-preview.yml`), it can use:
```
cw preview start --agent <agentId> # Server resolves everything, starts dev mode
cw preview stop --agent <agentId> # 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 `<preview_deployments>` block includes prefilled `cw preview start/stop --agent <agentId>` 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 <id>` finds and stops previews by label.
### Setup Command
`cw preview setup` prints inline setup instructions for `.cw-preview.yml`. With `--auto --project <id>`, 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 <id> --project <id> --branch <branch> [--phase <id>] [--mode preview|dev]
cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
cw preview start --agent <id>
cw preview stop <previewId>
cw preview stop --agent <id>
cw preview list [--initiative <id>]
cw preview status <previewId>
cw preview setup [--auto --project <id> [--provider <name>]]
```
## Frontend