mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 13:56:51 +00:00
Implement Mika runtime sync and go-live controls
This commit is contained in:
parent
cb54fa4666
commit
0df3befb67
29 changed files with 2153 additions and 94 deletions
23
supabase/functions/_shared/hermes-config.ts
Normal file
23
supabase/functions/_shared/hermes-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
1035
supabase/functions/_shared/runtime-sync.ts
Normal file
1035
supabase/functions/_shared/runtime-sync.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
110
supabase/functions/list-ollama-models/index.ts
Normal file
110
supabase/functions/list-ollama-models/index.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
109
supabase/functions/sync-agent-runtime/index.ts
Normal file
109
supabase/functions/sync-agent-runtime/index.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
97
supabase/functions/sync-agent-skills/index.ts
Normal file
97
supabase/functions/sync-agent-skills/index.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue