diff --git a/README.md b/README.md index fd1eb7d..1f21e85 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud): - `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET` - `SUPABASE_SERVICE_ROLE_KEY` - `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes. + + > **Suspend/Resume via flag, não via stop.** Railway não permite parar containers sem redeploy. As Edge Functions `suspend-agent`/`resume-agent` usam a env var `HERMES_SUSPENDED` + `serviceInstanceRedeploy`: + > - `suspend` → upsert `HERMES_SUSPENDED=true` e redeploy → container entra em `sleep infinity`. + > - `resume` → upsert `HERMES_SUSPENDED=""` e redeploy → container sobe normal. + > + > Isso depende do start command verificar a flag. Novos serviços já são provisionados com o comando correto (`HERMES_START_COMMAND` em `_shared/railway.ts`): + > ```bash + > /bin/bash -c 'if [ "$HERMES_SUSPENDED" = "true" ]; then echo "Agent suspended" && sleep infinity; fi && if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/SOUL.md; fi && /opt/hermes/docker/entrypoint.sh gateway run' + > ``` + > **Para serviços Railway existentes (provisionados antes desta mudança):** atualize manualmente o Start Command via Railway UI/Agent para o comando acima — caso contrário `suspend-agent` apenas marcará `status='suspended'` no banco mas o container continuará rodando. - `OPENROUTER_API_KEY` — **obrigatório**. Injetado em cada container Hermes provisionado para que o agente possa chamar os modelos `openrouter/google/gemma-4-*-it`. Sem isso o `provision-agent` retorna 500. - `ADMIN_TELEGRAM_BOT_TOKEN` — token do bot de admin (ex: `@mika_test2_bot`) usado pelo `payments-webhook` para enviar notificações de novos clientes, falhas de pagamento e cancelamentos. - `ADMIN_TELEGRAM_CHAT_ID` — chat ID do admin que recebe as notificações (ex: `179720882`). diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts index 6de6d77..ee73263 100644 --- a/supabase/functions/_shared/railway.ts +++ b/supabase/functions/_shared/railway.ts @@ -2,6 +2,18 @@ // Docs: https://docs.railway.com/reference/public-api const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2"; +/** + * Start command padrão dos containers Hermes. + * - Verifica HERMES_SUSPENDED no início: se true, dorme infinitamente (agente "pausado") + * - Aplica HERMES_SOUL_OVERRIDE em /opt/data/SOUL.md se presente + * - Inicia o gateway Hermes + * + * IMPORTANTE: este comando deve ser idêntico ao configurado nos serviços Railway + * existentes. Para serviços antigos, atualize manualmente via UI/Agent do Railway. + */ +export const HERMES_START_COMMAND = + `/bin/bash -c 'if [ "$HERMES_SUSPENDED" = "true" ]; then echo "Agent suspended" && sleep infinity; fi && if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/SOUL.md; fi && /opt/hermes/docker/entrypoint.sh gateway run'`; + export interface RailwayError { message: string; path?: string[]; @@ -92,28 +104,14 @@ export async function configureRailwayService(opts: { } // 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)}`); - } + await upsertRailwayVariable({ + token: opts.token, + serviceId: opts.serviceId, + environmentId: opts.environmentId, + name, + value, + }); } } @@ -138,48 +136,67 @@ export async function deployRailwayService(opts: { } /** - * Suspende ou retoma um serviço Railway usando `sleepApplication`. - * Railway não aceita `numReplicas: 0` via serviceInstanceUpdate — o caminho - * suportado para pausar é `sleepApplication: true` (e `false` para acordar). - * `replicas <= 0` => sleep, `>= 1` => wake. + * Upsert de uma única variável de ambiente no serviço Railway. + * Para "remover" o efeito de uma variável boolean, passe value="" (string vazia). */ -export async function setRailwayReplicas(opts: { +export async function upsertRailwayVariable(opts: { token: string; serviceId: string; environmentId: string; - replicas: number; + projectId?: string; + name: string; + value: string; }): Promise { - const sleep = opts.replicas <= 0; const mutation = ` - mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { - serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + mutation VariableUpsert($input: VariableUpsertInput!) { + variableUpsert(input: $input) } `; - const res = await railwayQuery( - mutation, - { - serviceId: opts.serviceId, - environmentId: opts.environmentId, - input: { sleepApplication: sleep }, - }, - opts.token, - ); - if (res.errors?.length) { - throw new Error(`setRailwayReplicas (sleepApplication=${sleep}) failed: ${JSON.stringify(res.errors)}`); - } + const input: Record = { + environmentId: opts.environmentId, + serviceId: opts.serviceId, + name: opts.name, + value: opts.value, + }; + if (opts.projectId) input.projectId = opts.projectId; - // Força redeploy para aplicar imediatamente o novo estado de sleep - try { - await deployRailwayService({ - token: opts.token, - serviceId: opts.serviceId, - environmentId: opts.environmentId, - }); - } catch (e) { - console.warn("setRailwayReplicas: redeploy after sleep change failed (non-fatal):", e); + const res = await railwayQuery(mutation, { input }, opts.token); + if (res.errors?.length) { + throw new Error(`variableUpsert(${opts.name}) failed: ${JSON.stringify(res.errors)}`); } } +/** + * Suspende ou retoma um serviço Hermes via flag HERMES_SUSPENDED + redeploy. + * Railway não suporta stop sem redeploy — a abordagem oficial é controlar + * via env var consumida pelo start command (ver HERMES_START_COMMAND). + * + * suspend=true → seta HERMES_SUSPENDED=true e redeploy (container fica em sleep infinity) + * suspend=false → seta HERMES_SUSPENDED="" e redeploy (container sobe normalmente) + */ +export async function setHermesSuspended(opts: { + token: string; + serviceId: string; + environmentId: string; + projectId?: string; + suspend: boolean; +}): Promise { + await upsertRailwayVariable({ + token: opts.token, + serviceId: opts.serviceId, + environmentId: opts.environmentId, + projectId: opts.projectId, + name: "HERMES_SUSPENDED", + value: opts.suspend ? "true" : "", + }); + + await deployRailwayService({ + token: opts.token, + serviceId: opts.serviceId, + environmentId: opts.environmentId, + }); +} + /** Busca o environmentId do primeiro deployment de um serviço. Útil quando vps_pool_id está null. */ export async function getServiceEnvironmentId(opts: { token: string; diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index 0c1e603..69bca68 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -10,6 +10,7 @@ import { configureRailwayService, deployRailwayService, deleteTelegramWebhook, + HERMES_START_COMMAND, } from "../_shared/railway.ts"; interface RequestBody { @@ -191,7 +192,7 @@ Deno.serve(async (req) => { serviceId: railwayServiceId, environmentId: pool.railway_environment_id, image: "nousresearch/hermes-agent:latest", - startCommand: "/opt/hermes/docker/entrypoint.sh gateway run", + startCommand: HERMES_START_COMMAND, variables: envVars, }); diff --git a/supabase/functions/resume-agent/index.ts b/supabase/functions/resume-agent/index.ts index a50c098..3dca052 100644 --- a/supabase/functions/resume-agent/index.ts +++ b/supabase/functions/resume-agent/index.ts @@ -1,11 +1,11 @@ // resume-agent -// Retoma o serviço Railway (numReplicas=1) e marca agent_instance.status='active'. +// Retoma o serviço Hermes removendo HERMES_SUSPENDED (string vazia) e disparando redeploy. // Disparado automaticamente quando subscription volta para active/trialing, // ou manualmente pelo painel admin. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { corsHeaders } from "../_shared/cors.ts"; -import { setRailwayReplicas, getServiceEnvironmentId } from "../_shared/railway.ts"; +import { setHermesSuspended, getServiceEnvironmentId } from "../_shared/railway.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; @@ -55,15 +55,17 @@ Deno.serve(async (req) => { return jsonResponse(200, { ok: true, already_active: true }); } - // Resolve environmentId: prioriza vps_pool, fallback para query no Railway + // Resolve environmentId/projectId let environmentId: string | null = null; + let projectId: string | undefined; if (agent.vps_pool_id) { const { data: pool } = await supabase .from("vps_pool") - .select("railway_environment_id") + .select("railway_environment_id, railway_project_id") .eq("id", agent.vps_pool_id) .maybeSingle(); environmentId = pool?.railway_environment_id ?? null; + projectId = pool?.railway_project_id ?? undefined; } if (!environmentId) { environmentId = await getServiceEnvironmentId({ @@ -76,16 +78,17 @@ Deno.serve(async (req) => { } try { - await setRailwayReplicas({ + await setHermesSuspended({ token: RAILWAY_API_TOKEN, serviceId: agent.railway_service_id, environmentId, - replicas: 1, + projectId, + suspend: false, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error("resume-agent: setRailwayReplicas failed:", msg); - return jsonResponse(500, { error: "railway scale-up failed", detail: msg }); + console.error("resume-agent: setHermesSuspended failed:", msg); + return jsonResponse(500, { error: "railway resume failed", detail: msg }); } await supabase diff --git a/supabase/functions/suspend-agent/index.ts b/supabase/functions/suspend-agent/index.ts index d53c774..0ea7f1c 100644 --- a/supabase/functions/suspend-agent/index.ts +++ b/supabase/functions/suspend-agent/index.ts @@ -1,11 +1,12 @@ // suspend-agent -// Pausa o serviço Railway (numReplicas=0) e marca agent_instance.status='suspended'. +// Pausa o serviço Hermes setando HERMES_SUSPENDED=true e disparando redeploy. +// O start command verifica essa flag e entra em `sleep infinity` (agente pausado). // Disparado automaticamente quando subscription muda para canceled/past_due/unpaid/paused, // ou manualmente pelo painel admin. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { corsHeaders } from "../_shared/cors.ts"; -import { setRailwayReplicas, getServiceEnvironmentId } from "../_shared/railway.ts"; +import { setHermesSuspended, getServiceEnvironmentId } from "../_shared/railway.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; @@ -56,15 +57,17 @@ Deno.serve(async (req) => { return jsonResponse(200, { ok: true, no_container: true }); } - // Resolve o environmentId: prioriza vps_pool, fallback para query no Railway + // Resolve o environmentId/projectId via vps_pool, fallback Railway query let environmentId: string | null = null; + let projectId: string | undefined; if (agent.vps_pool_id) { const { data: pool } = await supabase .from("vps_pool") - .select("railway_environment_id") + .select("railway_environment_id, railway_project_id") .eq("id", agent.vps_pool_id) .maybeSingle(); environmentId = pool?.railway_environment_id ?? null; + projectId = pool?.railway_project_id ?? undefined; } if (!environmentId) { environmentId = await getServiceEnvironmentId({ @@ -77,16 +80,17 @@ Deno.serve(async (req) => { } try { - await setRailwayReplicas({ + await setHermesSuspended({ token: RAILWAY_API_TOKEN, serviceId: agent.railway_service_id, environmentId, - replicas: 0, + projectId, + suspend: true, }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error("suspend-agent: setRailwayReplicas failed:", msg); - return jsonResponse(500, { error: "railway scale-down failed", detail: msg }); + console.error("suspend-agent: setHermesSuspended failed:", msg); + return jsonResponse(500, { error: "railway suspend failed", detail: msg }); } await supabase