diff --git a/README.md b/README.md index b1df2ad..9004f75 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,88 @@ A view `user_integration_limits` e `user_jobs_limits` usam dados de `user_integr --- +## Fase 5.1 — Provisionamento Railway (parcial: schema + provision-agent + railway-webhook) + +### O que foi entregue nesta etapa + +- **Schema**: + - `vps_pool` (pool de projetos Railway, com `railway_project_id` / `railway_environment_id` placeholders) + - `provisioning_jobs` (state machine: pending/running/retrying/completed/failed, com retry exponencial `attempt²` minutos) + - Acréscimos em `agent_instances`: `railway_service_id`, `vps_pool_id`, `provisioned_at`, `last_health_check_at` + - `vps_host` e `container_name` mantidos como deprecated via comentário + - `user_roles` + enum `app_role` + função `has_role()` (substitui `is_admin` em profiles, mais seguro) + - Trigger `on_agent_instance_provisioning` usando `pg_net` chama `provision-agent` quando uma agent_instance entra em status `provisioning` +- **Edge Functions**: + - `provision-agent` (verify_jwt=false): cria serviço Docker no Railway via GraphQL API, configura variáveis (TELEGRAM_BOT_TOKEN do Vault, OPENCODE_ZEN_API_KEY, etc.) e dispara deploy. Apaga webhook do Telegram antes (Hermes opera em polling). Retry com backoff exponencial até 5 tentativas. + - `railway-webhook` (verify_jwt=false): recebe eventos do Railway. SUCCESS → marca agent como `active`; FAILED/CRASHED → marca como `error`. +- **Helper compartilhado**: `supabase/functions/_shared/railway.ts` + +### Etapas pós-deploy (uma única vez) — **OBRIGATÓRIAS** + +#### 1. Criar conta Railway e workspace + +1. Crie conta em [railway.com](https://railway.com) +2. Crie um Workspace chamado **"Mika Agents"** +3. Dentro dele, crie um Projeto chamado **"hermes-agents-prod"** com environment **"production"** + +#### 2. Gerar Account Token e adicionar como secret + +1. **Account Settings → Tokens → Create Token** (não confundir com Project Token, precisa ser de conta) +2. Copie o token e adicione como secret `RAILWAY_API_TOKEN` em Lovable Cloud → Secrets +3. As Edge Functions já leem `OPENCODE_ZEN_API_KEY` e `OPENCODE_GO_API_KEY` (também precisam estar configurados — já solicitados nesta fase) + +#### 3. Preencher os IDs do Railway na vps_pool + +Pegue o `railway_project_id` na URL do projeto (`railway.com/project/`) e o `railway_environment_id` em **Project → Settings → Environments → production → Copy ID**, e rode via SQL: + +```sql +UPDATE public.vps_pool +SET railway_project_id = '', + railway_environment_id = '' +WHERE name = 'railway-prod-1'; +``` + +#### 4. Configurar webhook do Railway → Lovable Cloud + +No Railway: **Project Settings → Webhooks → Add Webhook** e cole: + +``` +https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/railway-webhook +``` + +Tipo: **Deployment status changes** (ou todos). + +#### 5. Marcar você (Felipe) como admin + +```sql +INSERT INTO public.user_roles (user_id, role) +VALUES ('', 'admin'); +``` + +Pegue seu UUID em **Lovable Cloud → Auth → Users**. + +### O que ainda falta nesta fase (próxima mensagem) + +- ❌ Edge Functions `suspend-agent` e `resume-agent` +- ❌ Botão "Abrir no Telegram" no `/painel` quando `status='active'` +- ❌ Página `/admin` (lista de agentes, ações suspender/reativar, link Railway, contadores) +- ❌ Simplificação do wizard de Telegram (capturar só token, sem configurar webhook) + +### O que **não** está nesta fase (5.1) por design + +- ❌ Sync de skills/cronjobs/MCPs para o container (Fase 5.2) +- ❌ Backup de memória antes de desprovisionamento +- ❌ Múltiplos environments Railway por plano +- ❌ Auto-scaling + +### Notas técnicas + +- Hermes roda em **polling**: o container faz outbound para `api.telegram.org`, sem necessidade de domínio público nem TLS. +- Após `provision-agent` retornar 200, o Railway leva 1–5 min para puxar a imagem Docker e iniciar. Durante esse tempo, `agent_instance.status` permanece `provisioning`. O webhook do Railway notifica quando o deploy fica `SUCCESS`. +- A Edge Function `telegram-webhook` (Fase 3) ainda existe mas será desativada na próxima entrega — o polling do Hermes substitui o webhook do Telegram. + +--- + ## Comandos úteis ```bash @@ -162,3 +244,4 @@ bun dev # dev server (porta 8080) bun run build # build de produção bun run typecheck ``` + diff --git a/bun.lockb b/bun.lockb index ff6bb5c..a417a2e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1104cf5..3fd7c78 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@cloudflare/vite-plugin": "^1.25.5", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.41.1", "@hookform/resolvers": "^5.2.2", "@lovable.dev/cloud-auth-js": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", @@ -49,6 +50,7 @@ "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", + "@tanstack/zod-adapter": "^1.166.9", "@uiw/react-codemirror": "^4.25.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 89b7193..de10033 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -19,6 +19,9 @@ export type Database = { container_name: string | null created_at: string id: string + last_health_check_at: string | null + provisioned_at: string | null + railway_service_id: string | null status: string telegram_bot_token_vault_id: string | null telegram_bot_username: string | null @@ -32,11 +35,15 @@ export type Database = { user_id: string uuid_tenant: string vps_host: string | null + vps_pool_id: string | null } Insert: { container_name?: string | null created_at?: string id?: string + last_health_check_at?: string | null + provisioned_at?: string | null + railway_service_id?: string | null status?: string telegram_bot_token_vault_id?: string | null telegram_bot_username?: string | null @@ -50,11 +57,15 @@ export type Database = { user_id: string uuid_tenant?: string vps_host?: string | null + vps_pool_id?: string | null } Update: { container_name?: string | null created_at?: string id?: string + last_health_check_at?: string | null + provisioned_at?: string | null + railway_service_id?: string | null status?: string telegram_bot_token_vault_id?: string | null telegram_bot_username?: string | null @@ -68,6 +79,7 @@ export type Database = { user_id?: string uuid_tenant?: string vps_host?: string | null + vps_pool_id?: string | null } Relationships: [ { @@ -98,6 +110,13 @@ export type Database = { referencedRelation: "user_skill_limits" referencedColumns: ["user_id"] }, + { + foreignKeyName: "agent_instances_vps_pool_id_fkey" + columns: ["vps_pool_id"] + isOneToOne: false + referencedRelation: "vps_pool" + referencedColumns: ["id"] + }, ] } available_mcps: { @@ -351,6 +370,75 @@ export type Database = { } Relationships: [] } + provisioning_jobs: { + Row: { + agent_instance_id: string + attempt: number + completed_at: string | null + created_at: string + error_message: string | null + id: string + max_attempts: number + next_retry_at: string | null + payload: Json | null + railway_service_id: string | null + started_at: string | null + status: string + updated_at: string + user_id: string + vps_pool_id: string | null + } + Insert: { + agent_instance_id: string + attempt?: number + completed_at?: string | null + created_at?: string + error_message?: string | null + id?: string + max_attempts?: number + next_retry_at?: string | null + payload?: Json | null + railway_service_id?: string | null + started_at?: string | null + status?: string + updated_at?: string + user_id: string + vps_pool_id?: string | null + } + Update: { + agent_instance_id?: string + attempt?: number + completed_at?: string | null + created_at?: string + error_message?: string | null + id?: string + max_attempts?: number + next_retry_at?: string | null + payload?: Json | null + railway_service_id?: string | null + started_at?: string | null + status?: string + updated_at?: string + user_id?: string + vps_pool_id?: string | null + } + Relationships: [ + { + foreignKeyName: "provisioning_jobs_agent_instance_id_fkey" + columns: ["agent_instance_id"] + isOneToOne: false + referencedRelation: "agent_instances" + referencedColumns: ["id"] + }, + { + foreignKeyName: "provisioning_jobs_vps_pool_id_fkey" + columns: ["vps_pool_id"] + isOneToOne: false + referencedRelation: "vps_pool" + referencedColumns: ["id"] + }, + ] + } scheduled_jobs: { Row: { action_prompt: string @@ -924,6 +1012,69 @@ export type Database = { }, ] } + user_roles: { + Row: { + created_at: string + id: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Insert: { + created_at?: string + id?: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Update: { + created_at?: string + id?: string + role?: Database["public"]["Enums"]["app_role"] + user_id?: string + } + Relationships: [] + } + vps_pool: { + Row: { + capacity_current: number + capacity_max: number + created_at: string + id: string + is_active: boolean + name: string + notes: string | null + railway_environment_id: string | null + railway_project_id: string | null + region: string + updated_at: string + } + Insert: { + capacity_current?: number + capacity_max?: number + created_at?: string + id?: string + is_active?: boolean + name: string + notes?: string | null + railway_environment_id?: string | null + railway_project_id?: string | null + region?: string + updated_at?: string + } + Update: { + capacity_current?: number + capacity_max?: number + created_at?: string + id?: string + is_active?: boolean + name?: string + notes?: string | null + railway_environment_id?: string | null + railway_project_id?: string | null + region?: string + updated_at?: string + } + Relationships: [] + } } Views: { user_integration_limits: { @@ -959,6 +1110,13 @@ export type Database = { Args: { check_env?: string; user_uuid: string } Returns: boolean } + has_role: { + Args: { + _role: Database["public"]["Enums"]["app_role"] + _user_id: string + } + Returns: boolean + } vault_create_secret: { Args: { secret_description?: string @@ -978,7 +1136,7 @@ export type Database = { vault_delete_secret: { Args: { secret_id: string }; Returns: undefined } } Enums: { - [_ in never]: never + app_role: "admin" | "support" | "user" } CompositeTypes: { [_ in never]: never @@ -1105,6 +1263,8 @@ export type CompositeTypes< export const Constants = { public: { - Enums: {}, + Enums: { + app_role: ["admin", "support", "user"], + }, }, } as const diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b32d2a8..9621d9d 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -496,12 +496,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/src/routes/painel.index.tsx b/src/routes/painel.index.tsx index f94fa1b..c849fbd 100644 --- a/src/routes/painel.index.tsx +++ b/src/routes/painel.index.tsx @@ -1,6 +1,8 @@ "use client"; import { createFileRoute, Link } from "@tanstack/react-router"; +import { zodValidator, fallback } from "@tanstack/zod-adapter"; +import { z } from "zod"; import { useEffect, useState } from "react"; import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react"; import { useSubscription } from "@/hooks/use-profile"; @@ -17,10 +19,12 @@ import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnb import { toast } from "sonner"; import { cn } from "@/lib/utils"; +const dashboardSearchSchema = z.object({ + status: fallback(z.string().optional(), undefined), +}); + export const Route = createFileRoute("/painel/")({ - validateSearch: (search: Record) => ({ - status: typeof search.status === "string" ? (search.status as string) : undefined, - }), + validateSearch: zodValidator(dashboardSearchSchema), component: DashboardPage, }); @@ -36,11 +40,11 @@ function DashboardPage() { useEffect(() => { if (search.status !== "success" || !agent) return; if (agent.status === "suspended" || agent.status === "error") { - navigate({ search: {}, replace: true }); + navigate({ search: { status: undefined }, replace: true }); return; } if (!agent.telegram_bot_username) setWizardOpen(true); - navigate({ search: {}, replace: true }); + navigate({ search: { status: undefined }, replace: true }); }, [search.status, agent, navigate]); useEffect(() => { diff --git a/src/routes/painel.integracoes.index.tsx b/src/routes/painel.integracoes.index.tsx index 98afa59..6ff9dce 100644 --- a/src/routes/painel.integracoes.index.tsx +++ b/src/routes/painel.integracoes.index.tsx @@ -1,6 +1,8 @@ "use client"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { zodValidator, fallback } from "@tanstack/zod-adapter"; +import { z } from "zod"; import { Plug } from "lucide-react"; import { useEffect } from "react"; import { toast } from "sonner"; @@ -9,12 +11,14 @@ import { useIntegrationCards } from "@/hooks/use-integrations"; import { useAgentInstance } from "@/hooks/use-agent-instance"; import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard"; +const integracoesSearchSchema = z.object({ + status: fallback(z.string().optional(), undefined), + error: fallback(z.string().optional(), undefined), + mcp: fallback(z.string().optional(), undefined), +}); + export const Route = createFileRoute("/painel/integracoes/")({ - validateSearch: (search: Record) => ({ - status: typeof search.status === "string" ? search.status : undefined, - error: typeof search.error === "string" ? search.error : undefined, - mcp: typeof search.mcp === "string" ? search.mcp : undefined, - }), + validateSearch: zodValidator(integracoesSearchSchema), component: IntegracoesPage, }); @@ -31,10 +35,10 @@ function IntegracoesPage() { toast.success(`${search.mcp} conectado com sucesso!`); queryClient.invalidateQueries({ queryKey: ["user-integrations"] }); queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] }); - navigate({ to: "/painel/integracoes", search: {}, replace: true }); + navigate({ to: "/painel/integracoes", search: { status: undefined, error: undefined, mcp: undefined }, replace: true }); } else if (search.error) { toast.error(`Erro ao conectar: ${search.error}`); - navigate({ to: "/painel/integracoes", search: {}, replace: true }); + navigate({ to: "/painel/integracoes", search: { status: undefined, error: undefined, mcp: undefined }, replace: true }); } }, [search.status, search.error, search.mcp, navigate, queryClient]); diff --git a/supabase/config.toml b/supabase/config.toml index 19fbbc2..f8e38f1 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -44,3 +44,9 @@ verify_jwt = true [functions.parse-cronjob-natural-language] verify_jwt = true + +[functions.provision-agent] +verify_jwt = false + +[functions.railway-webhook] +verify_jwt = false diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts new file mode 100644 index 0000000..a81df2f --- /dev/null +++ b/supabase/functions/_shared/railway.ts @@ -0,0 +1,163 @@ +// Helper para chamar a Railway GraphQL API (Public API). +// Docs: https://docs.railway.com/reference/public-api +const RAILWAY_GRAPHQL = "https://backboard.railway.com/graphql/v2"; + +export interface RailwayError { + message: string; + path?: string[]; + extensions?: Record; +} + +export async function railwayQuery( + query: string, + variables: Record, + token: string, +): Promise<{ data?: T; errors?: RailwayError[] }> { + const res = await fetch(RAILWAY_GRAPHQL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Railway HTTP ${res.status}: ${text}`); + } + + return res.json(); +} + +export async function createRailwayService(opts: { + token: string; + projectId: string; + name: string; +}): Promise { + const mutation = ` + mutation ServiceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { id name } + } + `; + const res = await railwayQuery<{ serviceCreate: { id: string; name: string } }>( + mutation, + { input: { projectId: opts.projectId, name: opts.name } }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`serviceCreate failed: ${JSON.stringify(res.errors)}`); + } + if (!res.data?.serviceCreate?.id) { + throw new Error("serviceCreate returned no id"); + } + return res.data.serviceCreate.id; +} + +export async function configureRailwayService(opts: { + token: string; + serviceId: string; + environmentId: string; + image: string; + variables: Record; +}): Promise { + // O Railway expõe variáveis via variableUpsert (uma por vez) e fonte/imagem via serviceInstanceUpdate. + // Setamos a imagem primeiro. + const updateSource = ` + mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + } + `; + const sourceRes = await railwayQuery( + updateSource, + { + serviceId: opts.serviceId, + environmentId: opts.environmentId, + input: { source: { image: opts.image } }, + }, + opts.token, + ); + if (sourceRes.errors?.length) { + throw new Error(`serviceInstanceUpdate (source) failed: ${JSON.stringify(sourceRes.errors)}`); + } + + // Agora as variáveis. Railway recomenda variableUpsert por chave. + const variableUpsert = ` + mutation VariableUpsert($input: VariableUpsertInput!) { + variableUpsert(input: $input) + } + `; + for (const [name, value] of Object.entries(opts.variables)) { + const r = await railwayQuery( + variableUpsert, + { + input: { + projectId: undefined, // será inferido pelo serviceId+environmentId + environmentId: opts.environmentId, + serviceId: opts.serviceId, + name, + value, + }, + }, + opts.token, + ); + if (r.errors?.length) { + throw new Error(`variableUpsert(${name}) failed: ${JSON.stringify(r.errors)}`); + } + } +} + +export async function deployRailwayService(opts: { + token: string; + serviceId: string; + environmentId: string; +}): Promise { + const mutation = ` + mutation ServiceInstanceRedeploy($serviceId: String!, $environmentId: String!) { + serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) + } + `; + const res = await railwayQuery( + mutation, + { serviceId: opts.serviceId, environmentId: opts.environmentId }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`serviceInstanceRedeploy failed: ${JSON.stringify(res.errors)}`); + } +} + +export async function setRailwayReplicas(opts: { + token: string; + serviceId: string; + environmentId: string; + replicas: number; +}): Promise { + const mutation = ` + mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + } + `; + const res = await railwayQuery( + mutation, + { + serviceId: opts.serviceId, + environmentId: opts.environmentId, + input: { numReplicas: opts.replicas }, + }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`setRailwayReplicas failed: ${JSON.stringify(res.errors)}`); + } +} + +/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */ +export async function deleteTelegramWebhook(botToken: string): Promise { + const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`; + const res = await fetch(url, { method: "POST" }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`deleteWebhook failed: ${res.status} ${text}`); + } +} diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts new file mode 100644 index 0000000..94b19c1 --- /dev/null +++ b/supabase/functions/provision-agent/index.ts @@ -0,0 +1,239 @@ +// provision-agent +// Cria um serviço Docker no Railway para um agent_instance que entrou em status='provisioning'. +// Chamado automaticamente pelo trigger pg_net OU manualmente pelo painel admin. +// verify_jwt = false: o trigger pg_net usa anon key como Bearer, sem JWT de usuário. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { + createRailwayService, + configureRailwayService, + deployRailwayService, + deleteTelegramWebhook, +} from "../_shared/railway.ts"; + +interface RequestBody { + agent_instance_id: string; +} + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN"); +const OPENCODE_ZEN_API_KEY = Deno.env.get("OPENCODE_ZEN_API_KEY") ?? ""; +const OPENCODE_GO_API_KEY = Deno.env.get("OPENCODE_GO_API_KEY") ?? ""; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // 1) Carregar agent_instance + profile + const { data: agent, error: agentErr } = await supabase + .from("agent_instances") + .select( + "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, railway_service_id", + ) + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (agentErr || !agent) { + return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message }); + } + + if (agent.status !== "provisioning") { + return jsonResponse(409, { error: "agent_instance is not in provisioning status", status: agent.status }); + } + + if (agent.railway_service_id) { + return jsonResponse(409, { error: "agent_instance already has a railway_service_id", railway_service_id: agent.railway_service_id }); + } + + // 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade) + const { data: pool, error: poolErr } = await supabase + .from("vps_pool") + .select("id, railway_project_id, railway_environment_id, capacity_max, capacity_current") + .eq("is_active", true) + .neq("railway_project_id", "PREENCHER_APOS_CRIAR_NO_RAILWAY") + .lt("capacity_current", 10000) + .order("capacity_current", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (poolErr || !pool || !pool.railway_project_id || !pool.railway_environment_id) { + await failJob(supabase, agent, null, "Nenhum vps_pool com Railway IDs configurados disponível"); + return jsonResponse(503, { error: "no railway pool available" }); + } + + // 3) Criar provisioning_job em status running + const { data: job, error: jobErr } = await supabase + .from("provisioning_jobs") + .insert({ + agent_instance_id: agent.id, + user_id: agent.user_id, + vps_pool_id: pool.id, + status: "running", + attempt: 1, + started_at: new Date().toISOString(), + payload: { uuid_tenant: agent.uuid_tenant, telegram_bot_username: agent.telegram_bot_username }, + }) + .select("id") + .single(); + + if (jobErr || !job) { + return jsonResponse(500, { error: "failed to create provisioning_job", detail: jobErr?.message }); + } + + // 4) Decrypt do telegram_bot_token (se existir) + let telegramToken = ""; + if (agent.telegram_bot_token_vault_id) { + const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + telegramToken = secret?.[0]?.decrypted_secret ?? ""; + } + + if (!telegramToken) { + await failJob(supabase, agent, job.id, "telegram_bot_token ausente no Vault — usuário precisa concluir onboarding antes"); + return jsonResponse(412, { error: "telegram token missing" }); + } + + // 5) Apagar webhook Telegram (Hermes vai usar polling) + try { + await deleteTelegramWebhook(telegramToken); + } catch (e) { + console.warn("deleteTelegramWebhook failed (continuing):", String(e)); + } + + // 6) Criar serviço no Railway + const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`; + let railwayServiceId: string; + + try { + railwayServiceId = await createRailwayService({ + token: RAILWAY_API_TOKEN, + projectId: pool.railway_project_id, + name: serviceName, + }); + + await configureRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: railwayServiceId, + environmentId: pool.railway_environment_id, + image: "nousresearch/hermes-agent:latest", + variables: { + TELEGRAM_BOT_TOKEN: telegramToken, + TELEGRAM_ALLOWED_USERS: "", + API_SERVER_ENABLED: "false", + HERMES_HOME: "/root/.hermes", + MAIN_MODEL_PROVIDER: "opencode-zen", + OPENCODE_ZEN_API_KEY, + OPENCODE_GO_API_KEY, + HERMES_GATEWAY_CMD: "true", + }, + }); + + await deployRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: railwayServiceId, + environmentId: pool.railway_environment_id, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Railway provisioning failed:", msg); + await scheduleRetry(supabase, agent, job.id, msg); + return jsonResponse(500, { error: "railway provisioning failed", detail: msg }); + } + + // 7) Persistir railway_service_id no agent_instance e no job + await supabase + .from("agent_instances") + .update({ railway_service_id: railwayServiceId, vps_pool_id: pool.id }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ railway_service_id: railwayServiceId }) + .eq("id", job.id); + + // status permanece 'provisioning' — o railway-webhook atualiza para 'active' quando o deploy subir + return jsonResponse(200, { + success: true, + agent_instance_id: agent.id, + railway_service_id: railwayServiceId, + job_id: job.id, + }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function failJob( + supabase: ReturnType, + agent: { id: string }, + jobId: string | null, + message: string, +) { + if (jobId) { + await supabase + .from("provisioning_jobs") + .update({ status: "failed", error_message: message, completed_at: new Date().toISOString() }) + .eq("id", jobId); + } + await supabase.from("agent_instances").update({ status: "error" }).eq("id", agent.id); +} + +async function scheduleRetry( + supabase: ReturnType, + agent: { id: string }, + jobId: string, + message: string, +) { + // Lê a tentativa atual + const { data: job } = await supabase + .from("provisioning_jobs") + .select("attempt, max_attempts") + .eq("id", jobId) + .single(); + + const attempt = job?.attempt ?? 1; + const max = job?.max_attempts ?? 5; + + if (attempt >= max) { + await failJob(supabase, agent, jobId, `Max attempts reached. Last error: ${message}`); + return; + } + + const nextDelayMs = Math.pow(attempt, 2) * 60_000; // attempt^2 minutos + const nextRetryAt = new Date(Date.now() + nextDelayMs).toISOString(); + + await supabase + .from("provisioning_jobs") + .update({ + status: "retrying", + attempt: attempt + 1, + error_message: message, + next_retry_at: nextRetryAt, + }) + .eq("id", jobId); +} diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts new file mode 100644 index 0000000..78f96ef --- /dev/null +++ b/supabase/functions/railway-webhook/index.ts @@ -0,0 +1,116 @@ +// railway-webhook (público) +// Recebe eventos do Railway (Project Settings → Webhooks) sobre deploys. +// Quando um deployment SUCCESS bate em um railway_service_id que conhecemos, +// marcamos o agent_instance como 'active'. +// +// Payload Railway (resumido): +// { type: "DEPLOY", deployment: { id, status, serviceId, environmentId, ... }, project, ... } +// Status possíveis: BUILDING, DEPLOYING, SUCCESS, FAILED, CRASHED, REMOVED + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + +interface RailwayWebhookPayload { + type?: string; + deployment?: { + id?: string; + status?: string; + serviceId?: string; + environmentId?: string; + }; + // Railway envia variantes; aceitamos serviceId/serviço em vários lugares + service?: { id?: string }; + status?: string; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + if (req.method !== "POST") { + return jsonResponse(405, { error: "method not allowed" }); + } + + let payload: RailwayWebhookPayload; + try { + payload = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json" }); + } + + const serviceId = + payload.deployment?.serviceId ?? payload.service?.id ?? null; + const status = payload.deployment?.status ?? payload.status ?? null; + + if (!serviceId || !status) { + console.log("railway-webhook: payload sem serviceId/status — ignorando", payload); + return jsonResponse(200, { ignored: true }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: agent } = await supabase + .from("agent_instances") + .select("id, status") + .eq("railway_service_id", serviceId) + .maybeSingle(); + + if (!agent) { + console.log(`railway-webhook: serviceId ${serviceId} não corresponde a nenhum agent_instance`); + return jsonResponse(200, { ignored: true }); + } + + const now = new Date().toISOString(); + const upper = status.toUpperCase(); + + if (upper === "SUCCESS") { + await supabase + .from("agent_instances") + .update({ + status: "active", + provisioned_at: now, + last_health_check_at: now, + }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ status: "completed", completed_at: now }) + .eq("agent_instance_id", agent.id) + .in("status", ["running", "retrying"]); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" }); + } + + if (upper === "FAILED" || upper === "CRASHED") { + await supabase + .from("agent_instances") + .update({ status: "error" }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ + status: "failed", + error_message: `Railway deployment ${upper}`, + completed_at: now, + }) + .eq("agent_instance_id", agent.id) + .in("status", ["running", "retrying"]); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "error" }); + } + + // BUILDING / DEPLOYING / outros — apenas log + return jsonResponse(200, { ok: true, ignored_status: upper }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} diff --git a/supabase/migrations/20260422143405_18250a17-c2c8-45f8-b058-92506a1f0053.sql b/supabase/migrations/20260422143405_18250a17-c2c8-45f8-b058-92506a1f0053.sql new file mode 100644 index 0000000..a22e930 --- /dev/null +++ b/supabase/migrations/20260422143405_18250a17-c2c8-45f8-b058-92506a1f0053.sql @@ -0,0 +1,200 @@ +-- ========================================================================= +-- Fase 5.1 — Provisionamento Railway +-- ========================================================================= + +-- 1) Enum de roles + tabela user_roles + has_role() +CREATE TYPE public.app_role AS ENUM ('admin', 'support', 'user'); + +CREATE TABLE public.user_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role public.app_role NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, role) +); + +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.has_role(_user_id uuid, _role public.app_role) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = _user_id AND role = _role + ) +$$; + +CREATE POLICY "Usuários veem suas próprias roles" + ON public.user_roles FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Admins veem todas as roles" + ON public.user_roles FOR SELECT + USING (public.has_role(auth.uid(), 'admin')); + +CREATE POLICY "Admins gerenciam roles" + ON public.user_roles FOR ALL + USING (public.has_role(auth.uid(), 'admin')) + WITH CHECK (public.has_role(auth.uid(), 'admin')); + +-- 2) vps_pool — pool de projetos Railway disponíveis para alocar serviços +CREATE TABLE public.vps_pool ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + railway_project_id text, + railway_environment_id text, + region text NOT NULL DEFAULT 'us-west', + capacity_max int NOT NULL DEFAULT 100, + capacity_current int NOT NULL DEFAULT 0, + is_active boolean NOT NULL DEFAULT true, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.vps_pool ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Admins gerenciam vps_pool" + ON public.vps_pool FOR ALL + USING (public.has_role(auth.uid(), 'admin')) + WITH CHECK (public.has_role(auth.uid(), 'admin')); + +CREATE TRIGGER update_vps_pool_updated_at + BEFORE UPDATE ON public.vps_pool + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Seed inicial — admin precisa preencher os IDs do Railway +INSERT INTO public.vps_pool (name, railway_project_id, railway_environment_id, region, capacity_max, notes) +VALUES ( + 'railway-prod-1', + 'PREENCHER_APOS_CRIAR_NO_RAILWAY', + 'PREENCHER_APOS_CRIAR_NO_RAILWAY', + 'us-west', + 100, + 'Pool inicial. Admin: criar projeto no Railway e atualizar railway_project_id e railway_environment_id via SQL.' +); + +-- 3) Acréscimos em agent_instances para Railway +ALTER TABLE public.agent_instances + ADD COLUMN IF NOT EXISTS railway_service_id text, + ADD COLUMN IF NOT EXISTS vps_pool_id uuid REFERENCES public.vps_pool(id), + ADD COLUMN IF NOT EXISTS provisioned_at timestamptz, + ADD COLUMN IF NOT EXISTS last_health_check_at timestamptz; + +-- vps_host e container_name ficam no schema (compat) mas marcamos como deprecated via comment +COMMENT ON COLUMN public.agent_instances.vps_host IS 'DEPRECATED — substituído por vps_pool_id + railway_service_id'; +COMMENT ON COLUMN public.agent_instances.container_name IS 'DEPRECATED — substituído por railway_service_id'; + +-- 4) provisioning_jobs — log/state machine de cada tentativa de provisionamento +CREATE TABLE public.provisioning_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_instance_id uuid NOT NULL REFERENCES public.agent_instances(id) ON DELETE CASCADE, + user_id uuid NOT NULL, + vps_pool_id uuid REFERENCES public.vps_pool(id), + railway_service_id text, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'retrying', 'completed', 'failed')), + attempt int NOT NULL DEFAULT 1, + max_attempts int NOT NULL DEFAULT 5, + error_message text, + payload jsonb, + started_at timestamptz, + completed_at timestamptz, + next_retry_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.provisioning_jobs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Usuários veem seus próprios provisioning jobs" + ON public.provisioning_jobs FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Admins veem todos os jobs" + ON public.provisioning_jobs FOR SELECT + USING (public.has_role(auth.uid(), 'admin')); + +CREATE INDEX idx_provisioning_jobs_agent_status ON public.provisioning_jobs(agent_instance_id, status); +CREATE INDEX idx_provisioning_jobs_retry ON public.provisioning_jobs(status, next_retry_at) WHERE status = 'retrying'; + +CREATE TRIGGER update_provisioning_jobs_updated_at + BEFORE UPDATE ON public.provisioning_jobs + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- 5) Extensão pg_net + trigger automático que dispara provision-agent +CREATE EXTENSION IF NOT EXISTS pg_net; + +CREATE OR REPLACE FUNCTION public.trigger_provision_agent() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $$ +DECLARE + v_supabase_url text; + v_anon_key text; +BEGIN + -- Só dispara em INSERT com status='provisioning' ou em UPDATE que entra nesse status + IF NEW.status <> 'provisioning' THEN + RETURN NEW; + END IF; + + IF TG_OP = 'UPDATE' AND OLD.status = 'provisioning' THEN + RETURN NEW; + END IF; + + -- Lê config do projeto via vault (caímos no fallback se não existir) + BEGIN + SELECT decrypted_secret INTO v_supabase_url + FROM vault.decrypted_secrets WHERE name = 'project_url' LIMIT 1; + SELECT decrypted_secret INTO v_anon_key + FROM vault.decrypted_secrets WHERE name = 'anon_key' LIMIT 1; + EXCEPTION WHEN OTHERS THEN + v_supabase_url := NULL; + END; + + -- Sem config completa, não tenta — admin pode disparar manualmente via /admin + IF v_supabase_url IS NULL OR v_anon_key IS NULL THEN + RAISE LOG 'trigger_provision_agent: vault.project_url ou vault.anon_key não configurado, pulando'; + RETURN NEW; + END IF; + + PERFORM net.http_post( + url := v_supabase_url || '/functions/v1/provision-agent', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || v_anon_key + ), + body := jsonb_build_object('agent_instance_id', NEW.id) + ); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER on_agent_instance_provisioning + AFTER INSERT OR UPDATE OF status ON public.agent_instances + FOR EACH ROW + EXECUTE FUNCTION public.trigger_provision_agent(); + +-- Salvar URL e anon key do projeto no vault para o trigger usar +SELECT vault.create_secret('https://smsarmgoirlcedmqvdgc.supabase.co', 'project_url', 'URL do projeto Supabase usada pelo trigger pg_net'); + +-- 6) Ajustar policy de service_role poder gravar em agent_instances e provisioning_jobs +-- (as edge functions usam service_role; já bypassam RLS, mas precisamos garantir UPDATE) +CREATE POLICY "Service role gerencia agent_instances" + ON public.agent_instances FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Service role gerencia provisioning_jobs" + ON public.provisioning_jobs FOR ALL + TO service_role + USING (true) + WITH CHECK (true);