Go-live runtime sync and control plane hardening (#1)

* Implement Mika runtime sync and go-live controls

* Add CI validation workflow

* Align CI with validated runtime checks

* Fix Mika CI install workflow
This commit is contained in:
Felipe Domingues 2026-04-30 20:41:06 -03:00 committed by GitHub
parent cb54fa4666
commit d87ea2657c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 4019 additions and 95 deletions

View file

@ -0,0 +1,23 @@
export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud";
export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud";
const LEGACY_MODEL_ALIASES: Record<string, string> = {
"openrouter/google/gemma-4-27b-a4b-it": DEFAULT_OLLAMA_MODEL,
"openrouter/google/gemma-4-31b-it": DEFAULT_OLLAMA_MODEL,
"ollama-cloud/gemma4:31b-cloud": DEFAULT_OLLAMA_MODEL,
};
export function normalizeOllamaModelSelection(value?: string | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_OLLAMA_MODEL;
const mapped = LEGACY_MODEL_ALIASES[trimmed];
if (mapped) return mapped;
if (trimmed.includes("/") && trimmed.includes(":")) {
const candidate = trimmed.split("/").pop()?.trim();
if (candidate) return candidate;
}
return trimmed;
}

View file

@ -4,15 +4,13 @@ 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
* - Encaminha para o entrypoint custom da imagem `hermes-agent-custom`
* - O próprio entrypoint aplica SOUL.md, model/provider, STT/TTS e suspensão
*
* 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 const HERMES_START_COMMAND = `/opt/hermes-custom/entrypoint.sh`;
export interface RailwayError {
message: string;
@ -308,6 +306,151 @@ export async function getServiceEnvironmentId(opts: {
return (await getServiceContext(opts)).environmentId;
}
export interface RailwayServiceDomainInfo {
id: string;
domain: string;
suffix?: string | null;
certificateStatus?: string | null;
}
export async function listRailwayServiceDomains(opts: {
token: string;
serviceId: string;
environmentId: string;
projectId?: string | null;
}): Promise<{ serviceDomains: RailwayServiceDomainInfo[]; customDomains: RailwayServiceDomainInfo[] }> {
const query = `
query Domains($environmentId: String!, $serviceId: String!, $projectId: String) {
domains(environmentId: $environmentId, serviceId: $serviceId, projectId: $projectId) {
serviceDomains {
id
domain
suffix
}
customDomains {
id
domain
status {
certificateStatus
}
}
}
}
`;
const res = await railwayQuery<{
domains: {
serviceDomains?: { id: string; domain: string; suffix?: string | null }[];
customDomains?: { id: string; domain: string; status?: { certificateStatus?: string | null } | null }[];
};
}>(
query,
{
environmentId: opts.environmentId,
serviceId: opts.serviceId,
projectId: opts.projectId ?? null,
},
opts.token,
);
if (res.errors?.length) {
throw new Error(`domains query failed: ${JSON.stringify(res.errors)}`);
}
const serviceDomains = (res.data?.domains?.serviceDomains ?? []).map((item) => ({
id: item.id,
domain: item.domain,
suffix: item.suffix ?? null,
certificateStatus: "ISSUED",
}));
const customDomains = (res.data?.domains?.customDomains ?? []).map((item) => ({
id: item.id,
domain: item.domain,
certificateStatus: item.status?.certificateStatus ?? null,
}));
return { serviceDomains, customDomains };
}
export async function createRailwayServiceDomain(opts: {
token: string;
serviceId: string;
environmentId: string;
targetPort?: number;
}): Promise<RailwayServiceDomainInfo> {
const mutation = `
mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) {
serviceDomainCreate(input: $input) {
id
domain
suffix
}
}
`;
const input: Record<string, unknown> = {
serviceId: opts.serviceId,
environmentId: opts.environmentId,
};
if (opts.targetPort !== undefined) {
input.targetPort = opts.targetPort;
}
const res = await railwayQuery<{
serviceDomainCreate: { id: string; domain: string; suffix?: string | null };
}>(mutation, { input }, opts.token);
if (res.errors?.length) {
throw new Error(`serviceDomainCreate failed: ${JSON.stringify(res.errors)}`);
}
const created = res.data?.serviceDomainCreate;
if (!created?.domain) {
throw new Error("serviceDomainCreate returned no domain");
}
return {
id: created.id,
domain: created.domain,
suffix: created.suffix ?? null,
certificateStatus: "ISSUED",
};
}
export async function ensureRailwayServiceDomain(opts: {
token: string;
serviceId: string;
environmentId: string;
projectId?: string | null;
targetPort?: number;
}): Promise<RailwayServiceDomainInfo> {
const existing = await listRailwayServiceDomains({
token: opts.token,
serviceId: opts.serviceId,
environmentId: opts.environmentId,
projectId: opts.projectId,
});
const serviceDomain = existing.serviceDomains.find((item) => item.domain);
if (serviceDomain) {
return serviceDomain;
}
const issuedCustomDomain = existing.customDomains.find((item) =>
item.domain && (!item.certificateStatus || item.certificateStatus === "ISSUED")
);
if (issuedCustomDomain) {
return issuedCustomDomain;
}
return await createRailwayServiceDomain({
token: opts.token,
serviceId: opts.serviceId,
environmentId: opts.environmentId,
targetPort: opts.targetPort,
});
}
/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */
export async function deleteTelegramWebhook(botToken: string): Promise<void> {
const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`;

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,10 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { revokeToken, type ProviderSlug } from "../_shared/oauth-providers.ts";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@ -164,8 +168,36 @@ Deno.serve(async (req) => {
return jsonResponse({ error: "Falha ao remover integração" }, 500);
}
// TODO Fase 5: notify Hermes container that MCP was disconnected
return jsonResponse({ success: true, paused_jobs_count: pausedCount });
const { data: agent } = await admin
.from("agent_instances")
.select("id")
.eq("user_id", userId)
.maybeSingle();
let runtimeSyncError: string | null = null;
if (agent?.id) {
try {
await syncAgentRuntimeSnapshot({
supabase: admin,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: "all",
});
} catch (syncErr) {
runtimeSyncError = syncErr instanceof Error ? syncErr.message : "unknown";
console.error(
"disconnect-integration runtime sync warning",
runtimeSyncError,
);
}
}
return jsonResponse({
success: true,
paused_jobs_count: pausedCount,
runtime_sync_warning: runtimeSyncError,
});
} catch (err) {
console.error("disconnect-integration fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(

View file

@ -1,7 +1,10 @@
// keep-alive-agents
// Mantém todos os containers Railway dos agentes ativos "acordados" fazendo
// uma request GET /getMe ao Telegram para cada agente. Isso força tráfego de
// saída no container, evitando que o Railway hiberne instâncias ociosas.
// Mantém todos os containers Railway dos agentes ativos "acordados" e aproveita
// o ciclo para reconciliar o estado operacional dos cronjobs de volta no banco.
//
// Estratégia:
// 1. Faz GET /getMe no Telegram quando o agente já tem bot configurado
// 2. Puxa /api/cronjobs do runtime Hermes e atualiza scheduled_jobs
//
// Substitui completamente o UptimeRobot — não precisa de configuração externa
// por agente. Roda via pg_cron a cada 4 minutos.
@ -10,14 +13,18 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { corsHeaders } from "../_shared/cors.ts";
import { pullAgentCronjobsRuntimeState } from "../_shared/runtime-sync.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 HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
interface AgentRow {
id: string;
user_id: string;
telegram_bot_token_vault_id: string;
railway_service_id: string | null;
telegram_bot_token_vault_id: string | null;
telegram_bot_username: string | null;
}
@ -28,12 +35,11 @@ Deno.serve(async (req) => {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1) Buscar agentes ativos com token configurado
// 1) Buscar agentes ativos; Telegram e runtime são tratados separadamente
const { data: agents, error: agentsErr } = await supabase
.from("agent_instances")
.select("id, user_id, telegram_bot_token_vault_id, telegram_bot_username")
.eq("status", "active")
.not("telegram_bot_token_vault_id", "is", null);
.select("id, user_id, railway_service_id, telegram_bot_token_vault_id, telegram_bot_username")
.eq("status", "active");
if (agentsErr) {
console.error("keep-alive: failed to load agents:", agentsErr.message);
@ -41,43 +47,78 @@ Deno.serve(async (req) => {
}
const list = (agents ?? []) as AgentRow[];
let success = 0;
let failed = 0;
let telegramSuccess = 0;
let telegramFailed = 0;
let telegramSkipped = 0;
let runtimeSyncSuccess = 0;
let runtimeSyncFailed = 0;
let runtimeSyncSkipped = 0;
const runtimeSyncEnabled = Boolean(RAILWAY_API_TOKEN && HERMES_API_SERVER_KEY);
// 2) Para cada agente, decrypt token + GET /getMe (em paralelo, mas sem quebrar o loop)
// 2) Para cada agente, ping no Telegram + reconciliação de runtime
await Promise.all(
list.map(async (agent) => {
if (agent.telegram_bot_token_vault_id) {
try {
const { data: secret, error: secretErr } = await supabase.rpc("vault_decrypt_secret", {
secret_id: agent.telegram_bot_token_vault_id,
});
if (secretErr || !secret?.[0]?.decrypted_secret) {
console.warn(`keep-alive: missing token for agent ${agent.id} (${agent.telegram_bot_username ?? "?"})`);
telegramFailed++;
} else {
const token = secret[0].decrypted_secret as string;
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { method: "GET" });
if (!res.ok) {
const text = await res.text().catch(() => "");
console.warn(`keep-alive: getMe failed for agent ${agent.id} (${agent.telegram_bot_username ?? "?"}): ${res.status} ${text.slice(0, 200)}`);
telegramFailed++;
} else {
telegramSuccess++;
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn(`keep-alive: telegram exception for agent ${agent.id}: ${msg}`);
telegramFailed++;
}
} else {
telegramSkipped++;
}
if (!runtimeSyncEnabled || !agent.railway_service_id) {
runtimeSyncSkipped++;
return;
}
try {
const { data: secret, error: secretErr } = await supabase.rpc("vault_decrypt_secret", {
secret_id: agent.telegram_bot_token_vault_id,
await pullAgentCronjobsRuntimeState({
supabase,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
if (secretErr || !secret?.[0]?.decrypted_secret) {
console.warn(`keep-alive: missing token for agent ${agent.id} (${agent.telegram_bot_username ?? "?"})`);
failed++;
return;
}
const token = secret[0].decrypted_secret as string;
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { method: "GET" });
if (!res.ok) {
const text = await res.text().catch(() => "");
console.warn(`keep-alive: getMe failed for agent ${agent.id} (${agent.telegram_bot_username ?? "?"}): ${res.status} ${text.slice(0, 200)}`);
failed++;
return;
}
success++;
runtimeSyncSuccess++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn(`keep-alive: exception for agent ${agent.id}: ${msg}`);
failed++;
console.warn(`keep-alive: runtime sync exception for agent ${agent.id}: ${msg}`);
runtimeSyncFailed++;
}
}),
);
const summary = { total: list.length, success, failed };
const summary = {
total: list.length,
telegram_success: telegramSuccess,
telegram_failed: telegramFailed,
telegram_skipped: telegramSkipped,
runtime_sync_enabled: runtimeSyncEnabled,
runtime_sync_success: runtimeSyncSuccess,
runtime_sync_failed: runtimeSyncFailed,
runtime_sync_skipped: runtimeSyncSkipped,
};
console.log("keep-alive summary:", JSON.stringify(summary));
return jsonResponse(200, summary);
});

View file

@ -0,0 +1,110 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import {
DEFAULT_OLLAMA_MODEL,
normalizeOllamaModelSelection,
} from "../_shared/hermes-config.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const OLLAMA_API_KEY = Deno.env.get("OLLAMA_API_KEY");
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
interface OllamaTagModel {
name?: string;
model?: string;
modified_at?: string;
size?: number;
digest?: string;
details?: Record<string, unknown>;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get("Authorization") ?? "";
const jwt = authHeader.replace(/^Bearer\s+/i, "");
if (!jwt) return jsonResponse({ error: "missing authorization" }, 401);
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: userData, error: userErr } = await admin.auth.getUser(jwt);
if (userErr || !userData?.user) {
return jsonResponse({ error: "invalid token" }, 401);
}
const { data: isAdmin, error: roleErr } = await admin.rpc("has_role", {
_user_id: userData.user.id,
_role: "admin",
});
if (roleErr || !isAdmin) {
return jsonResponse({ error: "admin role required" }, 403);
}
if (!OLLAMA_API_KEY) {
return jsonResponse({ error: "OLLAMA_API_KEY not configured" }, 500);
}
const res = await fetch("https://ollama.com/api/tags", {
headers: {
Authorization: `Bearer ${OLLAMA_API_KEY}`,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return jsonResponse(
{
error: `ollama tags request failed: ${res.status}`,
detail: text,
},
502,
);
}
const payload = await res.json().catch(() => ({}));
const models = Array.isArray(payload?.models) ? (payload.models as OllamaTagModel[]) : [];
const normalized = models
.map((item) => {
const raw = item.name || item.model || "";
const name = normalizeOllamaModelSelection(raw);
return {
name,
raw_name: raw,
modified_at: item.modified_at ?? null,
size: item.size ?? null,
digest: item.digest ?? null,
details: item.details ?? {},
};
})
.filter((item) => /(?:-cloud|:cloud)$/.test(item.name))
.sort((a, b) => {
if (a.name === DEFAULT_OLLAMA_MODEL) return -1;
if (b.name === DEFAULT_OLLAMA_MODEL) return 1;
return a.name.localeCompare(b.name);
});
return jsonResponse({
models: normalized,
default_model: DEFAULT_OLLAMA_MODEL,
source_endpoint: "https://ollama.com/api/tags",
});
} catch (err) {
return jsonResponse(
{ error: err instanceof Error ? err.message : "unexpected error" },
500,
);
}
});

View file

@ -9,6 +9,10 @@ import {
getProviderEnv,
type ProviderSlug,
} from "../_shared/oauth-providers.ts";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
function siteUrl(): string {
return Deno.env.get("SITE_URL") ?? "https://798b89e5-0dc6-412a-81be-a4b6dfea7b6c.lovable.app";
@ -175,7 +179,29 @@ Deno.serve(async (req) => {
return redirect("/painel/integracoes?error=db_error");
}
// TODO Fase 5: notify Hermes container that MCP is now available
const { data: agent } = await admin
.from("agent_instances")
.select("id")
.eq("user_id", stateRow.user_id)
.maybeSingle();
if (agent?.id) {
try {
await syncAgentRuntimeSnapshot({
supabase: admin,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: "all",
});
} catch (syncErr) {
console.error(
"oauth-callback runtime sync warning",
syncErr instanceof Error ? syncErr.message : String(syncErr),
);
}
}
return redirect(`/painel/integracoes?status=success&mcp=${encodeURIComponent(slug)}`);
} catch (err) {
console.error("oauth-callback fatal", err instanceof Error ? err.message : "unknown");

View file

@ -14,6 +14,11 @@ import {
findRailwayServiceByName,
upsertRailwayVariableCollection,
} from "../_shared/railway.ts";
import {
DEFAULT_OLLAMA_MODEL,
DEFAULT_OLLAMA_PROVIDER,
normalizeOllamaModelSelection,
} from "../_shared/hermes-config.ts";
interface RequestBody {
agent_instance_id: string;
@ -128,8 +133,7 @@ Deno.serve(async (req) => {
// deno-lint-ignore no-explicit-any
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
const isPro = ["professional", "enterprise"].includes(planSlug);
console.log(`[provision-agent] plano=${planSlug} isPro=${isPro}`);
console.log(`[provision-agent] plano=${planSlug}`);
// 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade)
const { data: pool, error: poolErr } = await supabase
@ -215,11 +219,15 @@ Deno.serve(async (req) => {
const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DomCo. exclusivamente para ${fullName}. Seu estilo: Direto e objetivo, sempre em português brasileiro, respostas curtas no Telegram, use emojis com moderação, trate ${firstName} pelo primeiro nome. Suas prioridades: produtividade, automação proativa. Identidade: você é ${agentName} da DomCo., nunca se identifique como Hermes ou qualquer outro modelo.`;
const soulContent = body.soul_content?.trim() || defaultSoul;
const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
const envVars: Record<string, string> = {
HERMES_HOME: "/opt/data/.hermes",
API_SERVER_ENABLED: "true",
API_SERVER_KEY: Deno.env.get("HERMES_API_SERVER_KEY") ?? "",
GATEWAY_ALLOW_ALL_USERS: "false",
HERMES_MODEL_DEFAULT: modelFinal,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
HERMES_SOUL_OVERRIDE: soulContent,
HERMES_STT_PROVIDER: sttProvider,
HERMES_TTS_PROVIDER: ttsProvider,
@ -230,10 +238,7 @@ Deno.serve(async (req) => {
TELEGRAM_HOME_CHANNEL: chatIdStr,
};
// Modelo é definido pelo config.yaml embutido na imagem custom (ollama-cloud + gemma4:31b-cloud).
// NÃO injetar HERMES_MODEL como env var — sobrescreve o config.yaml e quebra o bot.
const agentNameFinal = agentName;
const modelFinal = isPro ? "ollama-cloud/gemma4:31b-cloud" : "ollama-cloud/gemma4:31b-cloud";
// 7) Criar serviço no Railway
const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`;
@ -307,7 +312,8 @@ Deno.serve(async (req) => {
vps_pool_id: pool.id,
agent_name: agentNameFinal,
model_config: {
provider: modelFinal,
provider: DEFAULT_OLLAMA_PROVIDER,
model: modelFinal,
stt: sttProvider,
tts: ttsProvider,
agent_name: agentNameFinal,
@ -431,15 +437,10 @@ async function handleUpdateExistingService(
// deno-lint-ignore no-explicit-any
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
const isPro = ["professional", "enterprise"].includes(planSlug);
const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`;
const soulContent = body.soul_content?.trim() || defaultSoul;
const defaultModel = isPro
? "openrouter/google/gemma-4-31b-it"
: "openrouter/google/gemma-4-27b-a4b-it";
const model = body.model || defaultModel;
const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
const sttProvider = body.stt_provider || "local";
const ttsProvider = body.tts_provider || "disabled";
@ -470,10 +471,9 @@ async function handleUpdateExistingService(
return jsonResponse(500, { error: "failed to resolve railway project/environment" });
}
// Upsert das vars principais (não mexemos em token Telegram aqui — preservado)
// NÃO injetar HERMES_MODEL: a imagem custom já tem config.yaml com ollama-cloud/gemma4:31b-cloud.
// Sobrescrever via env var quebra o bot (model: "" / 404 not found).
const variables: Record<string, string> = {
HERMES_MODEL_DEFAULT: model,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
HERMES_SOUL_OVERRIDE: soulContent,
HERMES_STT_PROVIDER: sttProvider,
HERMES_TTS_PROVIDER: ttsProvider,
@ -515,7 +515,8 @@ async function handleUpdateExistingService(
.from("agent_instances")
.update({
model_config: {
provider: model,
provider: DEFAULT_OLLAMA_PROVIDER,
model,
stt: sttProvider,
tts: ttsProvider,
agent_name: agentName,

View file

@ -1,11 +1,13 @@
// Promove uma skill_version a "live" de forma atômica e idempotente.
// Garantia adicional: unique index parcial skill_versions_one_live_per_skill no banco.
// TODO Fase 5: dispatch SSH deploy to container after publish
import { createClient } from "npm:@supabase/supabase-js@2";
import { corsHeaders } from "../_shared/cors.ts";
import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.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 HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
@ -53,7 +55,7 @@ Deno.serve(async (req) => {
// Carrega versão + skill (verifica ownership e estado atual)
const { data: versionRow, error: vErr } = await admin
.from("skill_versions")
.select("id, skill_id, version_number, is_live, skills!inner(id, user_id)")
.select("id, skill_id, version_number, is_live, skills!inner(id, user_id, agent_instance_id)")
.eq("id", skill_version_id)
.maybeSingle();
@ -81,6 +83,8 @@ Deno.serve(async (req) => {
}
const skillId: string = versionRow.skill_id;
// @ts-expect-error nested
const agentInstanceId: string = versionRow.skills.agent_instance_id;
// Postgres não permite transação multi-statement via supabase-js.
// Estratégia: 1) zera todos is_live da skill, 2) marca a alvo como live, 3) atualiza skills.
@ -141,8 +145,35 @@ Deno.serve(async (req) => {
});
}
let syncResult:
| { synced: true; public_url: string; public_domain: string; synced_count: number }
| { synced: false; sync_error: string } = { synced: true, public_url: "", public_domain: "", synced_count: 0 };
try {
const result = await syncAgentSkillsSnapshot({
supabase: admin,
agentInstanceId,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
syncResult = {
synced: true,
public_url: result.public_url,
public_domain: result.public_domain,
synced_count: result.synced_count,
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error("publish skill sync failed:", msg);
syncResult = { synced: false, sync_error: msg };
}
return new Response(
JSON.stringify({ success: true, version_number: versionRow.version_number }),
JSON.stringify({
success: true,
version_number: versionRow.version_number,
...syncResult,
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
});

View file

@ -10,11 +10,17 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { corsHeaders } from "../_shared/cors.ts";
import {
syncAgentRuntimeSnapshot,
syncAgentSkillsSnapshot,
} from "../_shared/runtime-sync.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN");
const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID");
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
async function notifyAdmin(message: string): Promise<void> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
@ -138,8 +144,61 @@ Deno.serve(async (req) => {
}
}
let runtimeSyncError: string | null = null;
try {
const runtimeResult = await syncAgentRuntimeSnapshot({
supabase,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: "all",
});
console.log(
`railway-webhook: runtime sincronizado para agent ${agent.id} (${runtimeResult.cronjobs_synced_count} cronjobs, ${runtimeResult.integrations_synced_count} integrations)`,
);
} catch (e) {
runtimeSyncError = e instanceof Error ? e.message : String(e);
console.error(`railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`, runtimeSyncError);
if (wasProvisioning) {
const fullName = await loadFullName();
await notifyAdmin(
`⚠️ <b>Agente subiu, mas o sync operacional falhou</b>\n\n` +
`👤 <b>Cliente:</b> ${fullName}\n` +
`🚀 <b>Railway:</b> <code>${agent.railway_service_id}</code>\n` +
`❗ <b>Erro:</b> ${runtimeSyncError}\n\n` +
`➡️ <a href="https://mika.domco.ai/admin">Revisar no admin</a>`,
);
}
}
let skillsSyncError: string | null = null;
try {
const syncResult = await syncAgentSkillsSnapshot({
supabase,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
console.log(
`railway-webhook: skills sincronizadas para agent ${agent.id} (${syncResult.synced_count} skills)`,
);
} catch (e) {
skillsSyncError = e instanceof Error ? e.message : String(e);
console.error(`railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`, skillsSyncError);
if (wasProvisioning) {
const fullName = await loadFullName();
await notifyAdmin(
`⚠️ <b>Agente subiu, mas o sync de skills falhou</b>\n\n` +
`👤 <b>Cliente:</b> ${fullName}\n` +
`🚀 <b>Railway:</b> <code>${agent.railway_service_id}</code>\n` +
`❗ <b>Erro:</b> ${skillsSyncError}\n\n` +
`➡️ <a href="https://mika.domco.ai/admin">Revisar no admin</a>`,
);
}
}
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
if (wasProvisioning) {
if (wasProvisioning && !skillsSyncError && !runtimeSyncError) {
const fullName = await loadFullName();
await notifyAdmin(
`✅ <b>Agente provisionado automaticamente!</b>\n\n` +

View file

@ -8,6 +8,10 @@ import {
type ProviderSlug,
refreshAccessToken,
} from "../_shared/oauth-providers.ts";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@ -174,7 +178,33 @@ Deno.serve(async (req) => {
})
.eq("id", integration_id);
return jsonResponse({ success: true, expires_at: expiresAt });
let runtimeSyncError: string | null = null;
const { data: agent } = await admin
.from("agent_instances")
.select("id")
.eq("user_id", userId)
.maybeSingle();
if (agent?.id) {
try {
await syncAgentRuntimeSnapshot({
supabase: admin,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: "integrations",
});
} catch (syncErr) {
runtimeSyncError = syncErr instanceof Error ? syncErr.message : String(syncErr);
console.error("refresh-integration-token runtime sync warning", runtimeSyncError);
}
}
return jsonResponse({
success: true,
expires_at: expiresAt,
runtime_sync_warning: runtimeSyncError,
});
} catch (err) {
console.error("refresh-integration-token fatal", err instanceof Error ? err.message : "unknown");
return jsonResponse(

View file

@ -0,0 +1,109 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
type RuntimeSyncScope = "cronjobs" | "integrations" | "all";
function jsonResponse(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function isValidScope(scope: unknown): scope is RuntimeSyncScope {
return scope === "cronjobs" || scope === "integrations" || scope === "all";
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
const authHeader = req.headers.get("Authorization") ?? "";
const jwt = authHeader.replace(/^Bearer\s+/i, "");
if (!jwt) {
return jsonResponse(401, { error: "missing authorization" });
}
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: `Bearer ${jwt}` } },
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData?.user) {
return jsonResponse(401, { error: "invalid token" });
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
let body: { agent_instance_id?: string; scope?: RuntimeSyncScope };
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" });
}
if (body.scope && !isValidScope(body.scope)) {
return jsonResponse(400, { error: "invalid scope" });
}
const { data: agent, error: agentErr } = await supabase
.from("agent_instances")
.select("id, user_id")
.eq("id", body.agent_instance_id)
.maybeSingle();
if (agentErr || !agent) {
return jsonResponse(404, { error: "agent_instance not found" });
}
const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", {
_user_id: userData.user.id,
_role: "admin",
});
if (roleErr) {
return jsonResponse(500, { error: "failed to resolve role" });
}
if (agent.user_id !== userData.user.id && !isAdmin) {
return jsonResponse(403, { error: "forbidden" });
}
try {
const result = await syncAgentRuntimeSnapshot({
supabase,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
scope: body.scope ?? "all",
});
return jsonResponse(200, {
success: true,
agent_instance_id: result.agent_instance_id,
public_url: result.public_url,
public_domain: result.public_domain,
cronjobs_synced_count: result.cronjobs_synced_count,
integrations_synced_count: result.integrations_synced_count,
runtime_responses: result.responses,
});
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
console.error("sync-agent-runtime failed:", detail);
return jsonResponse(500, { error: "runtime sync failed", detail });
}
});

View file

@ -0,0 +1,97 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
function jsonResponse(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
const authHeader = req.headers.get("Authorization") ?? "";
const jwt = authHeader.replace(/^Bearer\s+/i, "");
if (!jwt) {
return jsonResponse(401, { error: "missing authorization" });
}
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: { headers: { Authorization: `Bearer ${jwt}` } },
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData?.user) {
return jsonResponse(401, { error: "invalid token" });
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
let body: { agent_instance_id?: string };
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 { data: agent, error: agentErr } = await supabase
.from("agent_instances")
.select("id, user_id")
.eq("id", body.agent_instance_id)
.maybeSingle();
if (agentErr || !agent) {
return jsonResponse(404, { error: "agent_instance not found" });
}
const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", {
_user_id: userData.user.id,
_role: "admin",
});
if (roleErr) {
return jsonResponse(500, { error: "failed to resolve role" });
}
if (agent.user_id !== userData.user.id && !isAdmin) {
return jsonResponse(403, { error: "forbidden" });
}
try {
const result = await syncAgentSkillsSnapshot({
supabase,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
return jsonResponse(200, {
success: true,
agent_instance_id: result.agent_instance_id,
public_url: result.public_url,
public_domain: result.public_domain,
synced_count: result.synced_count,
runtime_response: result.response,
});
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
console.error("sync-agent-skills failed:", detail);
return jsonResponse(500, { error: "skills sync failed", detail });
}
});

View file

@ -10,6 +10,11 @@ import {
getServiceContext,
upsertRailwayVariableCollection,
} from "../_shared/railway.ts";
import {
DEFAULT_OLLAMA_MODEL,
DEFAULT_OLLAMA_PROVIDER,
normalizeOllamaModelSelection,
} from "../_shared/hermes-config.ts";
interface RequestBody {
agent_instance_id: string;
@ -102,7 +107,11 @@ Deno.serve(async (req) => {
}
// 4) Upsert variáveis (incluindo HERMES_SOUL_OVERRIDE editado pelo admin)
const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
const variables: Record<string, string> = {
HERMES_MODEL_DEFAULT: model,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
HERMES_SOUL_OVERRIDE: body.soul_content,
HERMES_STT_PROVIDER: body.stt_provider || "local",
HERMES_TTS_PROVIDER: body.tts_provider || "disabled",
@ -134,7 +143,8 @@ Deno.serve(async (req) => {
.from("agent_instances")
.update({
model_config: {
provider: body.model,
provider: DEFAULT_OLLAMA_PROVIDER,
model,
stt: body.stt_provider || "local",
tts: body.tts_provider || "disabled",
agent_name: body.agent_name ?? null,

View file

@ -0,0 +1,25 @@
-- ============================================================================
-- FASE 5 — Observabilidade do runtime de cronjobs
-- ============================================================================
-- A UI e as Edge Functions já usam o estado "auto_paused", mas a migration
-- original ainda não o aceitava no CHECK da coluna status.
ALTER TABLE public.scheduled_jobs
DROP CONSTRAINT IF EXISTS scheduled_jobs_status_check;
ALTER TABLE public.scheduled_jobs
ADD CONSTRAINT scheduled_jobs_status_check
CHECK (status IN ('active', 'paused', 'auto_paused', 'error', 'archived'));
-- Espelha no Supabase o estado operacional retornado pelo runtime do Hermes.
ALTER TABLE public.scheduled_jobs
ADD COLUMN IF NOT EXISTS runtime_state text
CHECK (runtime_state IS NULL OR runtime_state IN ('scheduled', 'paused', 'completed', 'error')),
ADD COLUMN IF NOT EXISTS runtime_last_status text
CHECK (runtime_last_status IS NULL OR runtime_last_status IN ('ok', 'error')),
ADD COLUMN IF NOT EXISTS runtime_last_error text,
ADD COLUMN IF NOT EXISTS runtime_last_delivery_error text,
ADD COLUMN IF NOT EXISTS runtime_synced_at timestamptz;
CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_agent_runtime_synced_at
ON public.scheduled_jobs (agent_instance_id, runtime_synced_at DESC NULLS LAST);