diff --git a/.continue/config.yaml b/.continue/config.yaml new file mode 100644 index 00000000..a2dad543 --- /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/1.Generate Control Plane API scaffold.md b/1.Generate Control Plane API scaffold.md new file mode 100644 index 00000000..d5c2dd66 --- /dev/null +++ b/1.Generate Control Plane API scaffold.md @@ -0,0 +1,743 @@ +1) Generate Control Plane API scaffold +Folder layout +backend/control-plane/ + package.json + tsconfig.json + src/ + index.ts + config.ts + auth.ts + registry.ts + types.ts + storage/ + firestore.ts + gcs.ts + routes/ + tools.ts + runs.ts + health.ts + .env.example + +backend/control-plane/package.json +{ + "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": "^9.0.1", + "@fastify/helmet": "^12.0.0", + "@fastify/rate-limit": "^9.1.0", + "fastify": "^4.28.1", + "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" + } +} + +backend/control-plane/tsconfig.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} + +backend/control-plane/.env.example +PORT=8080 +GCP_PROJECT_ID=your-project-id +GCS_BUCKET_ARTIFACTS=productos-artifacts-dev +FIRESTORE_COLLECTION_RUNS=runs +FIRESTORE_COLLECTION_TOOLS=tools +# If you put behind IAP / OAuth later, validate ID tokens here: +AUTH_MODE=dev # dev | oauth + +backend/control-plane/src/config.ts +export const config = { + port: Number(process.env.PORT ?? 8080), + projectId: process.env.GCP_PROJECT_ID ?? "", + artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", + runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs", + toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools", + authMode: process.env.AUTH_MODE ?? "dev" +}; + +backend/control-plane/src/types.ts +export type ToolRisk = "low" | "medium" | "high"; + +export type ToolDef = { + name: string; + description: string; + risk: ToolRisk; + executor: { + kind: "http"; + url: string; // executor base url + path: string; // executor endpoint path + }; + inputSchema: unknown; // JSON Schema object + outputSchema?: unknown; // JSON Schema object +}; + +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 }; +}; + +backend/control-plane/src/auth.ts +import { FastifyRequest } from "fastify"; +import { config } from "./config.js"; + +/** + * V1: dev mode = trust caller (or a shared API key later). + * V2: validate Google OAuth/IAP identity token and map to tenant/org. + */ +export async function requireAuth(req: FastifyRequest) { + if (config.authMode === "dev") return; + + // Placeholder for OAuth/IAP verification: + // - read Authorization: Bearer + // - verify token (Google JWKS) + // - attach req.user + throw new Error("AUTH_MODE oauth not yet implemented"); +} + +backend/control-plane/src/storage/firestore.ts +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); +} + +backend/control-plane/src/storage/gcs.ts +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}` }; +} + +backend/control-plane/src/registry.ts +import type { ToolDef } from "./types.js"; +import { listTools } from "./storage/firestore.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])); +} + +backend/control-plane/src/routes/health.ts +import type { FastifyInstance } from "fastify"; + +export async function healthRoutes(app: FastifyInstance) { + app.get("/healthz", async () => ({ ok: true })); +} + +backend/control-plane/src/routes/tools.ts +import type { FastifyInstance } from "fastify"; +import { nanoid } from "nanoid"; +import { requireAuth } from "../auth.js"; +import { getRegistry } from "../registry.js"; +import { saveRun } from "../storage/firestore.js"; +import { writeArtifactText } from "../storage/gcs.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); + + // record input artifact + await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2)); + + // execute (sync for v1; v2: push to Cloud Tasks / Workflows) + 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 }; + } + }); +} + +backend/control-plane/src/routes/runs.ts +import type { FastifyInstance } from "fastify"; +import { requireAuth } from "../auth.js"; +import { getRun } from "../storage/firestore.js"; + +export async function runRoutes(app: FastifyInstance) { + app.get("/runs/:run_id", async (req) => { + await requireAuth(req); + // @ts-expect-error fastify param typing + const runId = req.params.run_id as string; + const run = await getRun(runId); + if (!run) return app.httpErrors.notFound("Run not found"); + return run; + }); + + // V1: logs are stored as artifacts in GCS; IDE can fetch by signed URL later + app.get("/runs/:run_id/logs", async (req) => { + await requireAuth(req); + // stub + return { note: "V1: logs are in GCS artifacts under runs//" }; + }); +} + +backend/control-plane/src/index.ts +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; +import rateLimit from "@fastify/rate-limit"; +import { config } from "./config.js"; +import { healthRoutes } from "./routes/health.js"; +import { toolRoutes } from "./routes/tools.js"; +import { runRoutes } from "./routes/runs.js"; + +const app = Fastify({ logger: true }); + +await app.register(cors, { origin: true }); +await app.register(helmet); +await app.register(rateLimit, { max: 300, timeWindow: "1 minute" }); + +await app.register(healthRoutes); +await app.register(toolRoutes); +await app.register(runRoutes); + +app.listen({ port: config.port, host: "0.0.0.0" }).catch((err) => { + app.log.error(err); + process.exit(1); +}); + +2) Generate Tool Registry schema + +You want two things: + +A human-editable YAML (source of truth) + +A JSON Schema to validate tool definitions + +contracts/tool-registry.yaml (example) +version: 1 +tools: + cloudrun.deploy_service: + description: Deploy a Cloud Run service via Cloud Build. + risk: medium + executor: + kind: http + url: https://deploy-executor-xxxxx.a.run.app + path: /execute/deploy + inputSchema: + type: object + required: [service_name, repo, ref, env] + properties: + service_name: { type: string } + repo: { type: string, description: "Git repo URL" } + ref: { type: string, description: "Branch, tag, or commit SHA" } + env: { type: string, enum: ["dev", "staging", "prod"] } + outputSchema: + type: object + properties: + service_url: { type: string } + revision: { type: string } + + analytics.get_funnel_summary: + description: Return funnel metrics for a tenant and time window. + risk: low + executor: + kind: http + url: https://analytics-executor-xxxxx.a.run.app + path: /execute/funnel + inputSchema: + type: object + required: [range_days] + properties: + range_days: { type: integer, minimum: 1, maximum: 365 } + segment: { type: object } + +contracts/tool-registry.schema.json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://productos.dev/schemas/tool-registry.schema.json", + "type": "object", + "required": ["version", "tools"], + "properties": { + "version": { "type": "integer", "minimum": 1 }, + "tools": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/ToolDef" } + } + }, + "$defs": { + "ToolDef": { + "type": "object", + "required": ["description", "risk", "executor", "inputSchema"], + "properties": { + "description": { "type": "string" }, + "risk": { "type": "string", "enum": ["low", "medium", "high"] }, + "executor": { "$ref": "#/$defs/Executor" }, + "inputSchema": { "type": "object" }, + "outputSchema": { "type": "object" } + }, + "additionalProperties": false + }, + "Executor": { + "type": "object", + "required": ["kind", "url", "path"], + "properties": { + "kind": { "type": "string", "enum": ["http"] }, + "url": { "type": "string" }, + "path": { "type": "string" } + }, + "additionalProperties": false + } + } +} + +3) Generate VSCodium extension skeleton +Folder layout +client-ide/extensions/gcp-productos/ + package.json + tsconfig.json + src/extension.ts + src/api.ts + src/ui.ts + media/icon.png (optional) + +client-ide/extensions/gcp-productos/package.json +{ + "name": "gcp-productos", + "displayName": "GCP Product OS", + "description": "Product-centric panels (Code, Marketing, Analytics, Growth...) and backend tool invocation.", + "version": "0.0.1", + "publisher": "productos", + "engines": { "vscode": "^1.90.0" }, + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { "command": "productos.configure", "title": "Product OS: Configure Backend" }, + { "command": "productos.tools.list", "title": "Product OS: List Tools" }, + { "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool" }, + { "command": "productos.runs.open", "title": "Product OS: Open Run" } + ], + "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" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "^1.90.0", + "typescript": "^5.5.4" + } +} + +client-ide/extensions/gcp-productos/tsconfig.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} + +client-ide/extensions/gcp-productos/src/api.ts +import * as vscode from "vscode"; + +function cfg(key: string): T { + return vscode.workspace.getConfiguration("productos").get(key)!; +} + +export function backendUrl() { + return cfg("backendUrl"); +} + +export function tenantId() { + return cfg("tenantId"); +} + +export async function listTools(): Promise { + const res = await fetch(`${backendUrl()}/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) { + const res = await fetch(`${backendUrl()}/tools/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tool, + tenant_id: tenantId(), + input + }) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function getRun(runId: string) { + const res = await fetch(`${backendUrl()}/runs/${runId}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +client-ide/extensions/gcp-productos/src/ui.ts +import * as vscode from "vscode"; +import { getRun } 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 showJson(`Run ${runId}`, run); +} + +client-ide/extensions/gcp-productos/src/extension.ts +import * as vscode from "vscode"; +import { invokeTool, listTools } from "./api"; +import { openRun, showJson } from "./ui"; + +export function activate(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand("productos.configure", async () => { + const backendUrl = await vscode.window.showInputBox({ prompt: "Control Plane backend URL" }); + if (!backendUrl) return; + await vscode.workspace.getConfiguration("productos").update("backendUrl", backendUrl, vscode.ConfigurationTarget.Global); + vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("productos.tools.list", async () => { + const tools = await listTools(); + await showJson("Tools", tools); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("productos.tools.invoke", async () => { + const tools = await listTools(); + const pick = await vscode.window.showQuickPick( + tools.map((t: any) => ({ label: t.name, description: t.description })), + { placeHolder: "Select a tool to invoke" } + ); + if (!pick) return; + + const inputText = await vscode.window.showInputBox({ + prompt: "Tool input JSON", + value: "{}" + }); + if (!inputText) return; + + const input = JSON.parse(inputText); + const result = await invokeTool(pick.label, input); + + await showJson("Invoke Result", result); + + if (result?.run_id) { + const open = await vscode.window.showInformationMessage(`Run started: ${result.run_id}`, "Open Run"); + if (open === "Open Run") await openRun(result.run_id); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("productos.runs.open", async () => { + const runId = await vscode.window.showInputBox({ prompt: "Run ID" }); + if (!runId) return; + await openRun(runId); + }) + ); +} + +export function deactivate() {} + +4) Generate Terraform base + +This is a minimal hub-style baseline: + +GCS bucket for artifacts + +Firestore (Native) for runs/tools + +Cloud Run service for Control Plane + +Service accounts + IAM + +Placeholders for executor services + +Folder layout +infra/terraform/ + providers.tf + variables.tf + outputs.tf + main.tf + iam.tf + +infra/terraform/providers.tf +terraform { + required_version = ">= 1.5.0" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.30" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +infra/terraform/variables.tf +variable "project_id" { type = string } +variable "region" { type = string default = "us-central1" } +variable "artifact_bucket_name" { type = string } +variable "control_plane_image" { + type = string + description = "Container image URI for control-plane (Artifact Registry)." +} + +infra/terraform/main.tf +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 (or via API depending on org policy). +resource "google_firestore_database" "default" { + name = "(default)" + location_id = var.region + type = "FIRESTORE_NATIVE" +} + +resource "google_service_account" "control_plane_sa" { + account_id = "sa-control-plane" + display_name = "Product OS Control Plane" +} + +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 optional; 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" +} + +infra/terraform/iam.tf +# 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. + +infra/terraform/outputs.tf +output "control_plane_url" { + value = google_cloud_run_v2_service.control_plane.uri +} + +output "artifact_bucket" { + value = google_storage_bucket.artifacts.name +} \ No newline at end of file diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..c755817b --- /dev/null +++ b/architecture.md @@ -0,0 +1,949 @@ +1) Recommended reference architecture (Web SaaS-first, 1 product = 1 GCP project per env) +Project model + +One product = one GCP project per environment + +product-foo-dev + +product-foo-staging + +product-foo-prod + +Optional “platform” projects (yours, not the customer’s): + +productos-control-plane (your backend + tool registry + auth) + +productos-observability (optional central dashboards / cross-product rollups) + +productos-billing-export (optional BigQuery billing export aggregation) + +High-level runtime pattern + +IDE + Supervisor AI never touch DBs/services directly. +They call your Control Plane API, which routes to domain Executors (Cloud Run services) with least-privilege service accounts. + +VSCodium IDE (Product OS UI) Supervisor AI (Vertex) + \ / + \ / + -----> Control Plane API ---- + | + ------------------------------------------------- + | | | | | + Deploy Exec Analytics Exec Firestore SQL Exec Marketing Exec + (Cloud Build (BigQuery jobs) Exec Exec (Missinglettr, + + Cloud Run) (Company (Cloud email provider) + Brain) SQL) + +Per-product (customer) project: “product-foo-prod” + +Must-have services + +Cloud Run: product services + executors (if you deploy executors into product project) + +Cloud SQL (Postgres/MySQL): transactional app data + +Firestore: config + “Company Brain” + style profiles + run metadata (if you keep metadata per product) + +BigQuery: event warehouse + analytics datasets/views + experimentation tables + +Pub/Sub: event bus for product events + tool events + +Cloud Tasks / Workflows / Scheduler: durable automation + cron-based routines + +Secret Manager: tokens, DB creds, OAuth secrets (never in code) + +Logging/Monitoring/Trace: observability + +Where to place executors + +Simplest: executors live in the product project (tight coupling, simple data access) + +More “platform”: executors live in your platform project, and access product resources cross-project (strong central control, but more IAM + org policy considerations) + +For your “product per project” approach, I recommend: + +Deploy executor can live in platform (deploy across projects) + +Data executors (SQL/Firestore/BigQuery) often live in product project (least-cross-project permissions) + +Data flows + +Events: Product apps → Pub/Sub → BigQuery (raw + curated) + +Causation/insights: Analytics Exec reads BigQuery → writes Insight Objects to: + +BigQuery tables (truth) + +GCS artifacts (reports) + +Firestore (summary pointers for UI) + +Marketing: Marketing Exec pulls Insight Objects + Company Brain → generates campaigns → publishes via Missinglettr/social APIs; stores outputs in GCS + metadata in Firestore + +2) Service-by-service IAM roles matrix (least privilege template) +Identities (service accounts) + +You’ll typically have: + +sa-control-plane (platform): routes tool calls, enforces policy, writes run metadata/artifacts + +sa-deploy-executor (platform): triggers builds and deploys to Cloud Run in product projects + +sa-analytics-executor (product): reads BigQuery + writes insights + +sa-firestore-executor (product): reads/writes Company Brain + configs + +sa-sql-executor (product): connects to Cloud SQL (plus DB user for SQL-level permissions) + +sa-marketing-executor (platform or product): reads insights + calls Missinglettr/email providers; reads secrets + +Where I say “product project”, apply it to each env project (dev/staging/prod). + +IAM matrix (by service) +Service / Scope Principal Roles (suggested) Notes +Cloud Run (product) sa-deploy-executor roles/run.admin (or narrower), roles/iam.serviceAccountUser (only on the runtime SA), roles/run.invoker (optional) Deploy revisions. Narrow iam.serviceAccountUser to only the runtime SA used by the service being deployed. +Cloud Build (platform or product) sa-deploy-executor roles/cloudbuild.builds.editor (or builds.builder depending on workflow) Triggers builds. Many teams keep builds centralized in platform. +Artifact Registry sa-deploy-executor roles/artifactregistry.writer Push images. If per-product registries, scope accordingly. +Secret Manager (platform/product) sa-marketing-executor, sa-deploy-executor roles/secretmanager.secretAccessor Only for the specific secrets needed. +BigQuery dataset (product) sa-analytics-executor roles/bigquery.dataViewer + roles/bigquery.jobUser Dataset-level grants. Prefer views/curated datasets. +BigQuery dataset (product write) sa-analytics-executor roles/bigquery.dataEditor (only for insight tables dataset) Separate datasets: events_raw (read), events_curated (read), insights (write). +Firestore (product) sa-firestore-executor roles/datastore.user (or roles/datastore.viewer) Use viewer when possible; writer only for Brain/config updates. +Cloud SQL (product) sa-sql-executor roles/cloudsql.client IAM to connect; SQL permissions handled by DB user(s). +Pub/Sub (product) Producers roles/pubsub.publisher For product services emitting events. +Pub/Sub (product) Consumers/executors roles/pubsub.subscriber For analytics/executor ingestion. +Cloud Tasks (product/platform) sa-control-plane or orchestrator roles/cloudtasks.enqueuer + roles/cloudtasks.viewer If you queue tool runs or retries. +Workflows (product/platform) sa-control-plane roles/workflows.invoker For orchestrated multi-step automations. +Cloud Storage (GCS artifacts) sa-control-plane roles/storage.objectAdmin (bucket-level) Write run artifacts; consider objectCreator + separate delete policy if you want immutability. +Cloud Run executors (wherever hosted) sa-control-plane roles/run.invoker Control Plane calls executors over HTTP. +Strongly recommended scoping rules + +Grant BigQuery roles at the dataset level, not project level. + +Use separate datasets for raw, curated, and insights. + +For Cloud SQL, enforce read-only DB users for most endpoints; create a separate writer user only when needed. + +Keep a “high risk” policy that requires approval for: + +pricing changes + +billing actions + +production destructive infra + +legal/claim-heavy marketing copy + +3) Agent tool catalog (seed tool registry mapped to GCP services) + +This is a starter “tool universe” your Supervisor AI + IDE can call. I’ve grouped by module and listed the backing GCP service. + +A) Code module (build/test/deploy) +Tool name Purpose Executes in Backed by +repo.apply_patch Apply diff to repo (local or PR flow) Control Plane / Repo service (GitHub App or local workspace) +repo.open_pr Open PR with changes Control Plane GitHub App +build.run_tests Run unit tests Executor (local/offline or remote) Cloud Build / local runner +cloudrun.deploy_service Build + deploy service Deploy Exec Cloud Build + Cloud Run +cloudrun.rollback_service Roll back revision Deploy Exec Cloud Run +cloudrun.get_service_status Health, revisions, URL Deploy Exec Cloud Run +logs.tail Tail logs for service/run Observability Exec Cloud Logging +B) Marketing module (campaign creation + publishing) +Tool name Purpose Executes in Backed by +brand.get_profile Fetch voice/style/claims Firestore Exec Firestore +brand.update_profile Update voice/style rules Firestore Exec Firestore +marketing.generate_campaign_plan Create campaign plan from insight/product update Marketing Exec Vertex AI (Gemini) +marketing.generate_channel_posts Generate platform-specific posts Marketing Exec Vertex AI (Gemini) +marketing.publish_missinglettr Schedule/publish via Missinglettr Marketing Exec Missinglettr API + Secret Manager +marketing.publish_email Send email campaign Marketing Exec Email provider (SendGrid/etc) + Secret Manager +marketing.store_assets Save creatives/outputs Marketing Exec GCS +marketing.get_campaign_status Poll publish status Marketing Exec Missinglettr / provider APIs +C) Analytics module (events, funnels, causation) +Tool name Purpose Executes in Backed by +events.ingest Ingest events (if you own ingestion endpoint) Analytics/Ingress Exec Pub/Sub + BigQuery +analytics.funnel_summary Funnel metrics Analytics Exec BigQuery +analytics.cohort_retention Retention cohorts Analytics Exec BigQuery +analytics.anomaly_detect Detect anomalies in KPIs Analytics Exec BigQuery / BQML +analytics.top_drivers Feature/sequence drivers Analytics Exec BigQuery / BQML / Vertex +analytics.causal_uplift Uplift/causal impact estimate Analytics Exec BigQuery + Vertex (optional) +analytics.write_insight Persist insight object Analytics Exec BigQuery + Firestore pointer + GCS artifact +D) Growth module (onboarding + lifecycle optimization) +Tool name Purpose Executes in Backed by +growth.identify_dropoffs Identify where users drop Analytics Exec BigQuery +growth.propose_experiment Generate experiment hypothesis/design Growth Exec Gemini + policies +experiments.create Create experiment definition Experiments Exec Firestore/SQL + your assignment service +experiments.evaluate Evaluate results Analytics/Experiments Exec BigQuery +growth.generate_lifecycle_messages Draft onboarding/lifecycle content Marketing/Growth Exec Gemini +E) Support module (feedback + ticket assist) +Tool name Purpose Executes in Backed by +support.ingest_tickets Pull tickets from provider Support Exec Zendesk/Intercom API +support.summarize_ticket Summarize and classify Support Exec Gemini +support.draft_reply Draft response Support Exec Gemini + brand profile +support.update_kb Generate/update KB article Support Exec CMS/Docs + GCS +support.escalate_issue Create issue/task Support Exec GitHub Issues/Jira/etc +F) Infrastructure module (safe, templated ops only) +Tool name Purpose Executes in Backed by +infra.provision_service_template Create a Cloud Run service template Infra Exec Terraform/Cloud APIs +infra.provision_database Create Cloud SQL/Firestore config Infra Exec Cloud SQL / Firestore +infra.provision_pubsub Topics/subscriptions Infra Exec Pub/Sub +infra.rotate_secret Rotate/refresh secrets Infra Exec Secret Manager +infra.cost_report Cost summary for product Analytics/FinOps Exec Billing export → BigQuery +G) Auth module (product auth + internal access) +Tool name Purpose Executes in Backed by +auth.configure_identity_platform Set up end-user auth Auth/Infra Exec Identity Platform +auth.configure_iap Protect internal tools Infra Exec IAP +auth.create_oauth_client Create OAuth creds for integrations Infra Exec Google OAuth / Secret Manager +Recommended “v1 tool registry seed” (small, shippable) + +If you want a tight first release, seed only these tools: + +Code + +cloudrun.deploy_service + +cloudrun.get_service_status + +logs.tail + +Company Brain + +brand.get_profile + +brand.update_profile + +Analytics + +analytics.funnel_summary + +analytics.top_drivers + +analytics.write_insight + +Marketing + +marketing.generate_channel_posts + +marketing.publish_missinglettr + +That’s enough to demonstrate the full Product OS loop: +deploy → events → insight → campaign → publish → measure. + +If you want, I can also output this as an actual tool-registry.yaml starter file (matching the schema we drafted earlier) with concrete JSON Schemas for each of the v1 tools. + +yes gor for it + +Here’s a starter tool-registry.yaml you can drop into contracts/tool-registry.yaml. It matches the schema we defined earlier and includes concrete JSON Schemas for the v1 seed tools: + +cloudrun.deploy_service + +cloudrun.get_service_status + +logs.tail + +brand.get_profile + +brand.update_profile + +analytics.funnel_summary + +analytics.top_drivers + +analytics.write_insight + +marketing.generate_channel_posts + +marketing.publish_missinglettr + +Replace the executor.url placeholders with your actual Cloud Run service URLs. + +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). + build: + type: object + additionalProperties: false + properties: + dockerfile_path: + type: string + default: Dockerfile + build_context: + type: string + default: . + env_vars: + type: object + additionalProperties: + type: string + description: Environment variables to set during build/deploy (non-secret). + deploy: + type: object + additionalProperties: false + properties: + cpu: + type: string + description: Cloud Run CPU (e.g., "1", "2"). + memory: + type: string + description: Cloud Run memory (e.g., "512Mi", "1Gi"). + min_instances: + type: integer + minimum: 0 + max_instances: + type: integer + minimum: 1 + concurrency: + type: integer + minimum: 1 + timeout_seconds: + type: integer + minimum: 1 + maximum: 3600 + service_account_email: + type: string + description: Runtime service account email for the Cloud Run service. + allow_unauthenticated: + type: boolean + default: false + outputSchema: + type: object + additionalProperties: false + required: [service_url, revision] + properties: + service_url: + type: string + revision: + type: string + build_id: + type: string + warnings: + type: array + items: + 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 + additionalProperties: false + required: [service_name, region, service_url, latest_ready_revision, status] + 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] + last_deploy_time: + type: string + description: ISO timestamp if available. + + logs.tail: + description: Tail recent logs for a Cloud Run service or for a specific run_id. Returns log lines (best-effort). + 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] + description: Tail logs by service or by tool run. + service_name: + type: string + description: Required if scope=service. + region: + type: string + description: Optional when scope=service, depending on your log query strategy. + run_id: + type: string + description: Required if scope=run. + limit: + type: integer + minimum: 1 + maximum: 2000 + default: 200 + since_seconds: + type: integer + minimum: 1 + maximum: 86400 + default: 900 + outputSchema: + type: object + additionalProperties: false + required: [lines] + properties: + lines: + type: array + items: + type: object + additionalProperties: false + required: [timestamp, text] + 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 + additionalProperties: false + required: [profile_id, brand] + properties: + profile_id: + type: string + brand: + type: object + additionalProperties: false + required: [name, voice, audience, claims_policy] + properties: + name: + type: string + voice: + type: object + additionalProperties: false + required: [tone, style_notes, do, dont] + properties: + tone: + type: array + items: { type: string } + style_notes: + type: array + items: { type: string } + do: + type: array + items: { type: string } + dont: + type: array + items: { type: string } + audience: + type: object + additionalProperties: false + properties: + primary: + type: string + secondary: + type: string + claims_policy: + type: object + additionalProperties: false + properties: + forbidden_claims: + type: array + items: { type: string } + required_disclaimers: + type: array + items: { type: string } + compliance_notes: + type: array + items: { type: string } + + 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 + additionalProperties: false + required: [ok, updated_at] + properties: + ok: + type: boolean + updated_at: + type: string + + # ---------------------------- + # ANALYTICS / CAUSATION (V1 metrics + drivers) + # ---------------------------- + + 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 + additionalProperties: false + required: [name, steps] + properties: + name: + type: string + steps: + type: array + minItems: 2 + items: + type: object + additionalProperties: false + required: [event_name] + properties: + event_name: + type: string + filter: + type: object + description: Optional event property filters (executor-defined). + segment: + type: object + description: Optional segment definition (executor-defined). + outputSchema: + type: object + additionalProperties: false + required: [funnel_name, range_days, steps] + properties: + funnel_name: + type: string + range_days: + type: integer + steps: + type: array + items: + type: object + additionalProperties: false + required: [event_name, users, conversion_from_prev] + properties: + event_name: + type: string + users: + type: integer + minimum: 0 + conversion_from_prev: + type: number + minimum: 0 + maximum: 1 + + analytics.top_drivers: + description: Identify top correlated drivers for a target metric/event (v1: correlation/feature importance; later: causality). + 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 + additionalProperties: false + required: [metric] + properties: + metric: + type: string + description: Named metric (e.g., "trial_to_paid", "activation_rate") or event-based metric. + event_name: + type: string + description: Optional: if metric is event-based, supply event_name. + candidate_features: + type: array + items: + type: string + description: Optional list of features/properties to consider. + segment: + type: object + description: Optional segmentation. + outputSchema: + type: object + additionalProperties: false + required: [target, range_days, drivers] + properties: + target: + type: object + range_days: + type: integer + drivers: + type: array + items: + type: object + additionalProperties: false + required: [name, score, direction, evidence] + properties: + name: + type: string + score: + type: number + direction: + type: string + enum: [positive, negative, mixed, unknown] + evidence: + type: string + description: Human-readable summary of why this driver matters. + confidence: + type: number + minimum: 0 + maximum: 1 + + analytics.write_insight: + description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact). Returns an insight_id. + 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 + additionalProperties: false + 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 + additionalProperties: false + required: [range_days] + properties: + range_days: + type: integer + minimum: 1 + maximum: 365 + context: + type: object + description: Arbitrary structured context (metric names, segments, charts pointers). + recommendations: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: [action, rationale] + properties: + action: + type: string + rationale: + type: string + links: + type: array + items: + type: object + additionalProperties: false + required: [label, url] + properties: + label: { type: string } + url: { type: string } + outputSchema: + type: object + additionalProperties: false + required: [insight_id, stored] + properties: + insight_id: + type: string + stored: + type: object + additionalProperties: false + required: [bigquery, firestore, gcs] + properties: + bigquery: + type: object + additionalProperties: false + required: [dataset, table] + properties: + dataset: { type: string } + table: { type: string } + firestore: + type: object + additionalProperties: false + required: [collection, doc_id] + properties: + collection: { type: string } + doc_id: { type: string } + gcs: + type: object + additionalProperties: false + required: [bucket, prefix] + properties: + bucket: { type: string } + prefix: { type: string } + + # ---------------------------- + # 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 + description: Brand profile id to load (e.g., "default"). + brief: + type: object + additionalProperties: false + required: [goal, product, audience, key_points] + properties: + goal: + type: string + description: What outcome are we driving? (e.g., "trial signups") + product: + type: string + audience: + type: string + key_points: + type: array + minItems: 1 + items: { type: string } + offer: + type: string + call_to_action: + type: string + landing_page_url: + type: string + channels: + type: array + minItems: 1 + items: + type: string + enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit, google_business, mastodon, bluesky, threads] + variations_per_channel: + type: integer + minimum: 1 + maximum: 10 + default: 3 + constraints: + type: object + additionalProperties: false + properties: + max_length: + type: integer + minimum: 50 + maximum: 4000 + emoji_level: + type: string + enum: [none, light, medium, heavy] + default: light + include_hashtags: + type: boolean + default: true + outputSchema: + type: object + additionalProperties: false + required: [channels] + properties: + channels: + type: array + items: + type: object + additionalProperties: false + required: [channel, posts] + properties: + channel: + type: string + posts: + type: array + items: + type: object + additionalProperties: false + required: [text] + properties: + text: { type: string } + title: { type: string } + alt_text: { type: string } + hashtags: + type: array + items: { type: string } + media_suggestions: + type: array + items: { type: string } + + marketing.publish_missinglettr: + description: Publish or schedule a campaign via Missinglettr using stored OAuth/token secrets. + 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 + additionalProperties: false + required: [name, posts] + properties: + name: + type: string + posts: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: [channel, text] + properties: + channel: + type: string + enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit, google_business, mastodon, bluesky, threads] + text: + type: string + media_urls: + type: array + items: { type: string } + link_url: + type: string + schedule: + type: object + additionalProperties: false + required: [mode] + properties: + mode: + type: string + enum: [now, scheduled] + start_time: + type: string + description: ISO timestamp required if mode=scheduled. + timezone: + type: string + default: UTC + idempotency_key: + type: string + description: Optional idempotency key to prevent duplicates. + outputSchema: + type: object + additionalProperties: false + required: [provider, campaign_id, status] + properties: + provider: + type: string + enum: [missinglettr] + campaign_id: + type: string + status: + type: string + enum: [queued, scheduled, published, failed] + provider_response: + type: object + description: Raw provider response (redacted as needed). \ No newline at end of file diff --git a/platform/backend/control-plane/package.json b/platform/backend/control-plane/package.json new file mode 100644 index 00000000..0a9afc1f --- /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 00000000..9ee48db2 --- /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 00000000..35e1450c --- /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 00000000..ba60b5ee --- /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 00000000..fd980a4d --- /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 00000000..bb107525 --- /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 00000000..46b52a62 --- /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 00000000..5b99f1a1 --- /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 00000000..02bded52 --- /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 00000000..80789fde --- /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 00000000..38b71336 --- /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 00000000..1af6d647 --- /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 00000000..a52eb4cc --- /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 00000000..e595a681 --- /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 00000000..ab50def7 --- /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 00000000..a6d228d9 --- /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 00000000..dcef35ee --- /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 00000000..c2c2b21b --- /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 00000000..a6d228d9 --- /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 00000000..77cf9d1f --- /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 00000000..d18a2c94 --- /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 00000000..a6d228d9 --- /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 00000000..a995c7a1 --- /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 00000000..06f85436 --- /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 00000000..a6d228d9 --- /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 00000000..cc049127 --- /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 00000000..ed13ef31 --- /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 00000000..e88269ff --- /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 00000000..486fed81 --- /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 00000000..47547c8d --- /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 00000000..cbd37cc5 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/package.json @@ -0,0 +1,113 @@ +{ + "name": "gcp-productos", + "displayName": "GCP Product OS", + "description": "Product-centric IDE for launching and operating SaaS products on Google Cloud. Use @productos in chat!", + "version": "0.2.0", + "publisher": "productos", + "engines": { "vscode": "^1.90.0" }, + "categories": ["AI", "Chat", "Other"], + "activationEvents": ["onStartupFinished"], + "main": "./dist/extension.js", + "contributes": { + "chatParticipants": [ + { + "id": "productos.chat", + "fullName": "Product OS", + "name": "productos", + "description": "Deploy, analyze, and automate your SaaS product on Google Cloud", + "isSticky": true, + "commands": [ + { + "name": "deploy", + "description": "Deploy a service to Cloud Run" + }, + { + "name": "analytics", + "description": "Get funnel and conversion analytics" + }, + { + "name": "marketing", + "description": "Generate marketing content" + }, + { + "name": "status", + "description": "Check service status" + } + ] + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "productos", + "title": "Product OS", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "productos": [ + { + "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.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)" } + ], + "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 00000000..ea5cc9d6 --- /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 00000000..df6757d9 --- /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/chatParticipant.ts b/platform/client-ide/extensions/gcp-productos/src/chatParticipant.ts new file mode 100644 index 00000000..838976e2 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/chatParticipant.ts @@ -0,0 +1,223 @@ +import * as vscode from "vscode"; +import { getBackendUrl } from "./api"; + +/** + * Product OS Chat Participant + * + * Registers @productos in the native VS Code chat panel. + * Users can type "@productos deploy to staging" and get responses + * in the same UI as GitHub Copilot. + */ + +// Chat response interface from Control Plane +interface ChatResponse { + message: string; + toolCalls?: { name: string; arguments: any }[]; + runs?: any[]; + finishReason: string; +} + +/** + * Register the Product OS chat participant + */ +export function registerChatParticipant(context: vscode.ExtensionContext) { + // Create the chat participant + const participant = vscode.chat.createChatParticipant( + "productos.chat", + chatHandler + ); + + // Set participant properties + participant.iconPath = vscode.Uri.joinPath(context.extensionUri, "media", "icon.svg"); + + // Add follow-up provider for suggestions + participant.followupProvider = { + provideFollowups( + result: vscode.ChatResult, + context: vscode.ChatContext, + token: vscode.CancellationToken + ): vscode.ProviderResult { + // Suggest follow-up actions based on what was just done + return [ + { prompt: "Show me funnel analytics", label: "📊 Analytics" }, + { prompt: "Deploy to staging", label: "🚀 Deploy" }, + { prompt: "Generate marketing posts", label: "📣 Marketing" }, + { prompt: "What drives conversions?", label: "📈 Drivers" } + ]; + } + }; + + context.subscriptions.push(participant); + + console.log("[Product OS] Chat participant @productos registered"); +} + +/** + * Handle chat requests + */ +async function chatHandler( + request: vscode.ChatRequest, + context: vscode.ChatContext, + response: vscode.ChatResponseStream, + token: vscode.CancellationToken +): Promise { + + const userPrompt = request.prompt; + console.log("[Product OS] Chat request:", userPrompt); + + // Show progress + response.progress("Connecting to Product OS..."); + + try { + // Build message history from context + const messages = buildMessageHistory(context, userPrompt); + + // Get code context if available + const codeContext = await getCodeContext(); + + // Call the Control Plane + const result = await callControlPlane(messages, codeContext); + + // Handle tool calls + if (result.toolCalls && result.toolCalls.length > 0) { + for (const toolCall of result.toolCalls) { + response.markdown(`\n\n**🔧 Executing:** \`${toolCall.name}\`\n`); + } + } + + // Handle runs (tool execution results) + if (result.runs && result.runs.length > 0) { + for (const run of result.runs) { + const status = run.status === "succeeded" ? "✅" : "❌"; + response.markdown(`\n${status} **${run.tool}**\n`); + + if (run.output) { + response.markdown("\n```json\n" + JSON.stringify(run.output, null, 2) + "\n```\n"); + } + + if (run.error) { + response.markdown(`\n**Error:** ${run.error.message}\n`); + } + } + } + + // Stream the main response + if (result.message) { + response.markdown(result.message); + } + + return { metadata: { command: "chat" } }; + + } catch (error: any) { + console.error("[Product OS] Chat error:", error); + + response.markdown(`\n\n❌ **Error:** ${error.message}\n\n`); + response.markdown("Make sure the Control Plane is running at " + getBackendUrl()); + + return { + metadata: { command: "error" }, + errorDetails: { message: error.message } + }; + } +} + +/** + * Build message history from chat context + */ +function buildMessageHistory( + context: vscode.ChatContext, + currentPrompt: string +): { role: string; content: string }[] { + const messages: { role: string; content: string }[] = []; + + // Add previous messages from context + for (const turn of context.history) { + if (turn instanceof vscode.ChatRequestTurn) { + messages.push({ role: "user", content: turn.prompt }); + } else if (turn instanceof vscode.ChatResponseTurn) { + // Extract text from response + let text = ""; + for (const part of turn.response) { + if (part instanceof vscode.ChatResponseMarkdownPart) { + text += part.value.value; + } + } + if (text) { + messages.push({ role: "assistant", content: text }); + } + } + } + + // Add current prompt + messages.push({ role: "user", content: currentPrompt }); + + return messages; +} + +/** + * Get context from the active editor + */ +async function getCodeContext(): Promise { + 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 + } + }; + } + + // No selection - include some context from the current file + const document = editor.document; + const cursorLine = selection.active.line; + const startLine = Math.max(0, cursorLine - 20); + const endLine = Math.min(document.lineCount - 1, cursorLine + 20); + + const range = new vscode.Range(startLine, 0, endLine, document.lineAt(endLine).text.length); + const surroundingCode = document.getText(range); + + if (surroundingCode.trim()) { + return { + files: [{ + path: vscode.workspace.asRelativePath(editor.document.uri), + content: surroundingCode + }] + }; + } + + return undefined; +} + +/** + * Call the Control Plane chat endpoint + */ +async function callControlPlane( + messages: { role: string; content: string }[], + context?: any +): Promise { + const backendUrl = getBackendUrl(); + + const response = await fetch(`${backendUrl}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages, + context, + autoExecuteTools: true + }) + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Control Plane error: ${response.status} - ${text}`); + } + + return response.json(); +} 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 00000000..40676bbb --- /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 00000000..7433f294 --- /dev/null +++ b/platform/client-ide/extensions/gcp-productos/src/extension.ts @@ -0,0 +1,176 @@ +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 { showJson, openRun, showRunDocument } from "./ui"; +import { registerChatParticipant } from "./chatParticipant"; + +export function activate(context: vscode.ExtensionContext) { + console.log("Product OS extension activated"); + + // Register @productos in the native VS Code chat + // This gives us the Copilot-like chat experience for FREE + try { + registerChatParticipant(context); + } catch (e) { + console.log("[Product OS] Chat Participant API not available (requires VS Code 1.90+)"); + } + + // Create tree providers + const toolsProvider = new ToolsTreeProvider(); + const runsProvider = new RunsTreeProvider(); + + // 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 00000000..3e33d86c --- /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 00000000..0fe3960a --- /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 00000000..b272a6d2 --- /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 00000000..1421826e --- /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 00000000..4e92fb2c --- /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 00000000..275b2719 --- /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 00000000..80fbff6f --- /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 00000000..e77e4db7 --- /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 00000000..92401921 --- /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 00000000..7b01f135 --- /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 00000000..e875e74a --- /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 00000000..724ede25 --- /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 00000000..ace2f1d4 --- /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 00000000..2d7b525e --- /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 00000000..14ddaf63 --- /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 00000000..f5db86ee --- /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 00000000..be1ab816 --- /dev/null +++ b/technical_spec.md @@ -0,0 +1,610 @@ +Google Cloud Product OS +Technical Specification + +Product-Centric IDE + SaaS Autopilot Platform + +1. Purpose + +This document defines the technical architecture, components, interfaces, and implementation plan for building a: + +Google Cloud–native, Gemini-powered Product Operating System (Product OS) + +The platform unifies: + +Code development + +Product launch + +Marketing automation + +Analytics and causality + +Growth optimization + +Support automation + +Experimentation + +Infrastructure management + +into a single product-centric IDE and automation system. + +This is not a general-purpose IDE. +It is a Product OS for launching and operating SaaS products on Google Cloud. + +2. Core Design Principles +2.1 Product-Centric Orientation + +The platform optimizes for: + +Shipping products + +Launching features + +Running marketing + +Optimizing growth + +Operating infrastructure + +Automating decisions + +Not for: + +Arbitrary coding workflows + +Multi-cloud portability + +Framework experimentation + +2.2 Opinionated for Google Cloud + +The platform is single-cloud and deeply integrated with: + +Cloud Run + +Cloud Build + +Artifact Registry + +Firestore + +Cloud SQL + +BigQuery + +Pub/Sub + +Vertex AI (Gemini) + +No AWS or Azure abstraction layers are supported. + +2.3 Backend Tool Execution (Security Model) + +All automation executes on the backend. + +The IDE: + +Never runs gcloud + +Never runs Terraform + +Never holds GCP credentials + +Never touches databases directly + +Instead: + +IDE / Supervisor AI + ↓ + Control Plane API + ↓ + Executors + ↓ + GCP Services + +2.4 AI as a Product Operator + +The AI is not a coding assistant. + +It is a: + +Product Operator AI + +Responsibilities: + +Interpret product goals + +Read analytics and insights + +Decide actions + +Dispatch tools + +Enforce policies + +Learn from outcomes + +3. High-Level Architecture + ┌─────────────────────────────┐ + │ VSCodium IDE Client │ + │ (Product-Centric UI Shell) │ + └──────────────┬──────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ Control Plane API │ + │ (Tool Router + Policy) │ + └──────────────┬───────────┘ + │ + ┌──────────────┬───────────┼─────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + Deploy Executor Analytics Exec Firestore Exec SQL Exec Missinglettr Exec + Cloud Build+Run BigQuery Firestore Cloud SQL Social Posting + + │ + ┌──────▼───────┐ + │ GCS Store │ + │ Artifacts │ + └──────────────┘ + +4. IDE Client Architecture +4.1 Base Editor + +VSCodium distribution + +OpenVSX marketplace + +Preinstalled extensions + +Preconfigured settings + +Custom UI panels + +4.2 Product-Centric Navigation + +The IDE must expose: + +Product OS +├── Code +├── Marketing +├── Analytics +├── Growth +├── Support +├── Experiments +└── Infrastructure + + +Each section is: + +First-class + +AI-assisted + +Connected to backend tools + +4.3 IDE Responsibilities + +The IDE handles: + +File editing + +Patch preview & application + +Project context collection + +Tool invocation UI + +Artifact viewing + +Logs & traces display + +The IDE does NOT: + +Execute cloud commands + +Store secrets + +Perform deployments + +Perform database queries + +5. Control Plane API +5.1 Purpose + +The Control Plane is the central orchestration backend. + +Responsibilities: + +Auth + +Tool registry + +Tool invocation routing + +Policy enforcement + +Run tracking + +Artifact storage (GCS) + +Gemini proxy + +5.2 Core Endpoints +POST /tools/invoke +GET /runs/{run_id} +GET /runs/{run_id}/logs +GET /tools +GET /artifacts/{run_id} + +5.3 Tool Invocation Contract +Request +{ + "tool": "cloudrun.deploy_service", + "tenant_id": "t_123", + "workspace_id": "w_456", + "input": { + "service_name": "marketing-gateway", + "repo": "github.com/org/repo", + "ref": "main", + "env": "prod" + }, + "dry_run": false +} + +Response +{ + "run_id": "run_20260119_abc", + "status": "queued" +} + +6. Tool Registry + +All executable actions are declared as tools. + +6.1 Tool Schema +tools: + cloudrun.deploy_service: + description: Deploy a Cloud Run service + input_schema: + service_name: string + repo: string + ref: string + env: string + output_schema: + service_url: string + risk: medium + executor: deploy-executor + +6.2 Registry Responsibilities + +Input validation + +Output validation + +Risk classification + +Executor routing + +Used by: + +IDE + +Supervisor AI + +Web dashboard + +7. Executors (Domain Services) + +Each executor is a Cloud Run service with its own service account. + +7.1 Deploy Executor + +Purpose: + +Build and deploy services + +Tools: + +cloudrun.deploy_service + +cloudrun.tail_logs + +cloudrun.rollback + +GCP APIs: + +Cloud Build + +Cloud Run + +Artifact Registry + +IAM: + +roles/cloudbuild.builds.editor + +roles/run.admin (scoped) + +roles/artifactregistry.writer + +7.2 Analytics Executor (OpsOS) + +Purpose: + +Product intelligence and causality + +Tools: + +analytics.get_funnel_summary + +analytics.get_top_drivers + +analytics.get_anomalies + +GCP APIs: + +BigQuery + +BigQuery ML + +IAM: + +roles/bigquery.dataViewer + +roles/bigquery.jobUser + +7.3 Firestore Executor + +Purpose: + +Company Brain + configs + +Tools: + +firestore.get_company_brain + +firestore.update_company_brain + +GCP APIs: + +Firestore + +IAM: + +roles/datastore.user + +7.4 SQL Executor + +Purpose: + +Transactional summaries + +Tools: + +sql.get_subscription_summary + +sql.get_user_metrics + +GCP APIs: + +Cloud SQL + +IAM: + +roles/cloudsql.client + +DB-level users + +7.5 Missinglettr Executor + +Purpose: + +Social publishing + +Tools: + +missinglettr.publish_campaign + +missinglettr.get_campaign_status + +Secrets: + +Missinglettr API tokens + +IAM: + +roles/secretmanager.secretAccessor + +8. Data Storage +8.1 Firestore + +Used for: + +Company Brain + +Tool registry + +Policy configs + +Style profiles + +Run metadata + +8.2 GCS + +Used for: + +Logs + +AI outputs + +Generated patches + +Deployment artifacts + +Prompt snapshots + +8.3 BigQuery + +Used for: + +Event warehouse + +Funnels + +Causality models + +Experiment results + +9. AI Integration +9.1 Gemini Proxy + +All AI calls go through Control Plane. + +Responsibilities: + +Auth + +Rate limiting + +Prompt registry + +Logging + +Cost controls + +9.2 AI Patch Contract + +Gemini must return: + +{ + "files": [ + { + "path": "src/main.ts", + "diff": "@@ -1,3 +1,6 @@ ..." + } + ], + "commands": [ + "npm test" + ], + "summary": "Add logging middleware" +} + +10. IAM Strategy +10.1 Users + +OAuth only + +No GCP IAM + +No key files + +10.2 Backend + +Workload identity + +No long-lived keys + +Least privilege + +Per-executor roles + +11. Supported Languages + +TypeScript / Node + +Python + +No additional languages in v1. + +12. SaaS Autopilot Layer + +A Supervisor AI Agent runs in Vertex AI Agent Designer. + +It calls the same tools as the IDE. + +Supervisor AI → Control Plane → Executors + +13. Non-Goals + +The platform does NOT: + +Replace VS Code generically + +Support all frameworks + +Support multi-cloud + +Allow raw IAM editing + +Execute cloud commands locally + +14. Repository Structure +/platform + /client-ide + /vscodium + /extensions + /backend + /control-plane + /executors + /contracts + /infra + /docs + +15. Implementation Phases +Phase 1 – Core + +Control Plane API + +Deploy Executor + +Gemini Proxy + +IDE Deploy UI + +Phase 2 – Intelligence + +Firestore Executor + +Analytics Executor + +Funnel + driver tools + +Phase 3 – Automation + +Missinglettr Executor + +Growth + Experiments + +Supervisor AI + +16. Final Statement + +This system is a: + +Google Cloud–native Product Operating System +for launching, growing, and automating SaaS products +using Gemini and backend-controlled automation. + +Optional Next Steps + +Generate Control Plane API scaffold + +Generate Tool Registry schema + +Generate VSCodium extension skeleton + +Generate Terraform base + +If you want, I can next generate: + +The Control Plane API OpenAPI spec + +The Tool Registry schema file + +The First Executor service skeleton + +The VSCodium extension skeleton + +Tell me which one you want first. \ No newline at end of file diff --git a/vision-ext.md b/vision-ext.md new file mode 100644 index 00000000..975db836 --- /dev/null +++ b/vision-ext.md @@ -0,0 +1,289 @@ +Final Direction Summary: Replacing Cursor for Your Use Case +Core Goal + +You want: + +A Cursor-like chat experience + +Integrated with: + +your codebase + +Google Cloud services + +your product workflows + +Without paying for Cursor or depending on OpenAI/Cursor infra. + +We aligned on an approach that gives you this, while keeping costs, maintenance, and risk manageable. + +The Chosen Architecture +1. Use VSCodium as your editor base + +Instead of Cursor or VS Code: + +Open-source + +Redistributable + +No telemetry/licensing issues + +Compatible with VS Code extensions + +Lets you ship your own IDE experience + +You are not building a new editor, you are building a product cockpit on top of a proven editor shell. + +2. Build your product experience as an Extension (not a fork) + +We agreed: + +Extension-first is the right V1 strategy. + +Because with an extension you can: + +Add your own Product OS UI + +Build your own chat interface + +Integrate Gemini + GCP + tools + +Ship cross-platform quickly + +Avoid the heavy maintenance cost of a fork + +A fork only becomes justified later if you need: + +Hard shell changes + +Locked-down layouts + +Enterprise kiosk behavior + +3. Use an Open-Source Chat UI Instead of Cursor + +To avoid building chat UI from scratch, we landed on: + +✅ Best starting point: Open-source chat extensions + +You can reuse or extend: + +Option A (Recommended) + +Copilot Chat UI (open-sourced by Microsoft) + +Production-grade chat UI + +MIT license + +Can be repointed to: + +your backend + +Gemini / Vertex AI + +Gives you: + +streaming responses + +history + +context-aware UX + +Option B (Fast prototyping) + +Continue + +Open-source + +Already works in VSCodium + +Can connect to: + +local LLMs + +remote APIs (your Gemini backend) + +Great for validating UX quickly + +This gives you: + +A Cursor-like chat UX without Cursor. + +4. Gemini + Control Plane replaces Cursor’s backend + +Instead of: + +Cursor → OpenAI → Cursor tools + +You will have: + +VSCodium → Your Extension → Control Plane → Gemini (Vertex AI) + GCP Tools + +Your backend becomes the intelligence layer: + +/chat endpoint → Gemini + +/tools/invoke → deploy, logs, analytics, campaigns, etc + +policy enforcement + +cost tracking + +product-aware reasoning + +This gives you: + +full ownership + +no vendor lock-in + +better monetization control + +5. Code Generation Does NOT require rebuilding everything + +We clarified: +You do NOT need to rebuild a full editor or execution engine to generate code. + +You only need: + +Minimal tooling: + +Model returns: + +structured diffs + +optional commands + +Extension: + +previews changes + +applies patches + +optionally runs tests + +Everything else (editing, git, terminals) is already provided by VSCodium. + +So you get: + +Cursor-like “generate code and apply it” behavior +without building Cursor from scratch. + +6. Direct Cloud Access: Use Signed URLs, Not Service Accounts + +We aligned on: + +Don’t give the IDE persistent cloud credentials + +Use: + +Control Plane → signed URLs → GCS + +This gives you: + +better security + +easier monetization + +easy migration later + +avoids long-term risk + +You can still have: + +Direct data transfer +without exposing cloud identities. + +7. Product OS > Code Chat Only + +You’re not just building a “code helper chat”. + +You’re building a Product OS, where chat can: + +generate code + +deploy services + +analyze funnels + +generate campaigns + +summarize experiments + +optimize onboarding + +respond to support tickets + +That’s your differentiator over Cursor: + +Cursor is a coding assistant +You’re building a product automation cockpit + +What This Means Practically + +You will: + +Run VSCodium + +Install: + +Your Product OS extension + +An open-source chat UI (or embed it) + +Connect it to: + +Your Control Plane + +Gemini on Vertex AI + +Add: + +Tool invocation + +Product modules (marketing, analytics, growth, etc) + +Ship: + +A Cursor-free AI IDE focused on launching and running products + +What You Avoid + +By this approach, you avoid: + +Paying Cursor per seat + +Being locked into OpenAI routing + +Forking VS Code prematurely + +Owning an editor platform too early + +Maintaining a custom compiler/distribution pipeline + +Final Position + +You do not need Cursor. + +You can build: + +A great chat interface + +With code + GCP integration + +On VSCodium + +With open-source UI + +Powered by Gemini + +And fully controlled by you + +If you’d like, next I can: + +Lay out a concrete build roadmap (V1 → V3) + +Or give you a minimal stack diagram + repo layout + +Or produce a starter technical spec for your Product OS Chat + Tooling platform \ No newline at end of file