From b6d7148ded6f836c03d59d138828c62502c4cc42 Mon Sep 17 00:00:00 2001 From: mawkone Date: Mon, 19 Jan 2026 20:34:43 -0800 Subject: [PATCH] Initial commit: Product OS platform - Control Plane API with Gemini integration - Executors: Deploy, Analytics, Marketing - MCP Adapter for Continue integration - VSCode/VSCodium extension - Tool registry and run tracking - In-memory storage for local dev - Terraform infrastructure setup --- .continue/config.yaml | 61 ++ .gitignore | 39 + 1.Generate Control Plane API scaffold.md | 0 Google_Cloud_Product_OS.md | 0 architecture.md | 0 platform/backend/control-plane/package.json | 30 + platform/backend/control-plane/src/auth.ts | 11 + platform/backend/control-plane/src/config.ts | 10 + platform/backend/control-plane/src/gemini.ts | 365 ++++++++ platform/backend/control-plane/src/index.ts | 29 + .../backend/control-plane/src/registry.ts | 10 + .../backend/control-plane/src/routes/chat.ts | 306 +++++++ .../control-plane/src/routes/health.ts | 17 + .../backend/control-plane/src/routes/runs.ts | 18 + .../backend/control-plane/src/routes/tools.ts | 91 ++ .../control-plane/src/storage/firestore.ts | 23 + .../backend/control-plane/src/storage/gcs.ts | 11 + .../control-plane/src/storage/index.ts | 23 + .../control-plane/src/storage/memory.ts | 116 +++ platform/backend/control-plane/src/types.ts | 37 + platform/backend/control-plane/tsconfig.json | 13 + .../backend/executors/analytics/package.json | 22 + .../backend/executors/analytics/src/index.ts | 91 ++ .../backend/executors/analytics/tsconfig.json | 13 + .../backend/executors/deploy/package.json | 23 + .../backend/executors/deploy/src/index.ts | 91 ++ .../backend/executors/deploy/tsconfig.json | 13 + .../backend/executors/marketing/package.json | 22 + .../backend/executors/marketing/src/index.ts | 88 ++ .../backend/executors/marketing/tsconfig.json | 13 + platform/backend/mcp-adapter/README.md | 103 +++ platform/backend/mcp-adapter/package.json | 25 + platform/backend/mcp-adapter/src/index.ts | 343 +++++++ platform/backend/mcp-adapter/tsconfig.json | 14 + .../extensions/gcp-productos/media/icon.svg | 5 + .../extensions/gcp-productos/package.json | 97 ++ .../extensions/gcp-productos/src/api.ts | 137 +++ .../extensions/gcp-productos/src/chatPanel.ts | 850 ++++++++++++++++++ .../gcp-productos/src/chatViewProvider.ts | 688 ++++++++++++++ .../extensions/gcp-productos/src/extension.ts | 182 ++++ .../gcp-productos/src/invokePanel.ts | 373 ++++++++ .../gcp-productos/src/runsTreeView.ts | 54 ++ .../extensions/gcp-productos/src/statusBar.ts | 41 + .../gcp-productos/src/toolsTreeView.ts | 63 ++ .../extensions/gcp-productos/src/ui.ts | 40 + .../extensions/gcp-productos/tsconfig.json | 11 + platform/contracts/tool-registry.yaml | 398 ++++++++ platform/docker-compose.yml | 41 + platform/docs/GETTING_STARTED.md | 143 +++ platform/infra/terraform/iam.tf | 16 + platform/infra/terraform/main.tf | 54 ++ platform/infra/terraform/outputs.tf | 9 + platform/infra/terraform/providers.tf | 14 + .../infra/terraform/terraform.tfvars.example | 4 + platform/infra/terraform/variables.tf | 20 + platform/scripts/start-all.sh | 54 ++ technical_spec.md | 0 vision-ext.md | 0 58 files changed, 5365 insertions(+) create mode 100644 .continue/config.yaml create mode 100644 .gitignore create mode 100644 1.Generate Control Plane API scaffold.md create mode 100644 Google_Cloud_Product_OS.md create mode 100644 architecture.md create mode 100644 platform/backend/control-plane/package.json create mode 100644 platform/backend/control-plane/src/auth.ts create mode 100644 platform/backend/control-plane/src/config.ts create mode 100644 platform/backend/control-plane/src/gemini.ts create mode 100644 platform/backend/control-plane/src/index.ts create mode 100644 platform/backend/control-plane/src/registry.ts create mode 100644 platform/backend/control-plane/src/routes/chat.ts create mode 100644 platform/backend/control-plane/src/routes/health.ts create mode 100644 platform/backend/control-plane/src/routes/runs.ts create mode 100644 platform/backend/control-plane/src/routes/tools.ts create mode 100644 platform/backend/control-plane/src/storage/firestore.ts create mode 100644 platform/backend/control-plane/src/storage/gcs.ts create mode 100644 platform/backend/control-plane/src/storage/index.ts create mode 100644 platform/backend/control-plane/src/storage/memory.ts create mode 100644 platform/backend/control-plane/src/types.ts create mode 100644 platform/backend/control-plane/tsconfig.json create mode 100644 platform/backend/executors/analytics/package.json create mode 100644 platform/backend/executors/analytics/src/index.ts create mode 100644 platform/backend/executors/analytics/tsconfig.json create mode 100644 platform/backend/executors/deploy/package.json create mode 100644 platform/backend/executors/deploy/src/index.ts create mode 100644 platform/backend/executors/deploy/tsconfig.json create mode 100644 platform/backend/executors/marketing/package.json create mode 100644 platform/backend/executors/marketing/src/index.ts create mode 100644 platform/backend/executors/marketing/tsconfig.json create mode 100644 platform/backend/mcp-adapter/README.md create mode 100644 platform/backend/mcp-adapter/package.json create mode 100644 platform/backend/mcp-adapter/src/index.ts create mode 100644 platform/backend/mcp-adapter/tsconfig.json create mode 100644 platform/client-ide/extensions/gcp-productos/media/icon.svg create mode 100644 platform/client-ide/extensions/gcp-productos/package.json create mode 100644 platform/client-ide/extensions/gcp-productos/src/api.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/chatPanel.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/chatViewProvider.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/extension.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/invokePanel.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/runsTreeView.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/statusBar.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/toolsTreeView.ts create mode 100644 platform/client-ide/extensions/gcp-productos/src/ui.ts create mode 100644 platform/client-ide/extensions/gcp-productos/tsconfig.json create mode 100644 platform/contracts/tool-registry.yaml create mode 100644 platform/docker-compose.yml create mode 100644 platform/docs/GETTING_STARTED.md create mode 100644 platform/infra/terraform/iam.tf create mode 100644 platform/infra/terraform/main.tf create mode 100644 platform/infra/terraform/outputs.tf create mode 100644 platform/infra/terraform/providers.tf create mode 100644 platform/infra/terraform/terraform.tfvars.example create mode 100644 platform/infra/terraform/variables.tf create mode 100644 platform/scripts/start-all.sh create mode 100644 technical_spec.md create mode 100644 vision-ext.md diff --git a/.continue/config.yaml b/.continue/config.yaml new file mode 100644 index 0000000..a2dad54 --- /dev/null +++ b/.continue/config.yaml @@ -0,0 +1,61 @@ +# Continue Configuration for Product OS +# https://docs.continue.dev/reference/config + +name: Product OS + +# Models - using Gemini via your Control Plane +models: + - name: Gemini (Product OS) + provider: openai # Continue uses OpenAI-compatible API format + model: gemini-1.5-flash + apiBase: http://localhost:8080 # Your Control Plane + apiKey: not-needed # Auth handled by Control Plane + +# Default model for chat +model: Gemini (Product OS) + +# MCP Servers - your Product OS tools +experimental: + modelContextProtocolServers: + - name: productos + command: npx + args: + - tsx + - /Users/markhenderson/Cursor Projects/Master Biz AI/platform/backend/mcp-adapter/src/index.ts + env: + CONTROL_PLANE_URL: http://localhost:8080 + TENANT_ID: t_continue + +# Context providers +contextProviders: + - name: code + params: + nFinal: 5 + nRetrieve: 10 + - name: docs + - name: terminal + - name: problems + +# Slash commands +slashCommands: + - name: deploy + description: Deploy a service to Cloud Run + - name: analytics + description: Get funnel analytics + - name: marketing + description: Generate marketing content + +# Custom instructions for the AI +systemMessage: | + You are Product OS, an AI assistant for building and operating SaaS products on Google Cloud. + + You have access to these tools via MCP: + - deploy_service: Deploy Cloud Run services + - get_service_status: Check deployment health + - get_funnel_analytics: Analyze conversion funnels + - get_top_drivers: Understand what drives metrics + - generate_marketing_posts: Create social media content + - chat_with_gemini: General AI conversation + + When users ask to deploy, analyze, or generate content, use the appropriate tool. + Always confirm before deploying to production. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ae98e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +dist/ +*.vsix + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +*.swp +*.swo +.DS_Store + +# Logs +*.log +npm-debug.log* + +# Testing +coverage/ + +# Temporary +tmp/ +temp/ + +# Continue local config (may contain API keys) +.continue/config.json + +# GCP credentials (never commit these!) +*.json +!package.json +!tsconfig.json +!**/package.json +!**/tsconfig.json diff --git a/1.Generate Control Plane API scaffold.md b/1.Generate Control Plane API scaffold.md new file mode 100644 index 0000000..e69de29 diff --git a/Google_Cloud_Product_OS.md b/Google_Cloud_Product_OS.md new file mode 100644 index 0000000..e69de29 diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..e69de29 diff --git a/platform/backend/control-plane/package.json b/platform/backend/control-plane/package.json new file mode 100644 index 0000000..0a9afc1 --- /dev/null +++ b/platform/backend/control-plane/package.json @@ -0,0 +1,30 @@ +{ + "name": "@productos/control-plane", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "lint": "eslint ." + }, + "dependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0", + "@fastify/cors": "^10.0.0", + "@fastify/helmet": "^13.0.0", + "@fastify/rate-limit": "^10.0.0", + "@fastify/sensible": "^6.0.0", + "fastify": "^5.0.0", + "zod": "^3.23.8", + "nanoid": "^5.0.7" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4", + "eslint": "^9.8.0" + } +} diff --git a/platform/backend/control-plane/src/auth.ts b/platform/backend/control-plane/src/auth.ts new file mode 100644 index 0000000..9ee48db --- /dev/null +++ b/platform/backend/control-plane/src/auth.ts @@ -0,0 +1,11 @@ +import { FastifyRequest } from "fastify"; +import { config } from "./config.js"; + +/** + * V1: dev mode = trust caller. + * V2: validate Google OAuth/IAP identity token. + */ +export async function requireAuth(req: FastifyRequest) { + if (config.authMode === "dev") return; + throw new Error("AUTH_MODE oauth not yet implemented"); +} diff --git a/platform/backend/control-plane/src/config.ts b/platform/backend/control-plane/src/config.ts new file mode 100644 index 0000000..35e1450 --- /dev/null +++ b/platform/backend/control-plane/src/config.ts @@ -0,0 +1,10 @@ +export const config = { + port: Number(process.env.PORT ?? 8080), + projectId: process.env.GCP_PROJECT_ID ?? "productos-local", + artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "productos-artifacts-local", + runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs", + toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools", + authMode: process.env.AUTH_MODE ?? "dev", + // Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured + storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory") +}; diff --git a/platform/backend/control-plane/src/gemini.ts b/platform/backend/control-plane/src/gemini.ts new file mode 100644 index 0000000..ba60b5e --- /dev/null +++ b/platform/backend/control-plane/src/gemini.ts @@ -0,0 +1,365 @@ +/** + * Gemini Integration for Product OS + * + * Supports: + * - Chat completions with streaming + * - Tool/function calling + * - Context-aware responses + * + * Set GOOGLE_CLOUD_PROJECT and optionally GEMINI_MODEL env vars. + * For local dev without Vertex AI, set GEMINI_API_KEY for AI Studio. + */ + +import { config } from "./config.js"; + +export interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; +} + +export interface ToolCall { + name: string; + arguments: Record; +} + +export interface ChatResponse { + message: string; + toolCalls?: ToolCall[]; + finishReason: "stop" | "tool_calls" | "error"; +} + +// Tool definitions that Gemini can call +export const PRODUCT_OS_TOOLS = [ + { + name: "deploy_service", + description: "Deploy a Cloud Run service. Use when user wants to deploy, ship, or launch code.", + parameters: { + type: "object", + properties: { + service_name: { type: "string", description: "Name of the service to deploy" }, + repo: { type: "string", description: "Git repository URL" }, + ref: { type: "string", description: "Git branch, tag, or commit" }, + env: { type: "string", enum: ["dev", "staging", "prod"], description: "Target environment" } + }, + required: ["service_name"] + } + }, + { + name: "get_funnel_analytics", + description: "Get funnel conversion metrics. Use when user asks about funnels, conversions, or drop-offs.", + parameters: { + type: "object", + properties: { + range_days: { type: "integer", description: "Number of days to analyze", default: 30 } + } + } + }, + { + name: "get_top_drivers", + description: "Identify top factors driving a metric. Use when user asks why something changed or what drives conversions.", + parameters: { + type: "object", + properties: { + metric: { type: "string", description: "The metric to analyze (e.g., 'conversion', 'retention')" }, + range_days: { type: "integer", description: "Number of days to analyze", default: 30 } + }, + required: ["metric"] + } + }, + { + name: "generate_marketing_posts", + description: "Generate social media posts for a campaign. Use when user wants to create marketing content.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Campaign goal (e.g., 'launch announcement')" }, + product: { type: "string", description: "Product or feature name" }, + channels: { + type: "array", + items: { type: "string" }, + description: "Social channels (e.g., ['x', 'linkedin'])" + } + }, + required: ["goal"] + } + }, + { + name: "get_service_status", + description: "Check the status of a deployed service. Use when user asks about service health or deployment status.", + parameters: { + type: "object", + properties: { + service_name: { type: "string", description: "Name of the service" }, + region: { type: "string", description: "GCP region", default: "us-central1" } + }, + required: ["service_name"] + } + }, + { + name: "generate_code", + description: "Generate or modify code. Use when user asks to write, fix, refactor, or change code.", + parameters: { + type: "object", + properties: { + task: { type: "string", description: "What code change to make" }, + file_path: { type: "string", description: "Target file path (if known)" }, + language: { type: "string", description: "Programming language" }, + context: { type: "string", description: "Additional context about the codebase" } + }, + required: ["task"] + } + } +]; + +// System prompt for Product OS assistant +const SYSTEM_PROMPT = `You are Product OS, an AI assistant specialized in helping users launch and operate SaaS products on Google Cloud. + +You can help with: +- Deploying services to Cloud Run +- Analyzing product metrics and funnels +- Generating marketing content +- Writing and modifying code +- Understanding what drives user behavior + +When users ask you to do something, use the available tools to take action. Be concise and helpful. + +If a user asks about code, analyze their request and either: +1. Use generate_code tool for code changes +2. Provide explanations directly + +Always confirm before taking destructive actions like deploying to production.`; + +/** + * Chat with Gemini + * Uses Vertex AI in production, or AI Studio API key for local dev + */ +export async function chat( + messages: ChatMessage[], + options: { stream?: boolean } = {} +): Promise { + const apiKey = process.env.GEMINI_API_KEY; + const projectId = config.projectId; + const model = process.env.GEMINI_MODEL ?? "gemini-1.5-flash"; + + // Build the request + const contents = [ + { role: "user", parts: [{ text: SYSTEM_PROMPT }] }, + { role: "model", parts: [{ text: "Understood. I'm Product OS, ready to help you build and operate your SaaS product. How can I help?" }] }, + ...messages.map(m => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }] + })) + ]; + + const tools = [{ + functionDeclarations: PRODUCT_OS_TOOLS + }]; + + // Use AI Studio API if API key is set (local dev) + if (apiKey) { + return chatWithAIStudio(apiKey, model, contents, tools); + } + + // Use Vertex AI if project is set (production) + if (projectId && projectId !== "productos-local") { + return chatWithVertexAI(projectId, model, contents, tools); + } + + // Mock response for local dev without credentials + return mockChat(messages); +} + +async function chatWithAIStudio( + apiKey: string, + model: string, + contents: any[], + tools: any[] +): Promise { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents, + tools, + generationConfig: { + temperature: 0.7, + maxOutputTokens: 2048 + } + }) + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Gemini API error:", error); + throw new Error(`Gemini API error: ${response.status}`); + } + + const data = await response.json(); + return parseGeminiResponse(data); +} + +async function chatWithVertexAI( + projectId: string, + model: string, + contents: any[], + tools: any[] +): Promise { + // Vertex AI endpoint + const location = process.env.VERTEX_LOCATION ?? "us-central1"; + const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:generateContent`; + + // Get access token (requires gcloud auth) + const { GoogleAuth } = await import("google-auth-library"); + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }); + const client = await auth.getClient(); + const token = await client.getAccessToken(); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token.token}` + }, + body: JSON.stringify({ + contents, + tools, + generationConfig: { + temperature: 0.7, + maxOutputTokens: 2048 + } + }) + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Vertex AI error:", error); + throw new Error(`Vertex AI error: ${response.status}`); + } + + const data = await response.json(); + return parseGeminiResponse(data); +} + +function parseGeminiResponse(data: any): ChatResponse { + const candidate = data.candidates?.[0]; + if (!candidate) { + return { message: "No response from Gemini", finishReason: "error" }; + } + + const content = candidate.content; + const parts = content?.parts ?? []; + + // Check for function calls + const functionCalls = parts.filter((p: any) => p.functionCall); + if (functionCalls.length > 0) { + const toolCalls = functionCalls.map((p: any) => ({ + name: p.functionCall.name, + arguments: p.functionCall.args ?? {} + })); + return { + message: "", + toolCalls, + finishReason: "tool_calls" + }; + } + + // Regular text response + const text = parts.map((p: any) => p.text ?? "").join(""); + return { + message: text, + finishReason: "stop" + }; +} + +/** + * Mock chat for local development without Gemini credentials + */ +function mockChat(messages: ChatMessage[]): ChatResponse { + const lastMessage = messages[messages.length - 1]?.content.toLowerCase() ?? ""; + + // Check marketing/campaign FIRST (before deploy) since "launch" can be ambiguous + if (lastMessage.includes("marketing") || lastMessage.includes("campaign") || lastMessage.includes("post") || + (lastMessage.includes("launch") && !lastMessage.includes("deploy"))) { + return { + message: "", + toolCalls: [{ + name: "generate_marketing_posts", + arguments: { goal: "product launch", channels: ["x", "linkedin"] } + }], + finishReason: "tool_calls" + }; + } + + // Simple keyword matching to simulate tool calls + if (lastMessage.includes("deploy") || lastMessage.includes("ship") || lastMessage.includes("staging") || lastMessage.includes("production")) { + return { + message: "", + toolCalls: [{ + name: "deploy_service", + arguments: { service_name: "my-service", env: lastMessage.includes("staging") ? "staging" : lastMessage.includes("prod") ? "prod" : "dev" } + }], + finishReason: "tool_calls" + }; + } + + if (lastMessage.includes("funnel") || lastMessage.includes("conversion") || lastMessage.includes("analytics")) { + return { + message: "", + toolCalls: [{ + name: "get_funnel_analytics", + arguments: { range_days: 30 } + }], + finishReason: "tool_calls" + }; + } + + if (lastMessage.includes("why") || lastMessage.includes("driver") || lastMessage.includes("cause")) { + return { + message: "", + toolCalls: [{ + name: "get_top_drivers", + arguments: { metric: "conversion", range_days: 30 } + }], + finishReason: "tool_calls" + }; + } + + if (lastMessage.includes("status") || lastMessage.includes("health")) { + return { + message: "", + toolCalls: [{ + name: "get_service_status", + arguments: { service_name: "my-service" } + }], + finishReason: "tool_calls" + }; + } + + if (lastMessage.includes("code") || lastMessage.includes("function") || lastMessage.includes("write") || lastMessage.includes("create")) { + return { + message: "", + toolCalls: [{ + name: "generate_code", + arguments: { task: lastMessage, language: "typescript" } + }], + finishReason: "tool_calls" + }; + } + + // Default response + return { + message: `I'm Product OS, your AI assistant for building and operating SaaS products. I can help you: + +โ€ข **Deploy** services to Cloud Run +โ€ข **Analyze** funnel metrics and conversions +โ€ข **Generate** marketing content +โ€ข **Understand** what drives user behavior + +What would you like to do? + +_(Note: Running in mock mode - set GEMINI_API_KEY for real AI responses)_`, + finishReason: "stop" + }; +} diff --git a/platform/backend/control-plane/src/index.ts b/platform/backend/control-plane/src/index.ts new file mode 100644 index 0000000..fd980a4 --- /dev/null +++ b/platform/backend/control-plane/src/index.ts @@ -0,0 +1,29 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; +import rateLimit from "@fastify/rate-limit"; +import sensible from "@fastify/sensible"; +import { config } from "./config.js"; +import { healthRoutes } from "./routes/health.js"; +import { toolRoutes } from "./routes/tools.js"; +import { runRoutes } from "./routes/runs.js"; +import { chatRoutes } from "./routes/chat.js"; + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: true }); +await app.register(helmet); +await app.register(sensible); +await app.register(rateLimit, { max: 300, timeWindow: "1 minute" }); + +await app.register(healthRoutes); +await app.register(toolRoutes); +await app.register(runRoutes); +await app.register(chatRoutes); + +app.listen({ port: config.port, host: "0.0.0.0" }).then(() => { + console.log(`๐Ÿš€ Control Plane API running on http://localhost:${config.port}`); +}).catch((err) => { + app.log.error(err); + process.exit(1); +}); diff --git a/platform/backend/control-plane/src/registry.ts b/platform/backend/control-plane/src/registry.ts new file mode 100644 index 0000000..bb10752 --- /dev/null +++ b/platform/backend/control-plane/src/registry.ts @@ -0,0 +1,10 @@ +import type { ToolDef } from "./types.js"; +import { listTools } from "./storage/index.js"; + +/** + * Simple registry. V2: cache + versioning + per-tenant overrides. + */ +export async function getRegistry(): Promise> { + const tools = await listTools(); + return Object.fromEntries(tools.map(t => [t.name, t])); +} diff --git a/platform/backend/control-plane/src/routes/chat.ts b/platform/backend/control-plane/src/routes/chat.ts new file mode 100644 index 0000000..46b52a6 --- /dev/null +++ b/platform/backend/control-plane/src/routes/chat.ts @@ -0,0 +1,306 @@ +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 } from "../storage/index.js"; +import { nanoid } from "nanoid"; +import type { RunRecord } from "../types.js"; + +interface ChatRequest { + messages: ChatMessage[]; + 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 => { + await requireAuth(req); + + const { messages, context, autoExecuteTools = true } = req.body; + + // Enhance messages with context if provided + let enhancedMessages = [...messages]; + 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}` }, + ...messages + ]; + } + + 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 }, + ...messages + ]; + } + + // 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 { + const runs: RunRecord[] = []; + const registry = await getRegistry(); + + for (const toolCall of toolCalls) { + // Map tool call names to actual tools + const toolMapping: Record = { + "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 { + 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"); +} diff --git a/platform/backend/control-plane/src/routes/health.ts b/platform/backend/control-plane/src/routes/health.ts new file mode 100644 index 0000000..5b99f1a --- /dev/null +++ b/platform/backend/control-plane/src/routes/health.ts @@ -0,0 +1,17 @@ +import type { FastifyInstance } from "fastify"; + +export async function healthRoutes(app: FastifyInstance) { + // Root route - API info + app.get("/", async () => ({ + name: "Product OS Control Plane", + version: "0.1.0", + endpoints: { + health: "GET /healthz", + tools: "GET /tools", + invoke: "POST /tools/invoke", + runs: "GET /runs/:run_id" + } + })); + + app.get("/healthz", async () => ({ ok: true })); +} diff --git a/platform/backend/control-plane/src/routes/runs.ts b/platform/backend/control-plane/src/routes/runs.ts new file mode 100644 index 0000000..02bded5 --- /dev/null +++ b/platform/backend/control-plane/src/routes/runs.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance } from "fastify"; +import { requireAuth } from "../auth.js"; +import { getRun } from "../storage/index.js"; + +export async function runRoutes(app: FastifyInstance) { + app.get("/runs/:run_id", async (req) => { + await requireAuth(req); + const runId = (req.params as any).run_id as string; + const run = await getRun(runId); + if (!run) return app.httpErrors.notFound("Run not found"); + return run; + }); + + app.get("/runs/:run_id/logs", async (req) => { + await requireAuth(req); + return { note: "V1: logs are in GCS artifacts under runs//" }; + }); +} diff --git a/platform/backend/control-plane/src/routes/tools.ts b/platform/backend/control-plane/src/routes/tools.ts new file mode 100644 index 0000000..80789fd --- /dev/null +++ b/platform/backend/control-plane/src/routes/tools.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance } from "fastify"; +import { nanoid } from "nanoid"; +import { requireAuth } from "../auth.js"; +import { getRegistry } from "../registry.js"; +import { saveRun, writeArtifactText } from "../storage/index.js"; +import type { RunRecord, ToolInvokeRequest } from "../types.js"; + +async function postJson(url: string, body: unknown) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Executor error ${res.status}: ${txt}`); + } + return res.json() as Promise; +} + +export async function toolRoutes(app: FastifyInstance) { + app.get("/tools", async (req) => { + await requireAuth(req); + const registry = await getRegistry(); + return { tools: Object.values(registry) }; + }); + + app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => { + await requireAuth(req); + + const body = req.body; + const registry = await getRegistry(); + const tool = registry[body.tool]; + if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`); + + const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`; + const now = new Date().toISOString(); + + const run: RunRecord = { + run_id: runId, + tenant_id: body.tenant_id, + tool: body.tool, + status: "queued", + created_at: now, + updated_at: now, + input: body.input, + artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` } + }; + + await saveRun(run); + await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2)); + + try { + run.status = "running"; + run.updated_at = new Date().toISOString(); + await saveRun(run); + + if (body.dry_run) { + run.status = "succeeded"; + run.output = { dry_run: true }; + run.updated_at = new Date().toISOString(); + await saveRun(run); + await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2)); + return { run_id: runId, status: run.status }; + } + + const execUrl = `${tool.executor.url}${tool.executor.path}`; + const output = await postJson(execUrl, { + run_id: runId, + tenant_id: body.tenant_id, + workspace_id: body.workspace_id, + input: body.input + }); + + run.status = "succeeded"; + run.output = output; + run.updated_at = new Date().toISOString(); + await saveRun(run); + await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2)); + + return { run_id: runId, status: run.status }; + } catch (e: any) { + run.status = "failed"; + run.error = { message: e?.message ?? "Unknown error" }; + run.updated_at = new Date().toISOString(); + await saveRun(run); + await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2)); + return { run_id: runId, status: run.status }; + } + }); +} diff --git a/platform/backend/control-plane/src/storage/firestore.ts b/platform/backend/control-plane/src/storage/firestore.ts new file mode 100644 index 0000000..38b7133 --- /dev/null +++ b/platform/backend/control-plane/src/storage/firestore.ts @@ -0,0 +1,23 @@ +import { Firestore } from "@google-cloud/firestore"; +import { config } from "../config.js"; +import type { RunRecord, ToolDef } from "../types.js"; + +const db = new Firestore({ projectId: config.projectId }); + +export async function saveRun(run: RunRecord): Promise { + await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true }); +} + +export async function getRun(runId: string): Promise { + const snap = await db.collection(config.runsCollection).doc(runId).get(); + return snap.exists ? (snap.data() as RunRecord) : null; +} + +export async function saveTool(tool: ToolDef): Promise { + await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true }); +} + +export async function listTools(): Promise { + const snap = await db.collection(config.toolsCollection).get(); + return snap.docs.map(d => d.data() as ToolDef); +} diff --git a/platform/backend/control-plane/src/storage/gcs.ts b/platform/backend/control-plane/src/storage/gcs.ts new file mode 100644 index 0000000..1af6d64 --- /dev/null +++ b/platform/backend/control-plane/src/storage/gcs.ts @@ -0,0 +1,11 @@ +import { Storage } from "@google-cloud/storage"; +import { config } from "../config.js"; + +const storage = new Storage({ projectId: config.projectId }); + +export async function writeArtifactText(prefix: string, filename: string, content: string) { + const bucket = storage.bucket(config.artifactsBucket); + const file = bucket.file(`${prefix}/${filename}`); + await file.save(content, { contentType: "text/plain" }); + return { bucket: config.artifactsBucket, path: `${prefix}/${filename}` }; +} diff --git a/platform/backend/control-plane/src/storage/index.ts b/platform/backend/control-plane/src/storage/index.ts new file mode 100644 index 0000000..a52eb4c --- /dev/null +++ b/platform/backend/control-plane/src/storage/index.ts @@ -0,0 +1,23 @@ +/** + * Storage adapter that switches between GCP (Firestore/GCS) and in-memory + */ +import { config } from "../config.js"; +import * as memory from "./memory.js"; +import * as firestore from "./firestore.js"; +import * as gcs from "./gcs.js"; + +const useMemory = config.storageMode === "memory"; + +if (useMemory) { + console.log("๐Ÿ’พ Using in-memory storage (set GCP_PROJECT_ID for Firestore/GCS)"); + memory.seedTools(); +} else { + console.log(`โ˜๏ธ Using GCP storage (project: ${config.projectId})`); +} + +// Export unified interface +export const saveRun = useMemory ? memory.saveRun : firestore.saveRun; +export const getRun = useMemory ? memory.getRun : firestore.getRun; +export const saveTool = useMemory ? memory.saveTool : firestore.saveTool; +export const listTools = useMemory ? memory.listTools : firestore.listTools; +export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText; diff --git a/platform/backend/control-plane/src/storage/memory.ts b/platform/backend/control-plane/src/storage/memory.ts new file mode 100644 index 0000000..e595a68 --- /dev/null +++ b/platform/backend/control-plane/src/storage/memory.ts @@ -0,0 +1,116 @@ +/** + * In-memory storage for local development without Firestore/GCS + */ +import type { RunRecord, ToolDef } from "../types.js"; + +// In-memory stores +const runs = new Map(); +const tools = new Map(); +const artifacts = new Map(); + +// Run operations +export async function saveRun(run: RunRecord): Promise { + runs.set(run.run_id, { ...run }); +} + +export async function getRun(runId: string): Promise { + return runs.get(runId) ?? null; +} + +// Tool operations +export async function saveTool(tool: ToolDef): Promise { + tools.set(tool.name, { ...tool }); +} + +export async function listTools(): Promise { + return Array.from(tools.values()); +} + +// Artifact operations +export async function writeArtifactText(prefix: string, filename: string, content: string) { + const path = `${prefix}/${filename}`; + artifacts.set(path, content); + return { bucket: "memory", path }; +} + +// Seed some example tools for testing +export function seedTools() { + const sampleTools: ToolDef[] = [ + { + name: "cloudrun.deploy_service", + description: "Build and deploy a Cloud Run service", + risk: "medium", + executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/deploy" }, + inputSchema: { + type: "object", + required: ["service_name", "repo", "ref", "env"], + properties: { + service_name: { type: "string" }, + repo: { type: "string" }, + ref: { type: "string" }, + env: { type: "string", enum: ["dev", "staging", "prod"] } + } + } + }, + { + name: "cloudrun.get_service_status", + description: "Get Cloud Run service status", + risk: "low", + executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/status" }, + inputSchema: { + type: "object", + required: ["service_name", "region"], + properties: { + service_name: { type: "string" }, + region: { type: "string" } + } + } + }, + { + name: "analytics.funnel_summary", + description: "Get funnel metrics for a time window", + risk: "low", + executor: { kind: "http", url: "http://localhost:8091", path: "/execute/analytics/funnel" }, + inputSchema: { + type: "object", + required: ["range_days"], + properties: { + range_days: { type: "integer", minimum: 1, maximum: 365 } + } + } + }, + { + name: "brand.get_profile", + description: "Get tenant brand profile", + risk: "low", + executor: { kind: "http", url: "http://localhost:8092", path: "/execute/brand/get" }, + inputSchema: { + type: "object", + required: ["profile_id"], + properties: { + profile_id: { type: "string" } + } + } + }, + { + name: "marketing.generate_channel_posts", + description: "Generate social posts from a brief", + risk: "low", + executor: { kind: "http", url: "http://localhost:8093", path: "/execute/marketing/generate" }, + inputSchema: { + type: "object", + required: ["brief", "channels"], + properties: { + brief: { type: "object" }, + channels: { type: "array", items: { type: "string" } } + } + } + } + ]; + + for (const tool of sampleTools) { + tools.set(tool.name, tool); + } + + console.log(`๐Ÿ“ฆ Seeded ${sampleTools.length} tools in memory`); +} diff --git a/platform/backend/control-plane/src/types.ts b/platform/backend/control-plane/src/types.ts new file mode 100644 index 0000000..ab50def --- /dev/null +++ b/platform/backend/control-plane/src/types.ts @@ -0,0 +1,37 @@ +export type ToolRisk = "low" | "medium" | "high"; + +export type ToolDef = { + name: string; + description: string; + risk: ToolRisk; + executor: { + kind: "http"; + url: string; + path: string; + }; + inputSchema: unknown; + outputSchema?: unknown; +}; + +export type ToolInvokeRequest = { + tool: string; + tenant_id: string; + workspace_id?: string; + input: unknown; + dry_run?: boolean; +}; + +export type RunStatus = "queued" | "running" | "succeeded" | "failed"; + +export type RunRecord = { + run_id: string; + tenant_id: string; + tool: string; + status: RunStatus; + created_at: string; + updated_at: string; + input: unknown; + output?: unknown; + error?: { message: string; details?: unknown }; + artifacts?: { bucket: string; prefix: string }; +}; diff --git a/platform/backend/control-plane/tsconfig.json b/platform/backend/control-plane/tsconfig.json new file mode 100644 index 0000000..a6d228d --- /dev/null +++ b/platform/backend/control-plane/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/platform/backend/executors/analytics/package.json b/platform/backend/executors/analytics/package.json new file mode 100644 index 0000000..dcef35e --- /dev/null +++ b/platform/backend/executors/analytics/package.json @@ -0,0 +1,22 @@ +{ + "name": "@productos/analytics-executor", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/cors": "^10.0.0", + "@fastify/sensible": "^6.0.0", + "fastify": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/platform/backend/executors/analytics/src/index.ts b/platform/backend/executors/analytics/src/index.ts new file mode 100644 index 0000000..c2c2b21 --- /dev/null +++ b/platform/backend/executors/analytics/src/index.ts @@ -0,0 +1,91 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import sensible from "@fastify/sensible"; + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: true }); +await app.register(sensible); + +app.get("/healthz", async () => ({ ok: true, executor: "analytics" })); + +/** + * Get funnel summary + * In production: queries BigQuery + */ +app.post("/execute/analytics/funnel", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`๐Ÿ“Š Funnel request:`, input); + + // Mock funnel data + const steps = [ + { event_name: "page_view", users: 10000, conversion_from_prev: 1.0 }, + { event_name: "signup_start", users: 3200, conversion_from_prev: 0.32 }, + { event_name: "signup_complete", users: 2100, conversion_from_prev: 0.66 }, + { event_name: "first_action", users: 1400, conversion_from_prev: 0.67 }, + { event_name: "subscription", users: 420, conversion_from_prev: 0.30 } + ]; + + return { + funnel_name: input.funnel?.name ?? "default_funnel", + range_days: input.range_days, + steps, + overall_conversion: 0.042, + generated_at: new Date().toISOString() + }; +}); + +/** + * Get top drivers for a metric + */ +app.post("/execute/analytics/top_drivers", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`๐Ÿ” Top drivers request:`, input); + + // Mock driver analysis + const drivers = [ + { name: "completed_onboarding", score: 0.85, direction: "positive", evidence: "Users who complete onboarding convert 3.2x more", confidence: 0.92 }, + { name: "used_feature_x", score: 0.72, direction: "positive", evidence: "Feature X usage correlates with 2.5x retention", confidence: 0.88 }, + { name: "time_to_first_value", score: 0.68, direction: "negative", evidence: "Each additional day reduces conversion by 12%", confidence: 0.85 }, + { name: "invited_team_member", score: 0.61, direction: "positive", evidence: "Team invites increase stickiness by 40%", confidence: 0.79 }, + { name: "mobile_signup", score: 0.45, direction: "negative", evidence: "Mobile signups have 25% lower activation", confidence: 0.71 } + ]; + + return { + target: input.target, + range_days: input.range_days, + drivers, + generated_at: new Date().toISOString() + }; +}); + +/** + * Write an insight + */ +app.post("/execute/analytics/write_insight", async (req) => { + const body = req.body as any; + const { input, run_id } = body; + + console.log(`๐Ÿ’ก Write insight:`, input.insight?.title); + + const insightId = `insight_${Date.now().toString(36)}`; + + return { + insight_id: insightId, + stored: { + bigquery: { dataset: "insights", table: "insights_v1" }, + firestore: { collection: "insights", doc_id: insightId }, + gcs: { bucket: "productos-artifacts", prefix: `insights/${insightId}` } + }, + created_at: new Date().toISOString() + }; +}); + +const port = Number(process.env.PORT ?? 8091); +app.listen({ port, host: "0.0.0.0" }).then(() => { + console.log(`๐Ÿ“ˆ Analytics Executor running on http://localhost:${port}`); +}); diff --git a/platform/backend/executors/analytics/tsconfig.json b/platform/backend/executors/analytics/tsconfig.json new file mode 100644 index 0000000..a6d228d --- /dev/null +++ b/platform/backend/executors/analytics/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/platform/backend/executors/deploy/package.json b/platform/backend/executors/deploy/package.json new file mode 100644 index 0000000..77cf9d1 --- /dev/null +++ b/platform/backend/executors/deploy/package.json @@ -0,0 +1,23 @@ +{ + "name": "@productos/deploy-executor", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/cors": "^10.0.0", + "@fastify/sensible": "^6.0.0", + "fastify": "^5.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/platform/backend/executors/deploy/src/index.ts b/platform/backend/executors/deploy/src/index.ts new file mode 100644 index 0000000..d18a2c9 --- /dev/null +++ b/platform/backend/executors/deploy/src/index.ts @@ -0,0 +1,91 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import sensible from "@fastify/sensible"; + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: true }); +await app.register(sensible); + +// Health check +app.get("/healthz", async () => ({ ok: true, executor: "deploy" })); + +/** + * Deploy a Cloud Run service + * In production: triggers Cloud Build, deploys to Cloud Run + * In dev: returns mock response + */ +app.post("/execute/cloudrun/deploy", async (req) => { + const body = req.body as any; + const { run_id, tenant_id, input } = body; + + console.log(`๐Ÿš€ Deploy request:`, { run_id, tenant_id, input }); + + // Simulate deployment time + await new Promise(r => setTimeout(r, 1500)); + + // In production, this would: + // 1. Clone the repo + // 2. Trigger Cloud Build + // 3. Deploy to Cloud Run + // 4. Return the service URL + + const mockRevision = `${input.service_name}-${Date.now().toString(36)}`; + const mockUrl = `https://${input.service_name}-abc123.a.run.app`; + + console.log(`โœ… Deploy complete:`, { revision: mockRevision, url: mockUrl }); + + return { + service_url: mockUrl, + revision: mockRevision, + build_id: `build-${Date.now()}`, + deployed_at: new Date().toISOString(), + region: input.region ?? "us-central1", + env: input.env + }; +}); + +/** + * Get Cloud Run service status + */ +app.post("/execute/cloudrun/status", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`๐Ÿ“Š Status request:`, input); + + // Mock status response + return { + service_name: input.service_name, + region: input.region, + service_url: `https://${input.service_name}-abc123.a.run.app`, + latest_ready_revision: `${input.service_name}-v1`, + status: "ready", + last_deploy_time: new Date().toISOString(), + traffic: [{ revision: `${input.service_name}-v1`, percent: 100 }] + }; +}); + +/** + * Rollback to a previous revision + */ +app.post("/execute/cloudrun/rollback", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`โช Rollback request:`, input); + + await new Promise(r => setTimeout(r, 1000)); + + return { + service_name: input.service_name, + rolled_back_to: input.target_revision ?? "previous", + status: "ready", + rolled_back_at: new Date().toISOString() + }; +}); + +const port = Number(process.env.PORT ?? 8090); +app.listen({ port, host: "0.0.0.0" }).then(() => { + console.log(`๐Ÿ”ง Deploy Executor running on http://localhost:${port}`); +}); diff --git a/platform/backend/executors/deploy/tsconfig.json b/platform/backend/executors/deploy/tsconfig.json new file mode 100644 index 0000000..a6d228d --- /dev/null +++ b/platform/backend/executors/deploy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/platform/backend/executors/marketing/package.json b/platform/backend/executors/marketing/package.json new file mode 100644 index 0000000..a995c7a --- /dev/null +++ b/platform/backend/executors/marketing/package.json @@ -0,0 +1,22 @@ +{ + "name": "@productos/marketing-executor", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/cors": "^10.0.0", + "@fastify/sensible": "^6.0.0", + "fastify": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/platform/backend/executors/marketing/src/index.ts b/platform/backend/executors/marketing/src/index.ts new file mode 100644 index 0000000..06f8543 --- /dev/null +++ b/platform/backend/executors/marketing/src/index.ts @@ -0,0 +1,88 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import sensible from "@fastify/sensible"; + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: true }); +await app.register(sensible); + +app.get("/healthz", async () => ({ ok: true, executor: "marketing" })); + +/** + * Generate channel posts from a brief + * In production: calls Gemini API + */ +app.post("/execute/marketing/generate", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`โœ๏ธ Generate posts:`, input.brief?.goal); + + await new Promise(r => setTimeout(r, 1000)); // Simulate AI generation time + + const channels = (input.channels ?? ["x", "linkedin"]).map((channel: string) => ({ + channel, + posts: [ + { + text: `๐Ÿš€ Exciting news! ${input.brief?.product ?? "Our product"} just got even better. ${input.brief?.key_points?.[0] ?? "Check it out!"}\n\n${input.brief?.call_to_action ?? "Learn more"} ๐Ÿ‘‡\n${input.brief?.landing_page_url ?? "https://example.com"}`, + hashtags: ["#ProductUpdate", "#SaaS", "#Innovation"], + media_suggestions: ["product-screenshot.png", "feature-demo.gif"] + }, + { + text: `${input.brief?.audience ?? "Teams"} asked, we listened! Introducing ${input.brief?.product ?? "new features"} that will transform how you work.\n\nโœจ ${input.brief?.key_points?.join("\nโœจ ") ?? "Amazing new capabilities"}\n\nTry it today!`, + hashtags: ["#ProductLaunch", "#Productivity"], + media_suggestions: ["comparison-chart.png"] + }, + { + text: `Did you know? ${input.brief?.key_points?.[0] ?? "Our latest update"} can save you hours every week.\n\nHere's how ${input.brief?.product ?? "it"} works:\n1๏ธโƒฃ Set it up in minutes\n2๏ธโƒฃ Let automation do the work\n3๏ธโƒฃ Focus on what matters\n\n${input.brief?.offer ?? "Start free today!"}`, + hashtags: ["#Automation", "#WorkSmarter"], + media_suggestions: ["how-it-works.mp4"] + } + ] + })); + + return { + channels, + brief_summary: input.brief?.goal, + generated_at: new Date().toISOString(), + variations_per_channel: 3 + }; +}); + +/** + * Get brand profile + */ +app.post("/execute/brand/get", async (req) => { + const body = req.body as any; + const { input } = body; + + console.log(`๐ŸŽจ Get brand profile:`, input.profile_id); + + return { + profile_id: input.profile_id ?? "default", + brand: { + name: "Product OS", + voice: { + tone: ["professional", "friendly", "innovative"], + style_notes: ["Use active voice", "Keep sentences short", "Be specific with benefits"], + do: ["Use data and metrics", "Include calls to action", "Highlight customer success"], + dont: ["Make unverified claims", "Use jargon", "Be overly salesy"] + }, + audience: { + primary: "SaaS founders and product teams", + secondary: "Growth marketers and developers" + }, + claims_policy: { + forbidden_claims: ["#1 in the market", "Guaranteed results"], + required_disclaimers: [], + compliance_notes: ["All metrics should be verifiable"] + } + } + }; +}); + +const port = Number(process.env.PORT ?? 8093); +app.listen({ port, host: "0.0.0.0" }).then(() => { + console.log(`๐Ÿ“ฃ Marketing Executor running on http://localhost:${port}`); +}); diff --git a/platform/backend/executors/marketing/tsconfig.json b/platform/backend/executors/marketing/tsconfig.json new file mode 100644 index 0000000..a6d228d --- /dev/null +++ b/platform/backend/executors/marketing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/platform/backend/mcp-adapter/README.md b/platform/backend/mcp-adapter/README.md new file mode 100644 index 0000000..cc04912 --- /dev/null +++ b/platform/backend/mcp-adapter/README.md @@ -0,0 +1,103 @@ +# Product OS MCP Adapter + +Exposes Control Plane tools to Continue and other MCP clients. + +## Architecture + +``` +Continue (MCP client) + โ†“ + MCP Adapter (this server) + โ†“ + Control Plane API + โ†“ + Gemini + Executors +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `deploy_service` | Deploy a Cloud Run service | +| `get_service_status` | Check deployment health | +| `get_funnel_analytics` | Get conversion metrics | +| `get_top_drivers` | Understand metric drivers | +| `generate_marketing_posts` | Create social content | +| `chat_with_gemini` | General AI conversation | + +## Setup + +### 1. Install dependencies + +```bash +cd platform/backend/mcp-adapter +npm install +``` + +### 2. Make sure Control Plane is running + +```bash +cd platform/backend/control-plane +npm run dev +``` + +### 3. Install Continue Extension + +In VS Code/VSCodium: +1. Open Extensions (Cmd+Shift+X) +2. Search for "Continue" +3. Install + +### 4. Configure Continue + +The configuration is already in `.continue/config.yaml`. + +Or manually add to your Continue config: + +```yaml +experimental: + modelContextProtocolServers: + - name: productos + command: npx + args: + - tsx + - /path/to/platform/backend/mcp-adapter/src/index.ts + env: + CONTROL_PLANE_URL: http://localhost:8080 +``` + +### 5. Use in Continue + +1. Open Continue chat (Cmd+L) +2. Enable Agent mode (click the robot icon) +3. Ask: "Deploy my service to staging" + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CONTROL_PLANE_URL` | `http://localhost:8080` | Control Plane API URL | +| `TENANT_ID` | `t_mcp` | Tenant ID for tool calls | + +## Development + +```bash +# Run directly (for testing) +npm run dev + +# Build +npm run build + +# Run built version +npm start +``` + +## Testing + +```bash +# Test tool listing +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npm run dev + +# Test tool call +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_funnel_analytics","arguments":{}}}' | npm run dev +``` diff --git a/platform/backend/mcp-adapter/package.json b/platform/backend/mcp-adapter/package.json new file mode 100644 index 0000000..ed13ef3 --- /dev/null +++ b/platform/backend/mcp-adapter/package.json @@ -0,0 +1,25 @@ +{ + "name": "@productos/mcp-adapter", + "version": "0.1.0", + "private": true, + "description": "MCP Adapter Server - exposes Control Plane tools to Continue and other MCP clients", + "type": "module", + "main": "dist/index.js", + "bin": { + "productos-mcp": "./dist/index.js" + }, + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.4" + } +} diff --git a/platform/backend/mcp-adapter/src/index.ts b/platform/backend/mcp-adapter/src/index.ts new file mode 100644 index 0000000..e88269f --- /dev/null +++ b/platform/backend/mcp-adapter/src/index.ts @@ -0,0 +1,343 @@ +#!/usr/bin/env node +/** + * Product OS MCP Adapter Server + * + * Exposes Control Plane tools to Continue and other MCP clients. + * + * Architecture: + * Continue (MCP client) โ†’ This Adapter โ†’ Control Plane โ†’ Gemini + Executors + * + * This keeps: + * - Control Plane as the canonical API + * - Auth/billing/policies centralized + * - MCP as a compatibility layer + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ToolSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +// Control Plane URL (configurable via env) +const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL || "http://localhost:8080"; +const TENANT_ID = process.env.TENANT_ID || "t_mcp"; + +// ============================================================================ +// Tool Definitions +// ============================================================================ + +const TOOLS: ToolSchema[] = [ + { + name: "deploy_service", + description: "Deploy a Cloud Run service to GCP. Use when the user wants to deploy, ship, or launch code to staging or production.", + inputSchema: { + type: "object", + properties: { + service_name: { + type: "string", + description: "Name of the service to deploy" + }, + repo: { + type: "string", + description: "Git repository URL (optional, defaults to current workspace)" + }, + ref: { + type: "string", + description: "Git branch, tag, or commit (optional, defaults to main)" + }, + env: { + type: "string", + enum: ["dev", "staging", "prod"], + description: "Target environment" + } + }, + required: ["service_name"] + } + }, + { + name: "get_service_status", + description: "Check the status of a deployed Cloud Run service. Use when the user asks about service health or deployment status.", + inputSchema: { + type: "object", + properties: { + service_name: { + type: "string", + description: "Name of the service to check" + }, + region: { + type: "string", + description: "GCP region (defaults to us-central1)" + } + }, + required: ["service_name"] + } + }, + { + name: "get_funnel_analytics", + description: "Get funnel conversion metrics and drop-off analysis. Use when the user asks about funnels, conversions, or user journey.", + inputSchema: { + type: "object", + properties: { + funnel_name: { + type: "string", + description: "Name of the funnel to analyze (optional)" + }, + range_days: { + type: "integer", + description: "Number of days to analyze (defaults to 30)" + } + } + } + }, + { + name: "get_top_drivers", + description: "Identify top factors driving a metric. Use when the user asks why something changed or what drives conversions/retention.", + inputSchema: { + type: "object", + properties: { + metric: { + type: "string", + description: "The metric to analyze (e.g., 'conversion', 'retention', 'churn')" + }, + range_days: { + type: "integer", + description: "Number of days to analyze (defaults to 30)" + } + }, + required: ["metric"] + } + }, + { + name: "generate_marketing_posts", + description: "Generate social media posts for a marketing campaign. Use when the user wants to create content for X, LinkedIn, etc.", + inputSchema: { + type: "object", + properties: { + goal: { + type: "string", + description: "Campaign goal (e.g., 'product launch', 'feature announcement', 'engagement')" + }, + product: { + type: "string", + description: "Product or feature name" + }, + channels: { + type: "array", + items: { type: "string" }, + description: "Social channels to generate for (e.g., ['x', 'linkedin'])" + }, + tone: { + type: "string", + description: "Tone of voice (e.g., 'professional', 'casual', 'excited')" + } + }, + required: ["goal"] + } + }, + { + name: "chat_with_gemini", + description: "Have a conversation with Gemini AI about your product, code, or anything else. Use for general questions, code explanation, or ideation.", + inputSchema: { + type: "object", + properties: { + message: { + type: "string", + description: "The message or question to send to Gemini" + }, + context: { + type: "string", + description: "Additional context (e.g., code snippet, file contents)" + } + }, + required: ["message"] + } + } +]; + +// ============================================================================ +// Tool Name Mapping (MCP tool name โ†’ Control Plane tool name) +// ============================================================================ + +const TOOL_MAPPING: Record = { + "deploy_service": "cloudrun.deploy_service", + "get_service_status": "cloudrun.get_service_status", + "get_funnel_analytics": "analytics.funnel_summary", + "get_top_drivers": "analytics.top_drivers", + "generate_marketing_posts": "marketing.generate_channel_posts", +}; + +// ============================================================================ +// Control Plane Client +// ============================================================================ + +async function invokeControlPlaneTool(tool: string, input: any): Promise { + // Step 1: Invoke the tool + const invokeResponse = await fetch(`${CONTROL_PLANE_URL}/tools/invoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tool, + tenant_id: TENANT_ID, + input, + dry_run: false + }) + }); + + if (!invokeResponse.ok) { + const text = await invokeResponse.text(); + throw new Error(`Control Plane error: ${invokeResponse.status} - ${text}`); + } + + const invokeResult = await invokeResponse.json(); + + // Step 2: Fetch the full run to get the output + const runResponse = await fetch(`${CONTROL_PLANE_URL}/runs/${invokeResult.run_id}`); + + if (!runResponse.ok) { + // Fall back to the invoke result if we can't fetch the run + return invokeResult; + } + + return runResponse.json(); +} + +async function chatWithControlPlane(message: string, context?: string): Promise { + const messages = []; + + if (context) { + messages.push({ role: "user", content: `Context:\n${context}` }); + } + messages.push({ role: "user", content: message }); + + const response = await fetch(`${CONTROL_PLANE_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages, + autoExecuteTools: true + }) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Control Plane chat error: ${response.status} - ${text}`); + } + + return response.json(); +} + +// ============================================================================ +// MCP Server +// ============================================================================ + +const server = new Server( + { + name: "productos-mcp", + version: "0.1.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// List available tools +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; +}); + +// Handle tool calls +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + console.error(`[MCP] Tool called: ${name}`, args); + + try { + // Special handling for chat + if (name === "chat_with_gemini") { + const result = await chatWithControlPlane( + (args as any).message, + (args as any).context + ); + + return { + content: [ + { + type: "text", + text: result.message || JSON.stringify(result, null, 2) + } + ] + }; + } + + // Map MCP tool name to Control Plane tool name + const controlPlaneTool = TOOL_MAPPING[name]; + if (!controlPlaneTool) { + throw new Error(`Unknown tool: ${name}`); + } + + // Invoke the Control Plane + const run = await invokeControlPlaneTool(controlPlaneTool, args); + + // Format the response + let responseText = ""; + + if (run.status === "succeeded") { + responseText = `โœ… **${name}** completed successfully\n\n`; + responseText += "**Result:**\n```json\n"; + responseText += JSON.stringify(run.output, null, 2); + responseText += "\n```"; + } else if (run.status === "failed") { + responseText = `โŒ **${name}** failed\n\n`; + responseText += `**Error:** ${run.error?.message || "Unknown error"}`; + } else { + responseText = `โณ **${name}** is ${run.status}\n\n`; + responseText += `**Run ID:** ${run.run_id}`; + } + + return { + content: [ + { + type: "text", + text: responseText + } + ] + }; + + } catch (error: any) { + console.error(`[MCP] Error:`, error); + return { + content: [ + { + type: "text", + text: `โŒ Error executing ${name}: ${error.message}` + } + ], + isError: true + }; + } +}); + +// ============================================================================ +// Start Server +// ============================================================================ + +async function main() { + console.error("[MCP] Product OS MCP Adapter starting..."); + console.error(`[MCP] Control Plane URL: ${CONTROL_PLANE_URL}`); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.error("[MCP] Server connected and ready"); +} + +main().catch((error) => { + console.error("[MCP] Fatal error:", error); + process.exit(1); +}); diff --git a/platform/backend/mcp-adapter/tsconfig.json b/platform/backend/mcp-adapter/tsconfig.json new file mode 100644 index 0000000..486fed8 --- /dev/null +++ b/platform/backend/mcp-adapter/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/platform/client-ide/extensions/gcp-productos/media/icon.svg b/platform/client-ide/extensions/gcp-productos/media/icon.svg new file mode 100644 index 0000000..47547c8 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/media/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/platform/client-ide/extensions/gcp-productos/package.json b/platform/client-ide/extensions/gcp-productos/package.json new file mode 100644 index 0000000..3d458ce --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/package.json @@ -0,0 +1,97 @@ +{ + "name": "gcp-productos", + "displayName": "GCP Product OS", + "description": "Product-centric IDE for launching and operating SaaS products on Google Cloud.", + "version": "0.1.0", + "publisher": "productos", + "engines": { "vscode": "^1.90.0" }, + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "productos", + "title": "Product OS", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "productos": [ + { + "id": "productos.chat", + "type": "webview", + "name": "Chat", + "icon": "$(comment-discussion)" + }, + { + "id": "productos.tools", + "name": "Tools", + "icon": "media/icon.svg" + }, + { + "id": "productos.runs", + "name": "Recent Runs" + } + ] + }, + "viewsWelcome": [ + { + "view": "productos.tools", + "contents": "Connect to Product OS backend to see available tools.\n[Configure Backend](command:productos.configure)" + } + ], + "commands": [ + { "command": "productos.chat", "title": "Product OS: Open Chat", "icon": "$(comment-discussion)" }, + { "command": "productos.configure", "title": "Product OS: Configure Backend", "icon": "$(gear)" }, + { "command": "productos.refresh", "title": "Product OS: Refresh", "icon": "$(refresh)" }, + { "command": "productos.tools.list", "title": "Product OS: List Tools" }, + { "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool", "icon": "$(play)" }, + { "command": "productos.tools.invokeFromTree", "title": "Invoke Tool", "icon": "$(play)" }, + { "command": "productos.runs.open", "title": "Product OS: Open Run" }, + { "command": "productos.runs.openFromTree", "title": "View Run Details", "icon": "$(eye)" }, + { "command": "productos.openPanel", "title": "Product OS: Open Panel" } + ], + "keybindings": [ + { "command": "productos.chat", "key": "ctrl+shift+p", "mac": "cmd+shift+p", "when": "editorTextFocus" } + ], + "menus": { + "view/title": [ + { "command": "productos.refresh", "when": "view == productos.tools", "group": "navigation" }, + { "command": "productos.configure", "when": "view == productos.tools" } + ], + "view/item/context": [ + { "command": "productos.tools.invokeFromTree", "when": "viewItem == tool", "group": "inline" }, + { "command": "productos.runs.openFromTree", "when": "viewItem == run", "group": "inline" } + ] + }, + "configuration": { + "title": "Product OS", + "properties": { + "productos.backendUrl": { + "type": "string", + "default": "http://localhost:8080", + "description": "Control Plane API base URL" + }, + "productos.tenantId": { + "type": "string", + "default": "t_dev", + "description": "Tenant ID for tool calls" + } + } + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "watch": "tsc -w -p tsconfig.json", + "package": "vsce package --allow-missing-repository" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^3.0.0", + "typescript": "^5.5.4" + } +} diff --git a/platform/client-ide/extensions/gcp-productos/src/api.ts b/platform/client-ide/extensions/gcp-productos/src/api.ts new file mode 100644 index 0000000..ea5cc9d --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/api.ts @@ -0,0 +1,137 @@ +import * as vscode from "vscode"; + +export interface Tool { + name: string; + description: string; + risk: "low" | "medium" | "high"; + executor: { + kind: string; + url: string; + path: string; + }; + inputSchema: any; + outputSchema?: any; +} + +export interface Run { + run_id: string; + tenant_id: string; + tool: string; + status: "queued" | "running" | "succeeded" | "failed"; + created_at: string; + updated_at: string; + input: any; + output?: any; + error?: { message: string; details?: any }; +} + +function getConfig(key: string): T { + return vscode.workspace.getConfiguration("productos").get(key)!; +} + +export function getBackendUrl(): string { + return getConfig("backendUrl"); +} + +export function getTenantId(): string { + return getConfig("tenantId"); +} + +export async function checkConnection(): Promise { + try { + const res = await fetch(`${getBackendUrl()}/healthz`, { + signal: AbortSignal.timeout(3000) + }); + return res.ok; + } catch { + return false; + } +} + +export async function listTools(): Promise { + const res = await fetch(`${getBackendUrl()}/tools`); + if (!res.ok) throw new Error(await res.text()); + const json = await res.json(); + return json.tools ?? []; +} + +export async function invokeTool(tool: string, input: any, dryRun = false): Promise { + const res = await fetch(`${getBackendUrl()}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tool, + tenant_id: getTenantId(), + input, + dry_run: dryRun + }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function getRun(runId: string): Promise { + const res = await fetch(`${getBackendUrl()}/runs/${runId}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// Store recent runs in memory +const recentRuns: Run[] = []; + +export function addRecentRun(run: Run) { + recentRuns.unshift(run); + if (recentRuns.length > 20) recentRuns.pop(); +} + +export function getRecentRuns(): Run[] { + return [...recentRuns]; +} + +// Chat types +export interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; +} + +export interface ToolCall { + name: string; + arguments: Record; +} + +export interface ChatResponse { + message: string; + toolCalls?: ToolCall[]; + runs?: Run[]; + finishReason: "stop" | "tool_calls" | "error"; +} + +export interface ChatContext { + files?: { path: string; content: string }[]; + selection?: { path: string; text: string; startLine: number }; +} + +/** + * Chat with the AI backend + */ +export async function chatWithAI( + messages: ChatMessage[], + context?: ChatContext +): Promise { + const res = await fetch(`${getBackendUrl()}/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + messages, + context, + autoExecuteTools: true + }) + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Chat failed: ${text}`); + } + + return res.json(); +} diff --git a/platform/client-ide/extensions/gcp-productos/src/chatPanel.ts b/platform/client-ide/extensions/gcp-productos/src/chatPanel.ts new file mode 100644 index 0000000..df6757d --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/chatPanel.ts @@ -0,0 +1,850 @@ +import * as vscode from "vscode"; +import { chatWithAI, ChatMessage, ChatResponse } from "./api"; + +/** + * Product OS Chat Panel + * A Cursor-like conversational AI interface + */ +export class ChatPanel { + public static currentPanel: ChatPanel | undefined; + private static readonly viewType = "productosChat"; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + private _messages: ChatMessage[] = []; + + public static createOrShow(extensionUri: vscode.Uri) { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + // If we already have a panel, show it + if (ChatPanel.currentPanel) { + ChatPanel.currentPanel._panel.reveal(column); + return; + } + + // Otherwise, create a new panel + const panel = vscode.window.createWebviewPanel( + ChatPanel.viewType, + "Product OS Chat", + column || vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, "media")] + } + ); + + ChatPanel.currentPanel = new ChatPanel(panel, extensionUri); + } + + private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { + this._panel = panel; + this._extensionUri = extensionUri; + + // Set the webview's initial html content + this._update(); + + // Listen for when the panel is disposed + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + // Handle messages from the webview + this._panel.webview.onDidReceiveMessage( + async (message) => { + switch (message.command) { + case "send": + await this._handleChat(message.text); + return; + case "addContext": + await this._handleAddContext(); + return; + case "clear": + this._messages = []; + this._update(); + return; + } + }, + null, + this._disposables + ); + } + + private async _handleChat(text: string) { + // Add user message to history (webview already shows it) + this._messages.push({ role: "user", content: text }); + // DON'T call _update() - it would reset the webview and kill the JS state + + // Show loading state + this._panel.webview.postMessage({ type: "loading", loading: true }); + + try { + // Get context from active editor + const context = this._getEditorContext(); + + console.log("[Product OS Chat] Sending to API:", text); + + // Call the AI + const response = await chatWithAI(this._messages, context); + + console.log("[Product OS Chat] Response:", response); + + // Add assistant response to history + this._messages.push({ role: "assistant", content: response.message || "" }); + + // Send response to webview + this._panel.webview.postMessage({ + type: "response", + message: response.message, + toolCalls: response.toolCalls, + runs: response.runs + }); + + } catch (error: any) { + console.error("[Product OS Chat] Error:", error); + this._panel.webview.postMessage({ + type: "error", + error: error.message || "Unknown error" + }); + } finally { + this._panel.webview.postMessage({ type: "loading", loading: false }); + } + } + + private async _handleAddContext() { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active editor"); + return; + } + + const selection = editor.selection; + const selectedText = editor.document.getText(selection); + + if (selectedText) { + const filePath = vscode.workspace.asRelativePath(editor.document.uri); + const startLine = selection.start.line + 1; + + this._panel.webview.postMessage({ + type: "contextAdded", + context: { + type: "selection", + path: filePath, + startLine, + text: selectedText + } + }); + } else { + // No selection, add the whole file + const filePath = vscode.workspace.asRelativePath(editor.document.uri); + const content = editor.document.getText(); + + this._panel.webview.postMessage({ + type: "contextAdded", + context: { + type: "file", + path: filePath, + text: content.substring(0, 5000) // Limit to first 5000 chars + } + }); + } + } + + private _getEditorContext(): any { + const editor = vscode.window.activeTextEditor; + if (!editor) return undefined; + + const selection = editor.selection; + const selectedText = editor.document.getText(selection); + + if (selectedText) { + return { + selection: { + path: vscode.workspace.asRelativePath(editor.document.uri), + text: selectedText, + startLine: selection.start.line + 1 + } + }; + } + + return undefined; + } + + public dispose() { + ChatPanel.currentPanel = undefined; + + // Clean up resources + this._panel.dispose(); + + while (this._disposables.length) { + const x = this._disposables.pop(); + if (x) { + x.dispose(); + } + } + } + + private _update() { + this._panel.webview.html = this._getHtmlForWebview(); + } + + private _getHtmlForWebview() { + const nonce = getNonce(); + + // Convert messages to HTML + const messagesHtml = this._messages.map(m => { + const isUser = m.role === "user"; + const avatarClass = isUser ? "user-avatar" : "ai-avatar"; + const messageClass = isUser ? "user-message" : "ai-message"; + const avatar = isUser ? "U" : "โœฆ"; + + return ` +
+
${avatar}
+
${escapeHtml(m.content)}
+
+ `; + }).join(""); + + return ` + + + + + + Product OS Chat + + + +
+
+
โœฆ
+

Product OS Chat

+
+
+ +
+
+ +
+ ${messagesHtml || ` +
+
โœฆ
+

Welcome to Product OS

+

I can help you deploy services, analyze metrics, generate marketing content, and write codeโ€”all in one place.

+
+ + + + +
+
+ `} +
+ +
+
+
+
+
+
+ Product OS is thinking... +
+ +
+
+ ๐Ÿ“Ž Context: + + โœ• +
+
+
+ + +
+ +
+
+ + + +`; + } +} + +function getNonce() { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} diff --git a/platform/client-ide/extensions/gcp-productos/src/chatViewProvider.ts b/platform/client-ide/extensions/gcp-productos/src/chatViewProvider.ts new file mode 100644 index 0000000..40676bb --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/chatViewProvider.ts @@ -0,0 +1,688 @@ +import * as vscode from "vscode"; +import { chatWithAI, ChatMessage, ChatResponse } from "./api"; + +/** + * Sidebar Chat View Provider + * Embedded chat experience in the Product OS sidebar + */ +export class ChatViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "productos.chat"; + + private _view?: vscode.WebviewView; + private _messages: ChatMessage[] = []; + + constructor(private readonly _extensionUri: vscode.Uri) {} + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ) { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri] + }; + + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case "send": + await this._handleChat(message.text); + return; + case "addContext": + await this._handleAddContext(); + return; + case "clear": + this._messages = []; + this._updateView(); + return; + } + }); + } + + private async _handleChat(text: string) { + if (!this._view) return; + + // Add user message to internal history (webview already shows it) + this._messages.push({ role: "user", content: text }); + // DON'T call _updateView() - it would reset the webview and kill the JS state + + // Show loading + this._view.webview.postMessage({ type: "loading", loading: true }); + + try { + // Get editor context + const context = this._getEditorContext(); + + console.log("[Product OS Chat] Sending to API:", text); + + // Call AI + const response = await chatWithAI(this._messages, context); + + console.log("[Product OS Chat] Response:", response); + + // Add assistant response to history + this._messages.push({ role: "assistant", content: response.message || "" }); + + // Send to webview + this._view.webview.postMessage({ + type: "response", + message: response.message, + toolCalls: response.toolCalls, + runs: response.runs + }); + } catch (error: any) { + console.error("[Product OS Chat] Error:", error); + this._view.webview.postMessage({ + type: "error", + error: error.message || "Unknown error" + }); + } finally { + this._view.webview.postMessage({ type: "loading", loading: false }); + } + } + + private async _handleAddContext() { + if (!this._view) return; + + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active editor"); + return; + } + + const selection = editor.selection; + const selectedText = editor.document.getText(selection); + + if (selectedText) { + const filePath = vscode.workspace.asRelativePath(editor.document.uri); + this._view.webview.postMessage({ + type: "contextAdded", + context: { + type: "selection", + path: filePath, + startLine: selection.start.line + 1, + text: selectedText + } + }); + } else { + const filePath = vscode.workspace.asRelativePath(editor.document.uri); + const content = editor.document.getText(); + this._view.webview.postMessage({ + type: "contextAdded", + context: { + type: "file", + path: filePath, + text: content.substring(0, 5000) + } + }); + } + } + + private _getEditorContext(): any { + const editor = vscode.window.activeTextEditor; + if (!editor) return undefined; + + const selection = editor.selection; + const selectedText = editor.document.getText(selection); + + if (selectedText) { + return { + selection: { + path: vscode.workspace.asRelativePath(editor.document.uri), + text: selectedText, + startLine: selection.start.line + 1 + } + }; + } + return undefined; + } + + private _updateView() { + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + + private _getHtmlForWebview(webview: vscode.Webview) { + const nonce = getNonce(); + + const messagesHtml = this._messages + .map((m) => { + const isUser = m.role === "user"; + const avatarClass = isUser ? "user-avatar" : "ai-avatar"; + const messageClass = isUser ? "user-message" : "ai-message"; + const avatar = isUser ? "U" : "โœฆ"; + + return ` +
+
${avatar}
+
${escapeHtml(m.content)}
+
+ `; + }) + .join(""); + + return ` + + + + + + Product OS Chat + + + +
+ ${ + messagesHtml || + ` +
+
โœฆ
+

Product OS Chat

+

Deploy, analyze, and create with AI.

+
+ + + +
+
+ ` + } +
+ +
+
+
+
+
+
+ Thinking... +
+ +
+
+ ๐Ÿ“Ž + + โœ• +
+
+
+ + +
+ +
+
+ + + +`; + } +} + +function getNonce() { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\n/g, "
"); +} diff --git a/platform/client-ide/extensions/gcp-productos/src/extension.ts b/platform/client-ide/extensions/gcp-productos/src/extension.ts new file mode 100644 index 0000000..d8f804a --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/extension.ts @@ -0,0 +1,182 @@ +import * as vscode from "vscode"; +import { listTools, invokeTool, getRun, addRecentRun, checkConnection } from "./api"; +import { ToolsTreeProvider, ToolItem } from "./toolsTreeView"; +import { RunsTreeProvider, RunItem } from "./runsTreeView"; +import { createStatusBar, updateConnectionStatus, dispose as disposeStatusBar } from "./statusBar"; +import { InvokePanel } from "./invokePanel"; +import { ChatPanel } from "./chatPanel"; +import { ChatViewProvider } from "./chatViewProvider"; +import { showJson, openRun, showRunDocument } from "./ui"; + +export function activate(context: vscode.ExtensionContext) { + console.log("Product OS extension activated"); + + // Create tree providers + const toolsProvider = new ToolsTreeProvider(); + const runsProvider = new RunsTreeProvider(); + + // Register sidebar Chat view provider + const chatViewProvider = new ChatViewProvider(context.extensionUri); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, chatViewProvider) + ); + + // Register Chat panel command (opens in editor area) + context.subscriptions.push( + vscode.commands.registerCommand("productos.chat", () => { + ChatPanel.createOrShow(context.extensionUri); + }) + ); + + // Register tree views + vscode.window.registerTreeDataProvider("productos.tools", toolsProvider); + vscode.window.registerTreeDataProvider("productos.runs", runsProvider); + + // Create status bar + createStatusBar(context); + + // Load tools on startup + toolsProvider.loadTools(); + + // === COMMANDS === + + // Configure backend URL + context.subscriptions.push( + vscode.commands.registerCommand("productos.configure", async () => { + const currentUrl = vscode.workspace.getConfiguration("productos").get("backendUrl"); + const backendUrl = await vscode.window.showInputBox({ + prompt: "Control Plane backend URL", + value: currentUrl as string, + placeHolder: "http://localhost:8080" + }); + if (!backendUrl) return; + + await vscode.workspace.getConfiguration("productos").update( + "backendUrl", + backendUrl, + vscode.ConfigurationTarget.Global + ); + + vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`); + updateConnectionStatus(); + toolsProvider.loadTools(); + }) + ); + + // Refresh tools + context.subscriptions.push( + vscode.commands.registerCommand("productos.refresh", async () => { + await toolsProvider.loadTools(); + runsProvider.refresh(); + updateConnectionStatus(); + vscode.window.showInformationMessage("Product OS refreshed"); + }) + ); + + // List tools (JSON view) + context.subscriptions.push( + vscode.commands.registerCommand("productos.tools.list", async () => { + try { + const tools = await listTools(); + await showJson("Tools", tools); + } catch (e: any) { + vscode.window.showErrorMessage(`Failed to list tools: ${e.message}`); + } + }) + ); + + // Invoke tool (quick pick) + context.subscriptions.push( + vscode.commands.registerCommand("productos.tools.invoke", async () => { + try { + const tools = await listTools(); + if (tools.length === 0) { + vscode.window.showWarningMessage("No tools available"); + return; + } + + const pick = await vscode.window.showQuickPick( + tools.map(t => ({ + label: t.name, + description: `[${t.risk}] ${t.description}`, + tool: t + })), + { placeHolder: "Select a tool to invoke" } + ); + + if (!pick) return; + + // Open invoke panel + InvokePanel.createOrShow( + context.extensionUri, + pick.tool, + () => runsProvider.refresh() + ); + } catch (e: any) { + vscode.window.showErrorMessage(`Failed: ${e.message}`); + } + }) + ); + + // Invoke from tree view + context.subscriptions.push( + vscode.commands.registerCommand("productos.tools.invokeFromTree", async (item: ToolItem) => { + if (!item?.tool) { + // No item passed, show quick pick + vscode.commands.executeCommand("productos.tools.invoke"); + return; + } + + InvokePanel.createOrShow( + context.extensionUri, + item.tool, + () => runsProvider.refresh() + ); + }) + ); + + // Open run by ID + context.subscriptions.push( + vscode.commands.registerCommand("productos.runs.open", async () => { + const runId = await vscode.window.showInputBox({ + prompt: "Enter Run ID", + placeHolder: "run_20240101..." + }); + if (!runId) return; + + try { + await openRun(runId); + } catch (e: any) { + vscode.window.showErrorMessage(`Failed to open run: ${e.message}`); + } + }) + ); + + // Open run from tree view + context.subscriptions.push( + vscode.commands.registerCommand("productos.runs.openFromTree", async (item: RunItem) => { + if (!item?.run) return; + + try { + const fullRun = await getRun(item.run.run_id); + await showRunDocument(fullRun); + } catch (e: any) { + vscode.window.showErrorMessage(`Failed to open run: ${e.message}`); + } + }) + ); + + // Watch for config changes + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration("productos")) { + updateConnectionStatus(); + toolsProvider.loadTools(); + } + }) + ); +} + +export function deactivate() { + disposeStatusBar(); +} diff --git a/platform/client-ide/extensions/gcp-productos/src/invokePanel.ts b/platform/client-ide/extensions/gcp-productos/src/invokePanel.ts new file mode 100644 index 0000000..3e33d86 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/invokePanel.ts @@ -0,0 +1,373 @@ +import * as vscode from "vscode"; +import { Tool, invokeTool, getRun, addRecentRun } from "./api"; + +export class InvokePanel { + public static currentPanel: InvokePanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _tool: Tool; + private _disposables: vscode.Disposable[] = []; + private _onRunComplete: () => void; + + public static createOrShow( + extensionUri: vscode.Uri, + tool: Tool, + onRunComplete: () => void + ) { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + + if (InvokePanel.currentPanel) { + InvokePanel.currentPanel._tool = tool; + InvokePanel.currentPanel._onRunComplete = onRunComplete; + InvokePanel.currentPanel._update(); + InvokePanel.currentPanel._panel.reveal(column); + return; + } + + const panel = vscode.window.createWebviewPanel( + "productosInvoke", + `Invoke: ${tool.name}`, + column, + { + enableScripts: true, + retainContextWhenHidden: true + } + ); + + InvokePanel.currentPanel = new InvokePanel(panel, extensionUri, tool, onRunComplete); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + tool: Tool, + onRunComplete: () => void + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._tool = tool; + this._onRunComplete = onRunComplete; + + this._update(); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (message) => { + switch (message.command) { + case "invoke": + await this._handleInvoke(message.input, message.dryRun); + break; + case "close": + this._panel.dispose(); + break; + } + }, + null, + this._disposables + ); + } + + private async _handleInvoke(inputText: string, dryRun: boolean) { + try { + const input = JSON.parse(inputText); + + this._panel.webview.postMessage({ command: "invoking" }); + + const result = await invokeTool(this._tool.name, input, dryRun); + + // Fetch full run details + const fullRun = await getRun(result.run_id); + addRecentRun(fullRun); + this._onRunComplete(); + + this._panel.webview.postMessage({ + command: "result", + run: fullRun + }); + + } catch (e: any) { + this._panel.webview.postMessage({ + command: "error", + message: e.message + }); + } + } + + private _update() { + this._panel.title = `Invoke: ${this._tool.name}`; + this._panel.webview.html = this._getHtml(); + } + + private _getHtml(): string { + const tool = this._tool; + const schemaStr = JSON.stringify(tool.inputSchema, null, 2); + const defaultInput = this._generateDefaultInput(tool.inputSchema); + + return ` + + + + + Invoke ${tool.name} + + + +

+ ${tool.name} + ${tool.risk} risk +

+

${tool.description}

+ + + + +
+ + + +
+ +
+ +
+ Input Schema +
${schemaStr}
+
+ + + +`; + } + + private _generateDefaultInput(schema: any): string { + if (!schema || schema.type !== "object") return "{}"; + + const obj: any = {}; + const props = schema.properties || {}; + const required = schema.required || []; + + for (const key of required) { + const prop = props[key]; + if (!prop) continue; + + if (prop.type === "string") { + obj[key] = prop.enum ? prop.enum[0] : ""; + } else if (prop.type === "integer" || prop.type === "number") { + obj[key] = prop.minimum ?? 0; + } else if (prop.type === "boolean") { + obj[key] = false; + } else if (prop.type === "array") { + obj[key] = []; + } else if (prop.type === "object") { + obj[key] = {}; + } + } + + return JSON.stringify(obj, null, 2); + } + + public dispose() { + InvokePanel.currentPanel = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) d.dispose(); + } + } +} diff --git a/platform/client-ide/extensions/gcp-productos/src/runsTreeView.ts b/platform/client-ide/extensions/gcp-productos/src/runsTreeView.ts new file mode 100644 index 0000000..0fe3960 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/runsTreeView.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; +import { Run, getRecentRuns } from "./api"; + +export class RunsTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: RunItem): vscode.TreeItem { + return element; + } + + getChildren(element?: RunItem): RunItem[] { + if (element) return []; + return getRecentRuns().map(run => new RunItem(run)); + } +} + +export class RunItem extends vscode.TreeItem { + constructor(public readonly run: Run) { + super(run.tool, vscode.TreeItemCollapsibleState.None); + + const statusIcon = run.status === "succeeded" ? "โœ…" : + run.status === "failed" ? "โŒ" : + run.status === "running" ? "๐Ÿ”„" : "โณ"; + + this.description = `${statusIcon} ${run.status}`; + + const time = new Date(run.created_at).toLocaleTimeString(); + this.tooltip = new vscode.MarkdownString( + `**${run.tool}**\n\n` + + `Status: ${run.status}\n\n` + + `Run ID: \`${run.run_id}\`\n\n` + + `Time: ${time}` + ); + + this.contextValue = "run"; + + // Icon based on status + const iconId = run.status === "succeeded" ? "pass" : + run.status === "failed" ? "error" : + run.status === "running" ? "sync~spin" : "clock"; + this.iconPath = new vscode.ThemeIcon(iconId); + + this.command = { + command: "productos.runs.openFromTree", + title: "View Run", + arguments: [this] + }; + } +} diff --git a/platform/client-ide/extensions/gcp-productos/src/statusBar.ts b/platform/client-ide/extensions/gcp-productos/src/statusBar.ts new file mode 100644 index 0000000..b272a6d --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/statusBar.ts @@ -0,0 +1,41 @@ +import * as vscode from "vscode"; +import { checkConnection, getBackendUrl } from "./api"; + +let statusBarItem: vscode.StatusBarItem; +let checkInterval: NodeJS.Timeout | undefined; + +export function createStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem { + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + statusBarItem.command = "productos.configure"; + statusBarItem.text = "$(cloud) Product OS"; + statusBarItem.tooltip = "Click to configure Product OS"; + statusBarItem.show(); + + context.subscriptions.push(statusBarItem); + + // Check connection periodically + updateConnectionStatus(); + checkInterval = setInterval(updateConnectionStatus, 30000); + + return statusBarItem; +} + +export async function updateConnectionStatus(): Promise { + const connected = await checkConnection(); + + if (connected) { + statusBarItem.text = "$(cloud) Product OS"; + statusBarItem.backgroundColor = undefined; + statusBarItem.tooltip = `Connected to ${getBackendUrl()}`; + } else { + statusBarItem.text = "$(cloud-offline) Product OS"; + statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); + statusBarItem.tooltip = `Disconnected - Click to configure`; + } +} + +export function dispose(): void { + if (checkInterval) { + clearInterval(checkInterval); + } +} diff --git a/platform/client-ide/extensions/gcp-productos/src/toolsTreeView.ts b/platform/client-ide/extensions/gcp-productos/src/toolsTreeView.ts new file mode 100644 index 0000000..1421826 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/toolsTreeView.ts @@ -0,0 +1,63 @@ +import * as vscode from "vscode"; +import { Tool, listTools } from "./api"; + +export class ToolsTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private tools: Tool[] = []; + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + async loadTools(): Promise { + try { + this.tools = await listTools(); + this.refresh(); + } catch (e) { + this.tools = []; + this.refresh(); + } + } + + getTreeItem(element: ToolItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: ToolItem): Promise { + if (element) return []; + + if (this.tools.length === 0) { + await this.loadTools(); + } + + return this.tools.map(tool => new ToolItem(tool)); + } +} + +export class ToolItem extends vscode.TreeItem { + constructor(public readonly tool: Tool) { + super(tool.name, vscode.TreeItemCollapsibleState.None); + + this.description = tool.description; + this.tooltip = new vscode.MarkdownString( + `**${tool.name}**\n\n${tool.description}\n\n` + + `Risk: \`${tool.risk}\`\n\n` + + `Executor: \`${tool.executor.url}${tool.executor.path}\`` + ); + + this.contextValue = "tool"; + + // Icon based on risk level + const iconColor = tool.risk === "high" ? "red" : tool.risk === "medium" ? "orange" : "green"; + this.iconPath = new vscode.ThemeIcon("symbol-method", new vscode.ThemeColor(`charts.${iconColor}`)); + + // Click to invoke + this.command = { + command: "productos.tools.invokeFromTree", + title: "Invoke Tool", + arguments: [this] + }; + } +} diff --git a/platform/client-ide/extensions/gcp-productos/src/ui.ts b/platform/client-ide/extensions/gcp-productos/src/ui.ts new file mode 100644 index 0000000..4e92fb2 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/ui.ts @@ -0,0 +1,40 @@ +import * as vscode from "vscode"; +import { getRun, Run } from "./api"; + +export async function showJson(title: string, obj: any) { + const doc = await vscode.workspace.openTextDocument({ + content: JSON.stringify(obj, null, 2), + language: "json" + }); + await vscode.window.showTextDocument(doc, { preview: false }); + vscode.window.setStatusBarMessage(title, 3000); +} + +export async function openRun(runId: string) { + const run = await getRun(runId); + await showRunDocument(run); +} + +export async function showRunDocument(run: Run) { + const statusEmoji = run.status === "succeeded" ? "โœ…" : + run.status === "failed" ? "โŒ" : + run.status === "running" ? "๐Ÿ”„" : "โณ"; + + const content = `// Run: ${run.run_id} +// Tool: ${run.tool} +// Status: ${statusEmoji} ${run.status} +// Created: ${new Date(run.created_at).toLocaleString()} + +// === INPUT === +${JSON.stringify(run.input, null, 2)} + +// === OUTPUT === +${JSON.stringify(run.output ?? run.error ?? null, null, 2)} +`; + + const doc = await vscode.workspace.openTextDocument({ + content, + language: "jsonc" + }); + await vscode.window.showTextDocument(doc, { preview: false }); +} diff --git a/platform/client-ide/extensions/gcp-productos/tsconfig.json b/platform/client-ide/extensions/gcp-productos/tsconfig.json new file mode 100644 index 0000000..275b271 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/platform/contracts/tool-registry.yaml b/platform/contracts/tool-registry.yaml new file mode 100644 index 0000000..80fbff6 --- /dev/null +++ b/platform/contracts/tool-registry.yaml @@ -0,0 +1,398 @@ +version: 1 + +tools: + + # ---------------------------- + # CODE / DEPLOYMENT + # ---------------------------- + + cloudrun.deploy_service: + description: Build and deploy a Cloud Run service using Cloud Build. Returns the service URL and deployed revision. + risk: medium + executor: + kind: http + url: https://deploy-executor-REPLACE.a.run.app + path: /execute/cloudrun/deploy + inputSchema: + type: object + additionalProperties: false + required: [service_name, repo, ref, env, region] + properties: + service_name: + type: string + minLength: 1 + description: Cloud Run service name. + repo: + type: string + minLength: 1 + description: Git repo URL (HTTPS). + ref: + type: string + minLength: 1 + description: Git ref (branch/tag/SHA). + env: + type: string + enum: [dev, staging, prod] + region: + type: string + minLength: 1 + description: GCP region for the Cloud Run service (e.g., us-central1). + outputSchema: + type: object + properties: + service_url: + type: string + revision: + type: string + + cloudrun.get_service_status: + description: Fetch Cloud Run service status including latest revision and URL. + risk: low + executor: + kind: http + url: https://deploy-executor-REPLACE.a.run.app + path: /execute/cloudrun/status + inputSchema: + type: object + additionalProperties: false + required: [service_name, region] + properties: + service_name: + type: string + minLength: 1 + region: + type: string + minLength: 1 + outputSchema: + type: object + properties: + service_name: + type: string + region: + type: string + service_url: + type: string + latest_ready_revision: + type: string + status: + type: string + enum: [ready, deploying, error, unknown] + + logs.tail: + description: Tail recent logs for a Cloud Run service or for a specific run_id. + risk: low + executor: + kind: http + url: https://observability-executor-REPLACE.a.run.app + path: /execute/logs/tail + inputSchema: + type: object + additionalProperties: false + required: [scope, limit] + properties: + scope: + type: string + enum: [service, run] + service_name: + type: string + region: + type: string + run_id: + type: string + limit: + type: integer + minimum: 1 + maximum: 2000 + default: 200 + outputSchema: + type: object + properties: + lines: + type: array + items: + type: object + properties: + timestamp: + type: string + severity: + type: string + text: + type: string + + # ---------------------------- + # COMPANY BRAIN (BRAND + STYLE) + # ---------------------------- + + brand.get_profile: + description: Retrieve the tenant's brand profile (voice, tone, positioning, compliance constraints). + risk: low + executor: + kind: http + url: https://firestore-executor-REPLACE.a.run.app + path: /execute/brand/get_profile + inputSchema: + type: object + additionalProperties: false + required: [profile_id] + properties: + profile_id: + type: string + minLength: 1 + description: Brand profile identifier (e.g., "default"). + outputSchema: + type: object + properties: + profile_id: + type: string + brand: + type: object + + brand.update_profile: + description: Update the tenant's brand profile. Write operations should be validated and audited. + risk: medium + executor: + kind: http + url: https://firestore-executor-REPLACE.a.run.app + path: /execute/brand/update_profile + inputSchema: + type: object + additionalProperties: false + required: [profile_id, patch] + properties: + profile_id: + type: string + minLength: 1 + patch: + type: object + description: Partial update object; executor must validate allowed fields. + outputSchema: + type: object + properties: + ok: + type: boolean + updated_at: + type: string + + # ---------------------------- + # ANALYTICS / CAUSATION + # ---------------------------- + + analytics.funnel_summary: + description: Return funnel metrics for a time window. Uses curated events in BigQuery. + risk: low + executor: + kind: http + url: https://analytics-executor-REPLACE.a.run.app + path: /execute/analytics/funnel_summary + inputSchema: + type: object + additionalProperties: false + required: [range_days, funnel] + properties: + range_days: + type: integer + minimum: 1 + maximum: 365 + funnel: + type: object + required: [name, steps] + properties: + name: + type: string + steps: + type: array + minItems: 2 + items: + type: object + required: [event_name] + properties: + event_name: + type: string + outputSchema: + type: object + properties: + funnel_name: + type: string + range_days: + type: integer + steps: + type: array + + analytics.top_drivers: + description: Identify top correlated drivers for a target metric/event. + risk: low + executor: + kind: http + url: https://analytics-executor-REPLACE.a.run.app + path: /execute/analytics/top_drivers + inputSchema: + type: object + additionalProperties: false + required: [range_days, target] + properties: + range_days: + type: integer + minimum: 1 + maximum: 365 + target: + type: object + required: [metric] + properties: + metric: + type: string + event_name: + type: string + outputSchema: + type: object + properties: + target: + type: object + range_days: + type: integer + drivers: + type: array + + analytics.write_insight: + description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact). + risk: medium + executor: + kind: http + url: https://analytics-executor-REPLACE.a.run.app + path: /execute/analytics/write_insight + inputSchema: + type: object + additionalProperties: false + required: [insight] + properties: + insight: + type: object + required: [type, title, summary, severity, confidence, window, recommendations] + properties: + type: + type: string + enum: [funnel_drop, anomaly, driver, experiment_result, general] + title: + type: string + summary: + type: string + severity: + type: string + enum: [info, low, medium, high, critical] + confidence: + type: number + minimum: 0 + maximum: 1 + window: + type: object + recommendations: + type: array + outputSchema: + type: object + properties: + insight_id: + type: string + stored: + type: object + + # ---------------------------- + # MARKETING (GENERATION + PUBLISH) + # ---------------------------- + + marketing.generate_channel_posts: + description: Generate platform-specific social posts from a campaign brief + brand profile. + risk: low + executor: + kind: http + url: https://marketing-executor-REPLACE.a.run.app + path: /execute/marketing/generate_channel_posts + inputSchema: + type: object + additionalProperties: false + required: [brief, channels, brand_profile_id] + properties: + brand_profile_id: + type: string + brief: + type: object + required: [goal, product, audience, key_points] + properties: + goal: + type: string + product: + type: string + audience: + type: string + key_points: + type: array + items: + type: string + channels: + type: array + items: + type: string + enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit] + variations_per_channel: + type: integer + minimum: 1 + maximum: 10 + default: 3 + outputSchema: + type: object + properties: + channels: + type: array + + marketing.publish_missinglettr: + description: Publish or schedule a campaign via Missinglettr. + risk: medium + executor: + kind: http + url: https://marketing-executor-REPLACE.a.run.app + path: /execute/marketing/publish_missinglettr + inputSchema: + type: object + additionalProperties: false + required: [campaign, schedule] + properties: + campaign: + type: object + required: [name, posts] + properties: + name: + type: string + posts: + type: array + items: + type: object + required: [channel, text] + properties: + channel: + type: string + text: + type: string + media_urls: + type: array + items: + type: string + schedule: + type: object + required: [mode] + properties: + mode: + type: string + enum: [now, scheduled] + start_time: + type: string + timezone: + type: string + default: UTC + outputSchema: + type: object + properties: + provider: + type: string + campaign_id: + type: string + status: + type: string + enum: [queued, scheduled, published, failed] diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml new file mode 100644 index 0000000..e77e4db --- /dev/null +++ b/platform/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + # Firestore Emulator + firestore: + image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators + command: gcloud emulators firestore start --host-port=0.0.0.0:8081 + ports: + - "8081:8081" + + # GCS Emulator (fake-gcs-server) + gcs: + image: fsouza/fake-gcs-server + command: -scheme http -port 4443 + ports: + - "4443:4443" + volumes: + - gcs-data:/data + + # Control Plane API + control-plane: + build: + context: ./backend/control-plane + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - PORT=8080 + - GCP_PROJECT_ID=productos-local + - GCS_BUCKET_ARTIFACTS=productos-artifacts-local + - FIRESTORE_COLLECTION_RUNS=runs + - FIRESTORE_COLLECTION_TOOLS=tools + - AUTH_MODE=dev + - FIRESTORE_EMULATOR_HOST=firestore:8081 + - STORAGE_EMULATOR_HOST=http://gcs:4443 + depends_on: + - firestore + - gcs + +volumes: + gcs-data: diff --git a/platform/docs/GETTING_STARTED.md b/platform/docs/GETTING_STARTED.md new file mode 100644 index 0000000..9240192 --- /dev/null +++ b/platform/docs/GETTING_STARTED.md @@ -0,0 +1,143 @@ +# Product OS - Getting Started + +## Project Structure + +``` +platform/ +โ”œโ”€โ”€ backend/ +โ”‚ โ””โ”€โ”€ control-plane/ # Fastify API server +โ”œโ”€โ”€ client-ide/ +โ”‚ โ””โ”€โ”€ extensions/ +โ”‚ โ””โ”€โ”€ gcp-productos/ # VSCodium/VS Code extension +โ”œโ”€โ”€ contracts/ # Tool registry schemas +โ”œโ”€โ”€ infra/ +โ”‚ โ””โ”€โ”€ terraform/ # GCP infrastructure +โ”œโ”€โ”€ docs/ # Documentation +โ””โ”€โ”€ docker-compose.yml # Local development +``` + +## Quick Start (Local Development) + +### Prerequisites + +- Node.js 22+ +- Docker & Docker Compose +- (Optional) VS Code or VSCodium for extension development + +### 1. Start Local Services + +```bash +cd platform +docker-compose up -d +``` + +This starts: +- Firestore emulator on port 8081 +- GCS emulator on port 4443 +- Control Plane API on port 8080 + +### 2. Run Control Plane in Dev Mode + +For faster iteration without Docker: + +```bash +cd platform/backend/control-plane +cp env.example .env +npm install +npm run dev +``` + +### 3. Test the API + +```bash +# Health check +curl http://localhost:8080/healthz + +# List tools (empty initially) +curl http://localhost:8080/tools + +# Invoke a tool (dry run) +curl -X POST http://localhost:8080/tools/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "cloudrun.deploy_service", + "tenant_id": "t_dev", + "input": {"service_name": "test"}, + "dry_run": true + }' +``` + +### 4. Build & Install the Extension + +```bash +cd platform/client-ide/extensions/gcp-productos +npm install +npm run build +``` + +Then in VS Code / VSCodium: +1. Open Command Palette (Cmd+Shift+P) +2. Run "Developer: Install Extension from Location..." +3. Select the `gcp-productos` folder + +Or use the VSIX package: +```bash +npx vsce package +code --install-extension gcp-productos-0.0.1.vsix +``` + +## Extension Usage + +Once installed, use the Command Palette: + +- **Product OS: Configure Backend** - Set the Control Plane URL +- **Product OS: List Tools** - View available tools +- **Product OS: Invoke Tool** - Execute a tool +- **Product OS: Open Run** - View run details + +## Deploying to GCP + +### 1. Configure Terraform + +```bash +cd platform/infra/terraform +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars with your project details +``` + +### 2. Build & Push Container + +```bash +cd platform/backend/control-plane + +# Build +docker build -t us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest . + +# Push (requires gcloud auth) +docker push us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest +``` + +### 3. Apply Terraform + +```bash +cd platform/infra/terraform +terraform init +terraform plan +terraform apply +``` + +## Seeding Tools + +To add tools to the registry, you can: + +1. Use the Firestore console to add documents to the `tools` collection +2. Create a seed script that loads `contracts/tool-registry.yaml` +3. Build an admin endpoint (coming in v2) + +## Next Steps + +- [ ] Build Deploy Executor +- [ ] Build Analytics Executor +- [ ] Add Gemini integration +- [ ] Add OAuth/IAP authentication +- [ ] Create Product-Centric UI panels diff --git a/platform/infra/terraform/iam.tf b/platform/infra/terraform/iam.tf new file mode 100644 index 0000000..7b01f13 --- /dev/null +++ b/platform/infra/terraform/iam.tf @@ -0,0 +1,16 @@ +# Allow control-plane to write artifacts in GCS +resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" { + bucket = google_storage_bucket.artifacts.name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.control_plane_sa.email}" +} + +# Firestore access for run/tool metadata +resource "google_project_iam_member" "control_plane_firestore" { + project = var.project_id + role = "roles/datastore.user" + member = "serviceAccount:${google_service_account.control_plane_sa.email}" +} + +# Placeholder: executor services will each have their own service accounts. +# Control-plane should be granted roles/run.invoker on each executor service once created. diff --git a/platform/infra/terraform/main.tf b/platform/infra/terraform/main.tf new file mode 100644 index 0000000..e875e74 --- /dev/null +++ b/platform/infra/terraform/main.tf @@ -0,0 +1,54 @@ +# GCS Bucket for artifacts (logs, AI outputs, patches) +resource "google_storage_bucket" "artifacts" { + name = var.artifact_bucket_name + location = var.region + uniform_bucket_level_access = true + versioning { enabled = true } +} + +# Firestore (Native mode) โ€“ requires enabling in console once per project +resource "google_firestore_database" "default" { + name = "(default)" + location_id = var.region + type = "FIRESTORE_NATIVE" +} + +# Service account for Control Plane +resource "google_service_account" "control_plane_sa" { + account_id = "sa-control-plane" + display_name = "Product OS Control Plane" +} + +# Cloud Run service for Control Plane API +resource "google_cloud_run_v2_service" "control_plane" { + name = "control-plane" + location = var.region + + template { + service_account = google_service_account.control_plane_sa.email + + containers { + image = var.control_plane_image + env { + name = "GCP_PROJECT_ID" + value = var.project_id + } + env { + name = "GCS_BUCKET_ARTIFACTS" + value = google_storage_bucket.artifacts.name + } + env { + name = "AUTH_MODE" + value = "dev" + } + } + } +} + +# Public access for dev; prefer IAM auth in production +resource "google_cloud_run_v2_service_iam_member" "control_plane_public" { + name = google_cloud_run_v2_service.control_plane.name + location = var.region + role = "roles/run.invoker" + member = "allUsers" +} diff --git a/platform/infra/terraform/outputs.tf b/platform/infra/terraform/outputs.tf new file mode 100644 index 0000000..724ede2 --- /dev/null +++ b/platform/infra/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "control_plane_url" { + value = google_cloud_run_v2_service.control_plane.uri + description = "URL of the Control Plane API" +} + +output "artifact_bucket" { + value = google_storage_bucket.artifacts.name + description = "GCS bucket for artifacts" +} diff --git a/platform/infra/terraform/providers.tf b/platform/infra/terraform/providers.tf new file mode 100644 index 0000000..ace2f1d --- /dev/null +++ b/platform/infra/terraform/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.30" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} diff --git a/platform/infra/terraform/terraform.tfvars.example b/platform/infra/terraform/terraform.tfvars.example new file mode 100644 index 0000000..2d7b525 --- /dev/null +++ b/platform/infra/terraform/terraform.tfvars.example @@ -0,0 +1,4 @@ +project_id = "your-gcp-project-id" +region = "us-central1" +artifact_bucket_name = "productos-artifacts-dev" +control_plane_image = "us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest" diff --git a/platform/infra/terraform/variables.tf b/platform/infra/terraform/variables.tf new file mode 100644 index 0000000..14ddaf6 --- /dev/null +++ b/platform/infra/terraform/variables.tf @@ -0,0 +1,20 @@ +variable "project_id" { + type = string + description = "GCP Project ID" +} + +variable "region" { + type = string + default = "us-central1" + description = "GCP region for resources" +} + +variable "artifact_bucket_name" { + type = string + description = "Name for the GCS bucket storing artifacts" +} + +variable "control_plane_image" { + type = string + description = "Container image URI for control-plane (Artifact Registry)." +} diff --git a/platform/scripts/start-all.sh b/platform/scripts/start-all.sh new file mode 100644 index 0000000..f5db86e --- /dev/null +++ b/platform/scripts/start-all.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Start all Product OS services for local development + +echo "๐Ÿš€ Starting Product OS services..." + +cd "$(dirname "$0")/.." + +# Start Control Plane +echo "Starting Control Plane (port 8080)..." +cd backend/control-plane +npm run dev & +CONTROL_PLANE_PID=$! +cd ../.. + +sleep 2 + +# Start Deploy Executor +echo "Starting Deploy Executor (port 8090)..." +cd backend/executors/deploy +npm run dev & +DEPLOY_PID=$! +cd ../../.. + +# Start Analytics Executor +echo "Starting Analytics Executor (port 8091)..." +cd backend/executors/analytics +npm run dev & +ANALYTICS_PID=$! +cd ../../.. + +# Start Marketing Executor +echo "Starting Marketing Executor (port 8093)..." +cd backend/executors/marketing +npm run dev & +MARKETING_PID=$! +cd ../../.. + +echo "" +echo "โœ… All services started!" +echo "" +echo "Services:" +echo " - Control Plane: http://localhost:8080" +echo " - Deploy Executor: http://localhost:8090" +echo " - Analytics Executor: http://localhost:8091" +echo " - Marketing Executor: http://localhost:8093" +echo "" +echo "Press Ctrl+C to stop all services" + +# Wait for any process to exit +wait + +# Cleanup +kill $CONTROL_PLANE_PID $DEPLOY_PID $ANALYTICS_PID $MARKETING_PID 2>/dev/null diff --git a/technical_spec.md b/technical_spec.md new file mode 100644 index 0000000..e69de29 diff --git a/vision-ext.md b/vision-ext.md new file mode 100644 index 0000000..e69de29