Merge remote main branch with local changes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
61
.continue/config.yaml
Normal file
61
.continue/config.yaml
Normal file
@@ -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.
|
||||||
743
1.Generate Control Plane API scaffold.md
Normal file
743
1.Generate Control Plane API scaffold.md
Normal file
@@ -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 <id_token>
|
||||||
|
// - 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<void> {
|
||||||
|
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRun(runId: string): Promise<RunRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(): Promise<ToolDef[]> {
|
||||||
|
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<Record<string, ToolDef>> {
|
||||||
|
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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/<run_id>/" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(key: string): T {
|
||||||
|
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backendUrl() {
|
||||||
|
return cfg<string>("backendUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantId() {
|
||||||
|
return cfg<string>("tenantId");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(): Promise<any[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
949
architecture.md
Normal file
949
architecture.md
Normal file
@@ -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).
|
||||||
30
platform/backend/control-plane/package.json
Normal file
30
platform/backend/control-plane/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
platform/backend/control-plane/src/auth.ts
Normal file
11
platform/backend/control-plane/src/auth.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
10
platform/backend/control-plane/src/config.ts
Normal file
10
platform/backend/control-plane/src/config.ts
Normal file
@@ -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")
|
||||||
|
};
|
||||||
365
platform/backend/control-plane/src/gemini.ts
Normal file
365
platform/backend/control-plane/src/gemini.ts
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ChatResponse> {
|
||||||
|
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<ChatResponse> {
|
||||||
|
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<ChatResponse> {
|
||||||
|
// 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
29
platform/backend/control-plane/src/index.ts
Normal file
29
platform/backend/control-plane/src/index.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
10
platform/backend/control-plane/src/registry.ts
Normal file
10
platform/backend/control-plane/src/registry.ts
Normal file
@@ -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<Record<string, ToolDef>> {
|
||||||
|
const tools = await listTools();
|
||||||
|
return Object.fromEntries(tools.map(t => [t.name, t]));
|
||||||
|
}
|
||||||
306
platform/backend/control-plane/src/routes/chat.ts
Normal file
306
platform/backend/control-plane/src/routes/chat.ts
Normal file
@@ -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<ChatResponseWithRuns> => {
|
||||||
|
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<RunRecord[]> {
|
||||||
|
const runs: RunRecord[] = [];
|
||||||
|
const registry = await getRegistry();
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
// Map tool call names to actual tools
|
||||||
|
const toolMapping: Record<string, string> = {
|
||||||
|
"deploy_service": "cloudrun.deploy_service",
|
||||||
|
"get_funnel_analytics": "analytics.funnel_summary",
|
||||||
|
"get_top_drivers": "analytics.top_drivers",
|
||||||
|
"generate_marketing_posts": "marketing.generate_channel_posts",
|
||||||
|
"get_service_status": "cloudrun.get_service_status",
|
||||||
|
"generate_code": "code.generate" // This one is special - handled inline
|
||||||
|
};
|
||||||
|
|
||||||
|
const actualToolName = toolMapping[toolCall.name];
|
||||||
|
|
||||||
|
// Special handling for code generation
|
||||||
|
if (toolCall.name === "generate_code") {
|
||||||
|
const codeRun = await handleCodeGeneration(toolCall.arguments);
|
||||||
|
runs.push(codeRun);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = actualToolName ? registry[actualToolName] : null;
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
console.log(`Tool not found: ${toolCall.name} (mapped to ${actualToolName})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a run
|
||||||
|
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const run: RunRecord = {
|
||||||
|
run_id: runId,
|
||||||
|
tenant_id: "t_chat",
|
||||||
|
tool: actualToolName,
|
||||||
|
status: "queued",
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
input: toolCall.arguments,
|
||||||
|
artifacts: { bucket: "", prefix: `runs/${runId}` }
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveRun(run);
|
||||||
|
|
||||||
|
// Execute the tool
|
||||||
|
try {
|
||||||
|
run.status = "running";
|
||||||
|
run.updated_at = new Date().toISOString();
|
||||||
|
await saveRun(run);
|
||||||
|
|
||||||
|
const execUrl = `${tool.executor.url}${tool.executor.path}`;
|
||||||
|
const response = await fetch(execUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
run_id: runId,
|
||||||
|
tenant_id: "t_chat",
|
||||||
|
input: toolCall.arguments
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Executor error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await response.json();
|
||||||
|
run.status = "succeeded";
|
||||||
|
run.output = output;
|
||||||
|
run.updated_at = new Date().toISOString();
|
||||||
|
await saveRun(run);
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
run.status = "failed";
|
||||||
|
run.error = { message: e.message };
|
||||||
|
run.updated_at = new Date().toISOString();
|
||||||
|
await saveRun(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle code generation specially
|
||||||
|
*/
|
||||||
|
async function handleCodeGeneration(args: any): Promise<RunRecord> {
|
||||||
|
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// For now, return a mock code generation result
|
||||||
|
// In production, this would call Gemini again with a code-specific prompt
|
||||||
|
const mockDiff = `--- a/${args.file_path || "src/example.ts"}
|
||||||
|
+++ b/${args.file_path || "src/example.ts"}
|
||||||
|
@@ -1,3 +1,10 @@
|
||||||
|
+// Generated by Product OS
|
||||||
|
+// Task: ${args.task}
|
||||||
|
+
|
||||||
|
export function example() {
|
||||||
|
- return "hello";
|
||||||
|
+ // TODO: Implement ${args.task}
|
||||||
|
+ return {
|
||||||
|
+ status: "generated",
|
||||||
|
+ task: "${args.task}"
|
||||||
|
+ };
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const run: RunRecord = {
|
||||||
|
run_id: runId,
|
||||||
|
tenant_id: "t_chat",
|
||||||
|
tool: "code.generate",
|
||||||
|
status: "succeeded",
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
input: args,
|
||||||
|
output: {
|
||||||
|
type: "code_generation",
|
||||||
|
diff: mockDiff,
|
||||||
|
file_path: args.file_path || "src/example.ts",
|
||||||
|
language: args.language || "typescript",
|
||||||
|
description: `Generated code for: ${args.task}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveRun(run);
|
||||||
|
await writeArtifactText(`runs/${runId}`, "diff.patch", mockDiff);
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a human-readable summary of tool executions
|
||||||
|
*/
|
||||||
|
function generateToolSummary(toolCalls: ToolCall[], runs: RunRecord[]): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < toolCalls.length; i++) {
|
||||||
|
const tool = toolCalls[i];
|
||||||
|
const run = runs[i];
|
||||||
|
|
||||||
|
if (!run) continue;
|
||||||
|
|
||||||
|
const status = run.status === "succeeded" ? "✅" : "❌";
|
||||||
|
|
||||||
|
switch (tool.name) {
|
||||||
|
case "deploy_service":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
const output = run.output as any;
|
||||||
|
parts.push(`${status} **Deployed** \`${tool.arguments.service_name}\` to ${tool.arguments.env || "dev"}\n URL: ${output?.service_url || "pending"}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`${status} **Deploy failed** for \`${tool.arguments.service_name}\`: ${run.error?.message}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_funnel_analytics":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
const output = run.output as any;
|
||||||
|
const steps = output?.steps || [];
|
||||||
|
const conversion = ((output?.overall_conversion || 0) * 100).toFixed(1);
|
||||||
|
parts.push(`${status} **Funnel Analysis** (${tool.arguments.range_days || 30} days)\n Overall conversion: ${conversion}%\n Steps: ${steps.length}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_top_drivers":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
const output = run.output as any;
|
||||||
|
const drivers = output?.drivers || [];
|
||||||
|
const topDrivers = drivers.slice(0, 3).map((d: any) => d.name).join(", ");
|
||||||
|
parts.push(`${status} **Top Drivers** for ${tool.arguments.metric}\n ${topDrivers}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "generate_marketing_posts":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
const output = run.output as any;
|
||||||
|
const channels = output?.channels || [];
|
||||||
|
const postCount = channels.reduce((sum: number, c: any) => sum + (c.posts?.length || 0), 0);
|
||||||
|
parts.push(`${status} **Generated** ${postCount} marketing posts for ${channels.map((c: any) => c.channel).join(", ")}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "get_service_status":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
const output = run.output as any;
|
||||||
|
parts.push(`${status} **Service Status**: \`${tool.arguments.service_name}\` is ${output?.status || "unknown"}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "generate_code":
|
||||||
|
if (run.status === "succeeded") {
|
||||||
|
parts.push(`${status} **Generated code** for: ${tool.arguments.task}\n File: \`${(run.output as any)?.file_path}\``);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
parts.push(`${status} **${tool.name}** completed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "I processed your request but no actions were taken.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
17
platform/backend/control-plane/src/routes/health.ts
Normal file
17
platform/backend/control-plane/src/routes/health.ts
Normal file
@@ -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 }));
|
||||||
|
}
|
||||||
18
platform/backend/control-plane/src/routes/runs.ts
Normal file
18
platform/backend/control-plane/src/routes/runs.ts
Normal file
@@ -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/<run_id>/" };
|
||||||
|
});
|
||||||
|
}
|
||||||
91
platform/backend/control-plane/src/routes/tools.ts
Normal file
91
platform/backend/control-plane/src/routes/tools.ts
Normal file
@@ -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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
23
platform/backend/control-plane/src/storage/firestore.ts
Normal file
23
platform/backend/control-plane/src/storage/firestore.ts
Normal file
@@ -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<void> {
|
||||||
|
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRun(runId: string): Promise<RunRecord | null> {
|
||||||
|
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<void> {
|
||||||
|
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(): Promise<ToolDef[]> {
|
||||||
|
const snap = await db.collection(config.toolsCollection).get();
|
||||||
|
return snap.docs.map(d => d.data() as ToolDef);
|
||||||
|
}
|
||||||
11
platform/backend/control-plane/src/storage/gcs.ts
Normal file
11
platform/backend/control-plane/src/storage/gcs.ts
Normal file
@@ -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}` };
|
||||||
|
}
|
||||||
23
platform/backend/control-plane/src/storage/index.ts
Normal file
23
platform/backend/control-plane/src/storage/index.ts
Normal file
@@ -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;
|
||||||
116
platform/backend/control-plane/src/storage/memory.ts
Normal file
116
platform/backend/control-plane/src/storage/memory.ts
Normal file
@@ -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<string, RunRecord>();
|
||||||
|
const tools = new Map<string, ToolDef>();
|
||||||
|
const artifacts = new Map<string, string>();
|
||||||
|
|
||||||
|
// Run operations
|
||||||
|
export async function saveRun(run: RunRecord): Promise<void> {
|
||||||
|
runs.set(run.run_id, { ...run });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRun(runId: string): Promise<RunRecord | null> {
|
||||||
|
return runs.get(runId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool operations
|
||||||
|
export async function saveTool(tool: ToolDef): Promise<void> {
|
||||||
|
tools.set(tool.name, { ...tool });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(): Promise<ToolDef[]> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
37
platform/backend/control-plane/src/types.ts
Normal file
37
platform/backend/control-plane/src/types.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
13
platform/backend/control-plane/tsconfig.json
Normal file
13
platform/backend/control-plane/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
platform/backend/executors/analytics/package.json
Normal file
22
platform/backend/executors/analytics/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
platform/backend/executors/analytics/src/index.ts
Normal file
91
platform/backend/executors/analytics/src/index.ts
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
13
platform/backend/executors/analytics/tsconfig.json
Normal file
13
platform/backend/executors/analytics/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
platform/backend/executors/deploy/package.json
Normal file
23
platform/backend/executors/deploy/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
platform/backend/executors/deploy/src/index.ts
Normal file
91
platform/backend/executors/deploy/src/index.ts
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
13
platform/backend/executors/deploy/tsconfig.json
Normal file
13
platform/backend/executors/deploy/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
platform/backend/executors/marketing/package.json
Normal file
22
platform/backend/executors/marketing/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
88
platform/backend/executors/marketing/src/index.ts
Normal file
88
platform/backend/executors/marketing/src/index.ts
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
13
platform/backend/executors/marketing/tsconfig.json
Normal file
13
platform/backend/executors/marketing/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
103
platform/backend/mcp-adapter/README.md
Normal file
103
platform/backend/mcp-adapter/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
25
platform/backend/mcp-adapter/package.json
Normal file
25
platform/backend/mcp-adapter/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
343
platform/backend/mcp-adapter/src/index.ts
Normal file
343
platform/backend/mcp-adapter/src/index.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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<any> {
|
||||||
|
// 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<any> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
14
platform/backend/mcp-adapter/tsconfig.json
Normal file
14
platform/backend/mcp-adapter/tsconfig.json
Normal file
@@ -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/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
113
platform/client-ide/extensions/gcp-productos/package.json
Normal file
113
platform/client-ide/extensions/gcp-productos/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
platform/client-ide/extensions/gcp-productos/src/api.ts
Normal file
137
platform/client-ide/extensions/gcp-productos/src/api.ts
Normal file
@@ -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<T>(key: string): T {
|
||||||
|
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackendUrl(): string {
|
||||||
|
return getConfig<string>("backendUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantId(): string {
|
||||||
|
return getConfig<string>("tenantId");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getBackendUrl()}/healthz`, {
|
||||||
|
signal: AbortSignal.timeout(3000)
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTools(): Promise<Tool[]> {
|
||||||
|
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<Run> {
|
||||||
|
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<Run> {
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ChatResponse> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
850
platform/client-ide/extensions/gcp-productos/src/chatPanel.ts
Normal file
850
platform/client-ide/extensions/gcp-productos/src/chatPanel.ts
Normal file
@@ -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 `
|
||||||
|
<div class="message ${messageClass}">
|
||||||
|
<div class="avatar ${avatarClass}">${avatar}</div>
|
||||||
|
<div class="content">${escapeHtml(m.content)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||||
|
<title>Product OS Chat</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-muted: #1f6feb;
|
||||||
|
--text-primary: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #6e7681;
|
||||||
|
--border: #30363d;
|
||||||
|
--success: #3fb950;
|
||||||
|
--warning: #d29922;
|
||||||
|
--danger: #f85149;
|
||||||
|
--gradient-start: #0d1117;
|
||||||
|
--gradient-end: #161b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
background: linear-gradient(180deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(13, 17, 23, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message .content {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message .content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 8px 32px rgba(88, 166, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 360px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion:hover {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
background: rgba(13, 17, 23, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge code {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-close {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
padding-right: 44px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent-muted);
|
||||||
|
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-context-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-context-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(88, 166, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-status.success { background: var(--success); }
|
||||||
|
.tool-result-status.error { background: var(--danger); }
|
||||||
|
|
||||||
|
.tool-result code {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-title">
|
||||||
|
<div class="header-icon">✦</div>
|
||||||
|
<h1>Product OS Chat</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="header-btn" onclick="clearChat()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages" id="messages">
|
||||||
|
${messagesHtml || `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">✦</div>
|
||||||
|
<h2>Welcome to Product OS</h2>
|
||||||
|
<p>I can help you deploy services, analyze metrics, generate marketing content, and write code—all in one place.</p>
|
||||||
|
<div class="suggestions">
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Deploy my service to staging')">Deploy to staging</button>
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Show me funnel analytics')">Funnel analytics</button>
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Generate launch posts for X and LinkedIn')">Marketing posts</button>
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('What drives my conversion rate?')">Conversion drivers</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="loading-dots">
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span>Product OS is thinking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="context-badge" id="context-badge">
|
||||||
|
<span>📎 Context:</span>
|
||||||
|
<code id="context-path"></code>
|
||||||
|
<span class="context-close" onclick="clearContext()">✕</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-container">
|
||||||
|
<textarea
|
||||||
|
id="input"
|
||||||
|
placeholder="Ask me to deploy, analyze, or generate..."
|
||||||
|
rows="1"
|
||||||
|
onkeydown="handleKeydown(event)"
|
||||||
|
oninput="autoResize(this)"
|
||||||
|
></textarea>
|
||||||
|
<button class="add-context-btn" onclick="addContext()" title="Add code context">@</button>
|
||||||
|
</div>
|
||||||
|
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="${nonce}">
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let currentContext = null;
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const input = document.getElementById('input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// Send to extension
|
||||||
|
vscode.postMessage({ command: 'send', text, context: currentContext });
|
||||||
|
|
||||||
|
// Clear input and context
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
clearContext();
|
||||||
|
|
||||||
|
// Add user message to UI immediately
|
||||||
|
addMessageToUI('user', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuggestion(text) {
|
||||||
|
document.getElementById('input').value = text;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContext() {
|
||||||
|
vscode.postMessage({ command: 'addContext' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContext() {
|
||||||
|
currentContext = null;
|
||||||
|
document.getElementById('context-badge').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChat() {
|
||||||
|
vscode.postMessage({ command: 'clear' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessageToUI(role, content) {
|
||||||
|
const messages = document.getElementById('messages');
|
||||||
|
const isEmpty = messages.querySelector('.empty-state');
|
||||||
|
if (isEmpty) isEmpty.remove();
|
||||||
|
|
||||||
|
const isUser = role === 'user';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
|
||||||
|
div.innerHTML = \`
|
||||||
|
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
|
||||||
|
<div class="content">\${escapeHtml(content)}</div>
|
||||||
|
\`;
|
||||||
|
messages.appendChild(div);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML.replace(/\\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(text) {
|
||||||
|
// Simple markdown-like formatting
|
||||||
|
return text
|
||||||
|
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\`(.+?)\`/g, '<code>$1</code>')
|
||||||
|
.replace(/\\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'loading':
|
||||||
|
document.getElementById('loading').classList.toggle('active', message.loading);
|
||||||
|
document.getElementById('send-btn').disabled = message.loading;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'response':
|
||||||
|
// Add AI response to UI
|
||||||
|
if (message.message) {
|
||||||
|
addMessageToUI('assistant', message.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show tool results if any
|
||||||
|
if (message.runs && message.runs.length > 0) {
|
||||||
|
const messages = document.getElementById('messages');
|
||||||
|
message.runs.forEach(run => {
|
||||||
|
const resultDiv = document.createElement('div');
|
||||||
|
resultDiv.className = 'tool-result';
|
||||||
|
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
|
||||||
|
resultDiv.innerHTML = \`
|
||||||
|
<div class="tool-result-header">
|
||||||
|
<div class="tool-result-status \${statusClass}"></div>
|
||||||
|
<span>Executed <code>\${run.tool}</code></span>
|
||||||
|
</div>
|
||||||
|
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
|
||||||
|
\`;
|
||||||
|
messages.appendChild(resultDiv);
|
||||||
|
});
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
addMessageToUI('assistant', '❌ Error: ' + message.error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'contextAdded':
|
||||||
|
currentContext = message.context;
|
||||||
|
document.getElementById('context-badge').classList.add('active');
|
||||||
|
document.getElementById('context-path').textContent = message.context.path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus input on load
|
||||||
|
document.getElementById('input').focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
@@ -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<vscode.ChatFollowup[]> {
|
||||||
|
// 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<vscode.ChatResult> {
|
||||||
|
|
||||||
|
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<any | undefined> {
|
||||||
|
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<ChatResponse> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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 `
|
||||||
|
<div class="message ${messageClass}">
|
||||||
|
<div class="avatar ${avatarClass}">${avatar}</div>
|
||||||
|
<div class="content">${escapeHtml(m.content)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||||
|
<title>Product OS Chat</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: var(--vscode-editor-background);
|
||||||
|
--bg-secondary: var(--vscode-sideBar-background);
|
||||||
|
--bg-tertiary: var(--vscode-input-background);
|
||||||
|
--accent: var(--vscode-focusBorder);
|
||||||
|
--text-primary: var(--vscode-foreground);
|
||||||
|
--text-secondary: var(--vscode-descriptionForeground);
|
||||||
|
--border: var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
background: var(--vscode-badge-background);
|
||||||
|
color: var(--vscode-badge-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-avatar {
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-badge code {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-close {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 8px 32px 8px 10px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-context-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-context-btn:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover {
|
||||||
|
background: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-status {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-result-status.success { background: var(--vscode-testing-iconPassed); }
|
||||||
|
.tool-result-status.error { background: var(--vscode-testing-iconFailed); }
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="messages" id="messages">
|
||||||
|
${
|
||||||
|
messagesHtml ||
|
||||||
|
`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">✦</div>
|
||||||
|
<h2>Product OS Chat</h2>
|
||||||
|
<p>Deploy, analyze, and create with AI.</p>
|
||||||
|
<div class="suggestions">
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Deploy to staging')">🚀 Deploy to staging</button>
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Show funnel analytics')">📊 Funnel analytics</button>
|
||||||
|
<button class="suggestion" onclick="sendSuggestion('Generate marketing posts')">📣 Marketing posts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="loading-dots">
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span>Thinking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="context-badge" id="context-badge">
|
||||||
|
<span>📎</span>
|
||||||
|
<code id="context-path"></code>
|
||||||
|
<span class="context-close" onclick="clearContext()">✕</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<div class="input-container">
|
||||||
|
<textarea
|
||||||
|
id="input"
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
rows="1"
|
||||||
|
onkeydown="handleKeydown(event)"
|
||||||
|
oninput="autoResize(this)"
|
||||||
|
></textarea>
|
||||||
|
<button class="add-context-btn" onclick="addContext()" title="Add context">@</button>
|
||||||
|
</div>
|
||||||
|
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script nonce="${nonce}">
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
let currentContext = null;
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const input = document.getElementById('input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
vscode.postMessage({ command: 'send', text, context: currentContext });
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
clearContext();
|
||||||
|
addMessageToUI('user', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSuggestion(text) {
|
||||||
|
document.getElementById('input').value = text;
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContext() {
|
||||||
|
vscode.postMessage({ command: 'addContext' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContext() {
|
||||||
|
currentContext = null;
|
||||||
|
document.getElementById('context-badge').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessageToUI(role, content) {
|
||||||
|
const messages = document.getElementById('messages');
|
||||||
|
const isEmpty = messages.querySelector('.empty-state');
|
||||||
|
if (isEmpty) isEmpty.remove();
|
||||||
|
|
||||||
|
const isUser = role === 'user';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
|
||||||
|
div.innerHTML = \`
|
||||||
|
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
|
||||||
|
<div class="content">\${escapeHtml(content)}</div>
|
||||||
|
\`;
|
||||||
|
messages.appendChild(div);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML.replace(/\\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const message = event.data;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'loading':
|
||||||
|
document.getElementById('loading').classList.toggle('active', message.loading);
|
||||||
|
document.getElementById('send-btn').disabled = message.loading;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'response':
|
||||||
|
if (message.message) {
|
||||||
|
addMessageToUI('assistant', message.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.runs && message.runs.length > 0) {
|
||||||
|
const messages = document.getElementById('messages');
|
||||||
|
message.runs.forEach(run => {
|
||||||
|
const resultDiv = document.createElement('div');
|
||||||
|
resultDiv.className = 'tool-result';
|
||||||
|
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
|
||||||
|
resultDiv.innerHTML = \`
|
||||||
|
<div class="tool-result-header">
|
||||||
|
<div class="tool-result-status \${statusClass}"></div>
|
||||||
|
<span>\${run.tool}</span>
|
||||||
|
</div>
|
||||||
|
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
|
||||||
|
\`;
|
||||||
|
messages.appendChild(resultDiv);
|
||||||
|
});
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
addMessageToUI('assistant', '❌ ' + message.error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'contextAdded':
|
||||||
|
currentContext = message.context;
|
||||||
|
document.getElementById('context-badge').classList.add('active');
|
||||||
|
document.getElementById('context-path').textContent = message.context.path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('input').focus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/'/g, "'")
|
||||||
|
.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
176
platform/client-ide/extensions/gcp-productos/src/extension.ts
Normal file
176
platform/client-ide/extensions/gcp-productos/src/extension.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
373
platform/client-ide/extensions/gcp-productos/src/invokePanel.ts
Normal file
373
platform/client-ide/extensions/gcp-productos/src/invokePanel.ts
Normal file
@@ -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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Invoke ${tool.name}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.risk {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.risk-low { background: #2ea043; color: white; }
|
||||||
|
.risk-medium { background: #d29922; color: black; }
|
||||||
|
.risk-high { background: #f85149; color: white; }
|
||||||
|
.description {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 10px;
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
font-size: var(--vscode-editor-font-size);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
textarea:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
}
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.schema {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--vscode-textBlockQuote-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.schema summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.schema pre {
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.result-success {
|
||||||
|
background: var(--vscode-inputValidation-infoBackground);
|
||||||
|
border: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||||
|
}
|
||||||
|
.result-error {
|
||||||
|
background: var(--vscode-inputValidation-errorBackground);
|
||||||
|
border: 1px solid var(--vscode-inputValidation-errorBorder);
|
||||||
|
}
|
||||||
|
.result h3 { margin: 0 0 10px 0; }
|
||||||
|
.result pre {
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--vscode-foreground);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>
|
||||||
|
${tool.name}
|
||||||
|
<span class="risk risk-${tool.risk}">${tool.risk} risk</span>
|
||||||
|
</h1>
|
||||||
|
<p class="description">${tool.description}</p>
|
||||||
|
|
||||||
|
<label for="input">Input JSON</label>
|
||||||
|
<textarea id="input">${defaultInput}</textarea>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn-primary" id="invokeBtn" onclick="invoke(false)">
|
||||||
|
<span id="invokeText">▶ Invoke</span>
|
||||||
|
<span id="invokingText" class="hidden"><span class="spinner"></span>Invoking...</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" onclick="invoke(true)">🧪 Dry Run</button>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="autoFormat" checked>
|
||||||
|
Auto-format JSON
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<details class="schema">
|
||||||
|
<summary>Input Schema</summary>
|
||||||
|
<pre>${schemaStr}</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
const inputEl = document.getElementById('input');
|
||||||
|
const invokeBtn = document.getElementById('invokeBtn');
|
||||||
|
const invokeText = document.getElementById('invokeText');
|
||||||
|
const invokingText = document.getElementById('invokingText');
|
||||||
|
const resultEl = document.getElementById('result');
|
||||||
|
|
||||||
|
inputEl.addEventListener('blur', () => {
|
||||||
|
if (document.getElementById('autoFormat').checked) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inputEl.value);
|
||||||
|
inputEl.value = JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function invoke(dryRun) {
|
||||||
|
vscode.postMessage({
|
||||||
|
command: 'invoke',
|
||||||
|
input: inputEl.value,
|
||||||
|
dryRun
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
const message = event.data;
|
||||||
|
switch (message.command) {
|
||||||
|
case 'invoking':
|
||||||
|
invokeBtn.disabled = true;
|
||||||
|
invokeText.classList.add('hidden');
|
||||||
|
invokingText.classList.remove('hidden');
|
||||||
|
resultEl.innerHTML = '';
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
invokeBtn.disabled = false;
|
||||||
|
invokeText.classList.remove('hidden');
|
||||||
|
invokingText.classList.add('hidden');
|
||||||
|
const run = message.run;
|
||||||
|
const statusEmoji = run.status === 'succeeded' ? '✅' : run.status === 'failed' ? '❌' : '🔄';
|
||||||
|
resultEl.innerHTML = \`
|
||||||
|
<div class="result result-success">
|
||||||
|
<h3>\${statusEmoji} Run \${run.status}</h3>
|
||||||
|
<p><strong>Run ID:</strong> \${run.run_id}</p>
|
||||||
|
<h4>Output:</h4>
|
||||||
|
<pre>\${JSON.stringify(run.output || run.error || {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
invokeBtn.disabled = false;
|
||||||
|
invokeText.classList.remove('hidden');
|
||||||
|
invokingText.classList.add('hidden');
|
||||||
|
resultEl.innerHTML = \`
|
||||||
|
<div class="result result-error">
|
||||||
|
<h3>❌ Error</h3>
|
||||||
|
<pre>\${message.message}</pre>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { Run, getRecentRuns } from "./api";
|
||||||
|
|
||||||
|
export class RunsTreeProvider implements vscode.TreeDataProvider<RunItem> {
|
||||||
|
private _onDidChangeTreeData = new vscode.EventEmitter<RunItem | undefined>();
|
||||||
|
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]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as vscode from "vscode";
|
||||||
|
import { Tool, listTools } from "./api";
|
||||||
|
|
||||||
|
export class ToolsTreeProvider implements vscode.TreeDataProvider<ToolItem> {
|
||||||
|
private _onDidChangeTreeData = new vscode.EventEmitter<ToolItem | undefined>();
|
||||||
|
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||||
|
|
||||||
|
private tools: Tool[] = [];
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTools(): Promise<void> {
|
||||||
|
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<ToolItem[]> {
|
||||||
|
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]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
platform/client-ide/extensions/gcp-productos/src/ui.ts
Normal file
40
platform/client-ide/extensions/gcp-productos/src/ui.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
11
platform/client-ide/extensions/gcp-productos/tsconfig.json
Normal file
11
platform/client-ide/extensions/gcp-productos/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
398
platform/contracts/tool-registry.yaml
Normal file
398
platform/contracts/tool-registry.yaml
Normal file
@@ -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]
|
||||||
41
platform/docker-compose.yml
Normal file
41
platform/docker-compose.yml
Normal file
@@ -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:
|
||||||
143
platform/docs/GETTING_STARTED.md
Normal file
143
platform/docs/GETTING_STARTED.md
Normal file
@@ -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
|
||||||
16
platform/infra/terraform/iam.tf
Normal file
16
platform/infra/terraform/iam.tf
Normal file
@@ -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.
|
||||||
54
platform/infra/terraform/main.tf
Normal file
54
platform/infra/terraform/main.tf
Normal file
@@ -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"
|
||||||
|
}
|
||||||
9
platform/infra/terraform/outputs.tf
Normal file
9
platform/infra/terraform/outputs.tf
Normal file
@@ -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"
|
||||||
|
}
|
||||||
14
platform/infra/terraform/providers.tf
Normal file
14
platform/infra/terraform/providers.tf
Normal file
@@ -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
|
||||||
|
}
|
||||||
4
platform/infra/terraform/terraform.tfvars.example
Normal file
4
platform/infra/terraform/terraform.tfvars.example
Normal file
@@ -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"
|
||||||
20
platform/infra/terraform/variables.tf
Normal file
20
platform/infra/terraform/variables.tf
Normal file
@@ -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)."
|
||||||
|
}
|
||||||
54
platform/scripts/start-all.sh
Normal file
54
platform/scripts/start-all.sh
Normal file
@@ -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
|
||||||
610
technical_spec.md
Normal file
610
technical_spec.md
Normal file
@@ -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.
|
||||||
289
vision-ext.md
Normal file
289
vision-ext.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user