diff --git a/supabase/config.toml b/supabase/config.toml index cc369ce..706a29a 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -62,3 +62,6 @@ verify_jwt = false [functions.create-skill-from-agent] verify_jwt = false + +[functions.admin-backfill-runtime] +verify_jwt = false diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts index 7d4cd11..da716ad 100644 --- a/supabase/functions/_shared/railway.ts +++ b/supabase/functions/_shared/railway.ts @@ -134,10 +134,13 @@ export async function deployRailwayService(opts: { environmentId: string; /** Use true on the first deploy of a freshly-created service (no prior deployment exists). */ firstDeploy?: boolean; + /** Use true after changing the configured source image so Railway pulls the current source. */ + fromSource?: boolean; }): Promise { // serviceInstanceRedeploy só funciona se já existe um deployment. - // Para o primeiro deploy de um serviço recém-criado, usamos serviceInstanceDeployV2. - if (opts.firstDeploy) { + // Para o primeiro deploy de um serviço recém-criado, ou após trocar source.image, + // usamos DeployV2 para puxar a fonte/imagem configurada em vez de reusar o último deploy. + if (opts.firstDeploy || opts.fromSource) { const mutation = ` mutation ServiceInstanceDeployV2($serviceId: String!, $environmentId: String!) { serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId) @@ -169,7 +172,7 @@ export async function deployRailwayService(opts: { // Fallback: serviço novo sem deploy anterior — redeploy falha, tenta V2. if (errStr.includes("no deployment") || errStr.includes("No deployments") || errStr.includes("not found")) { console.warn(`[deployRailwayService] redeploy sem deploy anterior, fallback para serviceInstanceDeployV2`); - await deployRailwayService({ ...opts, firstDeploy: true }); + await deployRailwayService({ ...opts, firstDeploy: true, fromSource: false }); return; } throw new Error(`serviceInstanceRedeploy failed: ${errStr}`); diff --git a/supabase/functions/admin-backfill-runtime/index.ts b/supabase/functions/admin-backfill-runtime/index.ts new file mode 100644 index 0000000..ea2843a --- /dev/null +++ b/supabase/functions/admin-backfill-runtime/index.ts @@ -0,0 +1,213 @@ +// admin-backfill-runtime +// Admin-only. Para cada agent_instance com railway_service_id, reaplica: +// - HERMES_RUNTIME_IMAGE (imagem corrigida) +// - API_SERVER_PORT=8765 +// - envs MIKA_* / HERMES_* obrigatórias do contrato runtime +// e dispara redeploy no Railway. NÃO recria serviço, NÃO toca tokens sensíveis +// (TELEGRAM_BOT_TOKEN, OLLAMA_API_KEY etc). Sempre roda em modo dry_run por padrão. +// +// POST body: +// { dry_run?: boolean (default true), agent_instance_id?: string } +// +// Resposta: relatório por instância com status ok|skipped|error. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { authorizeInternalRequest } from "../_shared/internal-auth.ts"; +import { + HERMES_START_COMMAND, + configureRailwayService, + deployRailwayService, + getServiceContext, + upsertRailwayVariableCollection, +} from "../_shared/railway.ts"; + +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 INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; +const HERMES_RUNTIME_IMAGE = + Deno.env.get("HERMES_RUNTIME_IMAGE") ?? "ghcr.io/domfelipe/hermes-agent-custom:latest"; +const MIKA_RUNTIME_CONTRACT_VERSION = "2026-05-28"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function buildBackfillEnv(agentInstanceId: string): Record { + const functionsBaseUrl = `${SUPABASE_URL.replace(/\/$/, "")}/functions/v1`; + const createCronjobUrl = `${functionsBaseUrl}/create-cronjob-from-agent`; + const createSkillUrl = `${functionsBaseUrl}/create-skill-from-agent`; + + return { + AGENT_INSTANCE_ID: agentInstanceId, + API_SERVER_PORT: "8765", + HERMES_AGENT_INSTANCE_ID: agentInstanceId, + HERMES_CREATE_CRONJOB_URL: createCronjobUrl, + HERMES_CREATE_SKILL_URL: createSkillUrl, + HERMES_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + HERMES_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + HERMES_RUNTIME_CONTRACT_VERSION: MIKA_RUNTIME_CONTRACT_VERSION, + INTERNAL_FUNCTION_SECRET, + MIKA_AGENT_INSTANCE_ID: agentInstanceId, + MIKA_CREATE_CRONJOB_URL: createCronjobUrl, + MIKA_CREATE_SKILL_URL: createSkillUrl, + MIKA_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + MIKA_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + MIKA_RUNTIME_CONTRACT_VERSION, + SUPABASE_URL, + }; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + const auth = await authorizeInternalRequest(req, { allowOwner: false }); + if (!auth.ok) { + return jsonResponse(401, { error: "unauthorized", reason: auth.reason }); + } + // Apenas via X-Internal-Secret OU admin JWT + if (!auth.viaSecret && !auth.isAdmin) { + return jsonResponse(403, { error: "admin only" }); + } + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: { dry_run?: boolean; agent_instance_id?: string } = {}; + try { + body = await req.json(); + } catch { + /* allow empty body */ + } + const dryRun = body.dry_run !== false; // default true + const filterId = body.agent_instance_id; + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + let query = supabase + .from("agent_instances") + .select("id, user_id, status, railway_service_id, vps_pool_id, agent_name") + .not("railway_service_id", "is", null) + .in("status", ["active", "provisioning"]); + + if (filterId) query = query.eq("id", filterId); + + const { data: agents, error: agentsErr } = await query; + if (agentsErr) { + return jsonResponse(500, { error: "failed to list agents", detail: agentsErr.message }); + } + + console.log( + `[admin-backfill-runtime] dry_run=${dryRun} target_count=${agents?.length ?? 0} image=${HERMES_RUNTIME_IMAGE}`, + ); + + const report: Array> = []; + + for (const agent of agents ?? []) { + const entry: Record = { + agent_instance_id: agent.id, + railway_service_id: agent.railway_service_id, + agent_name: agent.agent_name, + status: "pending", + }; + + try { + // Resolver project/environment + let projectId: string | null = null; + let environmentId: string | null = null; + + if (agent.vps_pool_id) { + const { data: pool } = await supabase + .from("vps_pool") + .select("railway_project_id, railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + projectId = (pool?.railway_project_id as string | null) ?? null; + environmentId = (pool?.railway_environment_id as string | null) ?? null; + } + + if (!projectId || !environmentId) { + const ctx = await getServiceContext({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + }); + projectId = projectId ?? ctx.projectId; + environmentId = environmentId ?? ctx.environmentId; + } + + if (!projectId || !environmentId) { + entry.status = "error"; + entry.error = "could not resolve railway project/environment"; + report.push(entry); + continue; + } + + const variables = buildBackfillEnv(agent.id); + entry.variables_count = Object.keys(variables).length; + entry.image = HERMES_RUNTIME_IMAGE; + + if (dryRun) { + entry.status = "dry_run"; + entry.would_apply = Object.keys(variables); + report.push(entry); + continue; + } + + // 1) upsert envs (sem disparar deploy ainda) + await upsertRailwayVariableCollection({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + projectId, + variables, + skipDeploys: true, + }); + + // 2) reconciliar imagem + start command + await configureRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + projectId, + image: HERMES_RUNTIME_IMAGE, + variables: {}, + startCommand: HERMES_START_COMMAND, + }); + + // 3) deploy from source para puxar a imagem reconciliada, não o último deploy antigo + await deployRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + fromSource: true, + }); + + entry.status = "ok"; + } catch (e) { + entry.status = "error"; + entry.error = e instanceof Error ? e.message : String(e); + console.error(`[admin-backfill-runtime] erro em ${agent.id}:`, entry.error); + } + + report.push(entry); + } + + const summary = { + dry_run: dryRun, + image: HERMES_RUNTIME_IMAGE, + total: report.length, + ok: report.filter((r) => r.status === "ok").length, + dry: report.filter((r) => r.status === "dry_run").length, + errors: report.filter((r) => r.status === "error").length, + }; + console.log(`[admin-backfill-runtime] summary=${JSON.stringify(summary)}`); + + return jsonResponse(200, { summary, report }); +}); diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index fa78d04..ea059ba 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -641,6 +641,7 @@ async function handleUpdateExistingService( token: RAILWAY_API_TOKEN!, serviceId: railwayServiceId, environmentId, + fromSource: true, }); console.log(`[provision-agent:update] redeploy disparado`); } catch (e) {