Files
vibn-agent-runner/platform/backend/control-plane/src/routes/chat.ts
mawkone 2c3e7f9dfb feat: add Turborepo per-project monorepo scaffold and project API
- Add Turborepo scaffold templates (apps: product, website, admin, storybook; packages: ui, tokens, types, config)
- Add ProjectRecord and AppRecord types to control plane
- Add Gitea integration service (repo creation, scaffold push, webhooks)
- Add Coolify integration service (project + per-app service provisioning with turbo --filter)
- Add project routes: GET/POST /projects, GET /projects/:id/apps, POST /projects/:id/deploy
- Update chat route to inject project/monorepo context into AI requests
- Add deploy_app and scaffold_app tools to Gemini tool set
- Update deploy executor with monorepo-aware /execute/deploy endpoint
- Add TURBOREPO_MIGRATION_PLAN.md documenting rationale and scope

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:07:35 -08:00

332 lines
10 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText, getProject } from "../storage/index.js";
import { nanoid } from "nanoid";
import type { RunRecord } from "../types.js";
interface ChatRequest {
messages: ChatMessage[];
project_id?: string;
context?: {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
};
autoExecuteTools?: boolean;
}
interface ChatResponseWithRuns extends ChatResponse {
runs?: RunRecord[];
}
export async function chatRoutes(app: FastifyInstance) {
/**
* Chat endpoint - proxies to Gemini with tool calling support
*/
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
await requireAuth(req);
const { messages, project_id, context, autoExecuteTools = true } = req.body;
let enhancedMessages = [...messages];
// Inject project context so the AI understands the full monorepo structure
if (project_id) {
const project = await getProject(project_id);
if (project) {
const appList = project.apps.map(a => ` - ${a.name} (${a.path})${a.domain ? `${a.domain}` : ""}`).join("\n");
const projectContext = [
`Project: ${project.name} (${project.slug})`,
`Repo: ${project.repo || "provisioning..."}`,
`Status: ${project.status}`,
`Apps in this monorepo:`,
appList,
`Shared packages: ui, tokens, types, config`,
`Build system: Turborepo ${project.turboVersion}`,
`Build command: turbo run build --filter=<app-name>`,
].join("\n");
enhancedMessages = [
{ role: "user" as const, content: `Project context:\n${projectContext}` },
...messages,
];
}
}
// Enhance messages with file/selection context if provided
if (context?.files?.length) {
const fileContext = context.files
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
.join("\n\n");
enhancedMessages = [
{ role: "user" as const, content: `Context:\n${fileContext}` },
...enhancedMessages,
];
}
if (context?.selection) {
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
enhancedMessages = [
{ role: "user" as const, content: selectionContext },
...enhancedMessages,
];
}
// Call Gemini
const response = await chat(enhancedMessages);
// If tool calls and auto-execute is enabled, run them
if (response.toolCalls && response.toolCalls.length > 0 && autoExecuteTools) {
const runs = await executeToolCalls(response.toolCalls, req.body);
// Generate a summary of what was done
const summary = generateToolSummary(response.toolCalls, runs);
return {
message: summary,
toolCalls: response.toolCalls,
runs,
finishReason: "tool_calls"
};
}
return response;
});
/**
* Streaming chat endpoint (SSE)
*/
app.get("/chat/stream", async (req, reply) => {
await requireAuth(req);
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
// For now, return a message that streaming is not yet implemented
reply.raw.write(`data: ${JSON.stringify({ message: "Streaming not yet implemented", finishReason: "stop" })}\n\n`);
reply.raw.end();
});
}
/**
* Execute tool calls by routing to the appropriate executor
*/
async function executeToolCalls(
toolCalls: ToolCall[],
request: ChatRequest
): Promise<RunRecord[]> {
const runs: RunRecord[] = [];
const registry = await getRegistry();
for (const toolCall of toolCalls) {
// Map tool call names to actual tools
const toolMapping: Record<string, string> = {
"deploy_service": "cloudrun.deploy_service",
"get_funnel_analytics": "analytics.funnel_summary",
"get_top_drivers": "analytics.top_drivers",
"generate_marketing_posts": "marketing.generate_channel_posts",
"get_service_status": "cloudrun.get_service_status",
"generate_code": "code.generate" // This one is special - handled inline
};
const actualToolName = toolMapping[toolCall.name];
// Special handling for code generation
if (toolCall.name === "generate_code") {
const codeRun = await handleCodeGeneration(toolCall.arguments);
runs.push(codeRun);
continue;
}
const tool = actualToolName ? registry[actualToolName] : null;
if (!tool) {
console.log(`Tool not found: ${toolCall.name} (mapped to ${actualToolName})`);
continue;
}
// Create a run
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: actualToolName,
status: "queued",
created_at: now,
updated_at: now,
input: toolCall.arguments,
artifacts: { bucket: "", prefix: `runs/${runId}` }
};
await saveRun(run);
// Execute the tool
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const response = await fetch(execUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
run_id: runId,
tenant_id: "t_chat",
input: toolCall.arguments
})
});
if (!response.ok) {
throw new Error(`Executor error: ${response.status}`);
}
const output = await response.json();
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
} catch (e: any) {
run.status = "failed";
run.error = { message: e.message };
run.updated_at = new Date().toISOString();
await saveRun(run);
}
runs.push(run);
}
return runs;
}
/**
* Handle code generation specially
*/
async function handleCodeGeneration(args: any): Promise<RunRecord> {
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
// For now, return a mock code generation result
// In production, this would call Gemini again with a code-specific prompt
const mockDiff = `--- a/${args.file_path || "src/example.ts"}
+++ b/${args.file_path || "src/example.ts"}
@@ -1,3 +1,10 @@
+// Generated by Product OS
+// Task: ${args.task}
+
export function example() {
- return "hello";
+ // TODO: Implement ${args.task}
+ return {
+ status: "generated",
+ task: "${args.task}"
+ };
}`;
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: "code.generate",
status: "succeeded",
created_at: now,
updated_at: now,
input: args,
output: {
type: "code_generation",
diff: mockDiff,
file_path: args.file_path || "src/example.ts",
language: args.language || "typescript",
description: `Generated code for: ${args.task}`
}
};
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "diff.patch", mockDiff);
return run;
}
/**
* Generate a human-readable summary of tool executions
*/
function generateToolSummary(toolCalls: ToolCall[], runs: RunRecord[]): string {
const parts: string[] = [];
for (let i = 0; i < toolCalls.length; i++) {
const tool = toolCalls[i];
const run = runs[i];
if (!run) continue;
const status = run.status === "succeeded" ? "✅" : "❌";
switch (tool.name) {
case "deploy_service":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Deployed** \`${tool.arguments.service_name}\` to ${tool.arguments.env || "dev"}\n URL: ${output?.service_url || "pending"}`);
} else {
parts.push(`${status} **Deploy failed** for \`${tool.arguments.service_name}\`: ${run.error?.message}`);
}
break;
case "get_funnel_analytics":
if (run.status === "succeeded") {
const output = run.output as any;
const steps = output?.steps || [];
const conversion = ((output?.overall_conversion || 0) * 100).toFixed(1);
parts.push(`${status} **Funnel Analysis** (${tool.arguments.range_days || 30} days)\n Overall conversion: ${conversion}%\n Steps: ${steps.length}`);
}
break;
case "get_top_drivers":
if (run.status === "succeeded") {
const output = run.output as any;
const drivers = output?.drivers || [];
const topDrivers = drivers.slice(0, 3).map((d: any) => d.name).join(", ");
parts.push(`${status} **Top Drivers** for ${tool.arguments.metric}\n ${topDrivers}`);
}
break;
case "generate_marketing_posts":
if (run.status === "succeeded") {
const output = run.output as any;
const channels = output?.channels || [];
const postCount = channels.reduce((sum: number, c: any) => sum + (c.posts?.length || 0), 0);
parts.push(`${status} **Generated** ${postCount} marketing posts for ${channels.map((c: any) => c.channel).join(", ")}`);
}
break;
case "get_service_status":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Service Status**: \`${tool.arguments.service_name}\` is ${output?.status || "unknown"}`);
}
break;
case "generate_code":
if (run.status === "succeeded") {
parts.push(`${status} **Generated code** for: ${tool.arguments.task}\n File: \`${(run.output as any)?.file_path}\``);
}
break;
default:
parts.push(`${status} **${tool.name}** completed`);
}
}
if (parts.length === 0) {
return "I processed your request but no actions were taken.";
}
return parts.join("\n\n");
}