mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 17:16:49 +00:00
Atualizou functions suspend/resume
X-Lovable-Edit-ID: edt-90e6d40e-f2d7-42a3-bf33-8354f6b62c2a Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
a72b4b6624
5 changed files with 103 additions and 68 deletions
10
README.md
10
README.md
|
|
@ -24,6 +24,16 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud):
|
||||||
- `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET`
|
- `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET`
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
- `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.
|
- `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.
|
- `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_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`).
|
- `ADMIN_TELEGRAM_CHAT_ID` — chat ID do admin que recebe as notificações (ex: `179720882`).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
// Docs: https://docs.railway.com/reference/public-api
|
// Docs: https://docs.railway.com/reference/public-api
|
||||||
const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2";
|
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 {
|
export interface RailwayError {
|
||||||
message: string;
|
message: string;
|
||||||
path?: string[];
|
path?: string[];
|
||||||
|
|
@ -92,28 +104,14 @@ export async function configureRailwayService(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agora as variáveis. Railway recomenda variableUpsert por chave.
|
// 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)) {
|
for (const [name, value] of Object.entries(opts.variables)) {
|
||||||
const r = await railwayQuery(
|
await upsertRailwayVariable({
|
||||||
variableUpsert,
|
token: opts.token,
|
||||||
{
|
|
||||||
input: {
|
|
||||||
projectId: undefined, // será inferido pelo serviceId+environmentId
|
|
||||||
environmentId: opts.environmentId,
|
|
||||||
serviceId: opts.serviceId,
|
serviceId: opts.serviceId,
|
||||||
|
environmentId: opts.environmentId,
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
},
|
});
|
||||||
},
|
|
||||||
opts.token,
|
|
||||||
);
|
|
||||||
if (r.errors?.length) {
|
|
||||||
throw new Error(`variableUpsert(${name}) failed: ${JSON.stringify(r.errors)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,46 +136,65 @@ export async function deployRailwayService(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspende ou retoma um serviço Railway usando `sleepApplication`.
|
* Upsert de uma única variável de ambiente no serviço Railway.
|
||||||
* Railway não aceita `numReplicas: 0` via serviceInstanceUpdate — o caminho
|
* Para "remover" o efeito de uma variável boolean, passe value="" (string vazia).
|
||||||
* suportado para pausar é `sleepApplication: true` (e `false` para acordar).
|
|
||||||
* `replicas <= 0` => sleep, `>= 1` => wake.
|
|
||||||
*/
|
*/
|
||||||
export async function setRailwayReplicas(opts: {
|
export async function upsertRailwayVariable(opts: {
|
||||||
token: string;
|
token: string;
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
replicas: number;
|
projectId?: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sleep = opts.replicas <= 0;
|
|
||||||
const mutation = `
|
const mutation = `
|
||||||
mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
|
mutation VariableUpsert($input: VariableUpsertInput!) {
|
||||||
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
|
variableUpsert(input: $input)
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const res = await railwayQuery(
|
const input: Record<string, unknown> = {
|
||||||
mutation,
|
|
||||||
{
|
|
||||||
serviceId: opts.serviceId,
|
|
||||||
environmentId: opts.environmentId,
|
environmentId: opts.environmentId,
|
||||||
input: { sleepApplication: sleep },
|
serviceId: opts.serviceId,
|
||||||
},
|
name: opts.name,
|
||||||
opts.token,
|
value: opts.value,
|
||||||
);
|
};
|
||||||
|
if (opts.projectId) input.projectId = opts.projectId;
|
||||||
|
|
||||||
|
const res = await railwayQuery(mutation, { input }, opts.token);
|
||||||
if (res.errors?.length) {
|
if (res.errors?.length) {
|
||||||
throw new Error(`setRailwayReplicas (sleepApplication=${sleep}) failed: ${JSON.stringify(res.errors)}`);
|
throw new Error(`variableUpsert(${opts.name}) failed: ${JSON.stringify(res.errors)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Força redeploy para aplicar imediatamente o novo estado de sleep
|
/**
|
||||||
try {
|
* 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<void> {
|
||||||
|
await upsertRailwayVariable({
|
||||||
|
token: opts.token,
|
||||||
|
serviceId: opts.serviceId,
|
||||||
|
environmentId: opts.environmentId,
|
||||||
|
projectId: opts.projectId,
|
||||||
|
name: "HERMES_SUSPENDED",
|
||||||
|
value: opts.suspend ? "true" : "",
|
||||||
|
});
|
||||||
|
|
||||||
await deployRailwayService({
|
await deployRailwayService({
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
serviceId: opts.serviceId,
|
serviceId: opts.serviceId,
|
||||||
environmentId: opts.environmentId,
|
environmentId: opts.environmentId,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.warn("setRailwayReplicas: redeploy after sleep change failed (non-fatal):", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Busca o environmentId do primeiro deployment de um serviço. Útil quando vps_pool_id está null. */
|
/** Busca o environmentId do primeiro deployment de um serviço. Útil quando vps_pool_id está null. */
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
configureRailwayService,
|
configureRailwayService,
|
||||||
deployRailwayService,
|
deployRailwayService,
|
||||||
deleteTelegramWebhook,
|
deleteTelegramWebhook,
|
||||||
|
HERMES_START_COMMAND,
|
||||||
} from "../_shared/railway.ts";
|
} from "../_shared/railway.ts";
|
||||||
|
|
||||||
interface RequestBody {
|
interface RequestBody {
|
||||||
|
|
@ -191,7 +192,7 @@ Deno.serve(async (req) => {
|
||||||
serviceId: railwayServiceId,
|
serviceId: railwayServiceId,
|
||||||
environmentId: pool.railway_environment_id,
|
environmentId: pool.railway_environment_id,
|
||||||
image: "nousresearch/hermes-agent:latest",
|
image: "nousresearch/hermes-agent:latest",
|
||||||
startCommand: "/opt/hermes/docker/entrypoint.sh gateway run",
|
startCommand: HERMES_START_COMMAND,
|
||||||
variables: envVars,
|
variables: envVars,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// resume-agent
|
// 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,
|
// Disparado automaticamente quando subscription volta para active/trialing,
|
||||||
// ou manualmente pelo painel admin.
|
// ou manualmente pelo painel admin.
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
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_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
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 });
|
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 environmentId: string | null = null;
|
||||||
|
let projectId: string | undefined;
|
||||||
if (agent.vps_pool_id) {
|
if (agent.vps_pool_id) {
|
||||||
const { data: pool } = await supabase
|
const { data: pool } = await supabase
|
||||||
.from("vps_pool")
|
.from("vps_pool")
|
||||||
.select("railway_environment_id")
|
.select("railway_environment_id, railway_project_id")
|
||||||
.eq("id", agent.vps_pool_id)
|
.eq("id", agent.vps_pool_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
environmentId = pool?.railway_environment_id ?? null;
|
environmentId = pool?.railway_environment_id ?? null;
|
||||||
|
projectId = pool?.railway_project_id ?? undefined;
|
||||||
}
|
}
|
||||||
if (!environmentId) {
|
if (!environmentId) {
|
||||||
environmentId = await getServiceEnvironmentId({
|
environmentId = await getServiceEnvironmentId({
|
||||||
|
|
@ -76,16 +78,17 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setRailwayReplicas({
|
await setHermesSuspended({
|
||||||
token: RAILWAY_API_TOKEN,
|
token: RAILWAY_API_TOKEN,
|
||||||
serviceId: agent.railway_service_id,
|
serviceId: agent.railway_service_id,
|
||||||
environmentId,
|
environmentId,
|
||||||
replicas: 1,
|
projectId,
|
||||||
|
suspend: false,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("resume-agent: setRailwayReplicas failed:", msg);
|
console.error("resume-agent: setHermesSuspended failed:", msg);
|
||||||
return jsonResponse(500, { error: "railway scale-up failed", detail: msg });
|
return jsonResponse(500, { error: "railway resume failed", detail: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// suspend-agent
|
// 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,
|
// Disparado automaticamente quando subscription muda para canceled/past_due/unpaid/paused,
|
||||||
// ou manualmente pelo painel admin.
|
// ou manualmente pelo painel admin.
|
||||||
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { corsHeaders } from "../_shared/cors.ts";
|
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_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
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 });
|
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 environmentId: string | null = null;
|
||||||
|
let projectId: string | undefined;
|
||||||
if (agent.vps_pool_id) {
|
if (agent.vps_pool_id) {
|
||||||
const { data: pool } = await supabase
|
const { data: pool } = await supabase
|
||||||
.from("vps_pool")
|
.from("vps_pool")
|
||||||
.select("railway_environment_id")
|
.select("railway_environment_id, railway_project_id")
|
||||||
.eq("id", agent.vps_pool_id)
|
.eq("id", agent.vps_pool_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
environmentId = pool?.railway_environment_id ?? null;
|
environmentId = pool?.railway_environment_id ?? null;
|
||||||
|
projectId = pool?.railway_project_id ?? undefined;
|
||||||
}
|
}
|
||||||
if (!environmentId) {
|
if (!environmentId) {
|
||||||
environmentId = await getServiceEnvironmentId({
|
environmentId = await getServiceEnvironmentId({
|
||||||
|
|
@ -77,16 +80,17 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setRailwayReplicas({
|
await setHermesSuspended({
|
||||||
token: RAILWAY_API_TOKEN,
|
token: RAILWAY_API_TOKEN,
|
||||||
serviceId: agent.railway_service_id,
|
serviceId: agent.railway_service_id,
|
||||||
environmentId,
|
environmentId,
|
||||||
replicas: 0,
|
projectId,
|
||||||
|
suspend: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("suspend-agent: setRailwayReplicas failed:", msg);
|
console.error("suspend-agent: setHermesSuspended failed:", msg);
|
||||||
return jsonResponse(500, { error: "railway scale-down failed", detail: msg });
|
return jsonResponse(500, { error: "railway suspend failed", detail: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue