diff --git a/src/components/mika/cronjobs/CronjobCard.tsx b/src/components/mika/cronjobs/CronjobCard.tsx
index c59ba99..955e723 100644
--- a/src/components/mika/cronjobs/CronjobCard.tsx
+++ b/src/components/mika/cronjobs/CronjobCard.tsx
@@ -23,6 +23,7 @@ import {
useUpdateCronjobStatus,
} from "@/hooks/use-cronjobs";
import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations";
+import { syncAgentRuntime } from "@/lib/sync-agent-runtime";
interface Props {
job: ScheduledJob;
@@ -45,6 +46,8 @@ export function CronjobCard({ job }: Props) {
const isActive = job.status === "active";
const isAutoPaused = job.status === "auto_paused";
+ const hasRuntimeError = job.runtime_state === "error" || job.runtime_last_status === "error";
+ const runtimeErrorText = job.runtime_last_delivery_error ?? job.runtime_last_error;
async function toggle() {
try {
@@ -53,6 +56,11 @@ export function CronjobCard({ job }: Props) {
status: isActive ? "paused" : "active",
});
toast.success(isActive ? "Automação pausada." : "Automação ativada.");
+
+ const { error: syncError } = await syncAgentRuntime(job.agent_instance_id, "cronjobs");
+ if (syncError) {
+ toast.warning("Status salvo, mas o runtime do agente não sincronizou.");
+ }
} catch (e) {
toast.error(e instanceof Error ? e.message : "Erro ao atualizar.");
}
@@ -63,6 +71,11 @@ export function CronjobCard({ job }: Props) {
await deleteMut.mutateAsync(job.id);
toast.success("Automação excluída.");
setConfirmDelete(false);
+
+ const { error: syncError } = await syncAgentRuntime(job.agent_instance_id, "cronjobs");
+ if (syncError) {
+ toast.warning("Automação removida, mas o runtime do agente não sincronizou.");
+ }
} catch (e) {
toast.error(e instanceof Error ? e.message : "Erro ao excluir.");
}
@@ -76,11 +89,15 @@ export function CronjobCard({ job }: Props) {
{job.name}
{isActive && Ativa}
{job.status === "paused" && Pausada}
+ {job.status === "error" && Erro}
{isAutoPaused && (
Auto-pausada
)}
+ {hasRuntimeError && !isAutoPaused && (
+ Runtime com erro
+ )}
{job.human_readable}
@@ -92,6 +109,10 @@ export function CronjobCard({ job }: Props) {
{job.auto_paused_reason}
)}
+ {hasRuntimeError && runtimeErrorText && (
+ {runtimeErrorText}
+ )}
+
{missingMcps.length > 0 && isActive && (
diff --git a/src/components/mika/cronjobs/CronjobWizard.tsx b/src/components/mika/cronjobs/CronjobWizard.tsx
index 69d40f0..5b8aff8 100644
--- a/src/components/mika/cronjobs/CronjobWizard.tsx
+++ b/src/components/mika/cronjobs/CronjobWizard.tsx
@@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { invokeFunction } from "@/lib/invoke-function";
+import { syncAgentRuntime } from "@/lib/sync-agent-runtime";
import { useCreateCronjob } from "@/hooks/use-cronjobs";
import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations";
import { useAgentInstance } from "@/hooks/use-agent-instance";
@@ -131,6 +132,12 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
next_run_at: parsed?.next_run_at ?? null,
});
toast.success("Automação criada!");
+
+ const { error: syncError } = await syncAgentRuntime(agent.id, "cronjobs");
+ if (syncError) {
+ toast.warning("Automação criada, mas o runtime do agente não sincronizou.");
+ }
+
onCreated?.(job.id);
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao criar automação";
diff --git a/src/components/mika/integrations/DisconnectMCPDialog.tsx b/src/components/mika/integrations/DisconnectMCPDialog.tsx
index 8b94891..7551e16 100644
--- a/src/components/mika/integrations/DisconnectMCPDialog.tsx
+++ b/src/components/mika/integrations/DisconnectMCPDialog.tsx
@@ -43,7 +43,11 @@ export function DisconnectMCPDialog({
async function handleDisconnect() {
setSubmitting(true);
- const { error } = await invokeFunction("disconnect-integration", {
+ const { data, error } = await invokeFunction<{
+ success: boolean;
+ paused_jobs_count: number;
+ runtime_sync_warning?: string | null;
+ }>("disconnect-integration", {
integration_id: integrationId,
});
setSubmitting(false);
@@ -52,6 +56,9 @@ export function DisconnectMCPDialog({
return;
}
toast.success(`${mcpName} desconectado.`);
+ if (data?.runtime_sync_warning) {
+ toast.warning("Integração removida, mas o runtime do agente não sincronizou.");
+ }
queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
onOpenChange(false);
diff --git a/src/components/mika/skills/SkillCard.tsx b/src/components/mika/skills/SkillCard.tsx
index 09de28e..687071f 100644
--- a/src/components/mika/skills/SkillCard.tsx
+++ b/src/components/mika/skills/SkillCard.tsx
@@ -8,6 +8,7 @@ import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
+import { syncAgentSkills } from "@/lib/sync-agent-skills";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -39,6 +40,15 @@ export function SkillCard({ skill }: { skill: Skill }) {
const isArchived = skill.status === "archived";
+ async function syncRuntimeAfterMutation(actionLabel: string) {
+ const { error } = await syncAgentSkills(skill.agent_instance_id);
+ if (error) {
+ toast.warning(`${actionLabel}, mas o sync com o container falhou.`, {
+ description: error.message,
+ });
+ }
+ }
+
const updateStatus = useMutation({
mutationFn: async (newStatus: string) => {
const { error } = await supabase
@@ -56,16 +66,21 @@ export function SkillCard({ skill }: { skill: Skill }) {
const handleToggleActive = () => {
const next = skill.status === "active" ? "disabled" : "active";
updateStatus.mutate(next, {
- onSuccess: () => toast.success(next === "active" ? "Skill ativada" : "Skill desativada"),
+ onSuccess: async () => {
+ const actionLabel = next === "active" ? "Skill ativada" : "Skill desativada";
+ toast.success(actionLabel);
+ await syncRuntimeAfterMutation(actionLabel);
+ },
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"),
});
};
const handleArchive = () => {
updateStatus.mutate("archived", {
- onSuccess: () => {
+ onSuccess: async () => {
toast.success("Skill arquivada");
setConfirmArchive(false);
+ await syncRuntimeAfterMutation("Skill arquivada");
},
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao arquivar"),
});
@@ -73,7 +88,10 @@ export function SkillCard({ skill }: { skill: Skill }) {
const handleRestore = () => {
updateStatus.mutate("draft", {
- onSuccess: () => toast.success("Skill restaurada como rascunho"),
+ onSuccess: async () => {
+ toast.success("Skill restaurada como rascunho");
+ await syncRuntimeAfterMutation("Skill restaurada como rascunho");
+ },
onError: (e: unknown) => {
if ((e as { code?: string })?.code === "23505") {
toast.error("Já existe outra skill ativa com esse nome. Renomeie antes de restaurar.");
@@ -89,11 +107,12 @@ export function SkillCard({ skill }: { skill: Skill }) {
const { error } = await supabase.from("skills").delete().eq("id", skill.id);
if (error) throw error;
},
- onSuccess: () => {
+ onSuccess: async () => {
toast.success("Skill deletada");
qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] });
setConfirmDelete(false);
+ await syncRuntimeAfterMutation("Skill deletada");
},
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"),
});
diff --git a/src/hooks/use-cronjobs.ts b/src/hooks/use-cronjobs.ts
index 694a2fc..6dd65c7 100644
--- a/src/hooks/use-cronjobs.ts
+++ b/src/hooks/use-cronjobs.ts
@@ -15,10 +15,15 @@ export interface ScheduledJob {
human_readable: string;
action_prompt: string;
required_mcp_slugs: string[];
- status: "active" | "paused" | "auto_paused";
+ status: "active" | "paused" | "auto_paused" | "error" | "archived";
auto_paused_reason: string | null;
last_run_at: string | null;
next_run_at: string | null;
+ runtime_state: "scheduled" | "paused" | "completed" | "error" | null;
+ runtime_last_status: "ok" | "error" | null;
+ runtime_last_error: string | null;
+ runtime_last_delivery_error: string | null;
+ runtime_synced_at: string | null;
timezone: string;
created_at: string;
updated_at: string;
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index 23a8b39..9e43fae 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -475,6 +475,11 @@ export type Database = {
natural_language_input: string
next_run_at: string | null
required_mcp_slugs: Json
+ runtime_last_delivery_error: string | null
+ runtime_last_error: string | null
+ runtime_last_status: string | null
+ runtime_state: string | null
+ runtime_synced_at: string | null
status: string
timezone: string
updated_at: string
@@ -494,6 +499,11 @@ export type Database = {
natural_language_input: string
next_run_at?: string | null
required_mcp_slugs?: Json
+ runtime_last_delivery_error?: string | null
+ runtime_last_error?: string | null
+ runtime_last_status?: string | null
+ runtime_state?: string | null
+ runtime_synced_at?: string | null
status?: string
timezone?: string
updated_at?: string
@@ -513,6 +523,11 @@ export type Database = {
natural_language_input?: string
next_run_at?: string | null
required_mcp_slugs?: Json
+ runtime_last_delivery_error?: string | null
+ runtime_last_error?: string | null
+ runtime_last_status?: string | null
+ runtime_state?: string | null
+ runtime_synced_at?: string | null
status?: string
timezone?: string
updated_at?: string
diff --git a/src/lib/ollama-models.ts b/src/lib/ollama-models.ts
new file mode 100644
index 0000000..fea2d92
--- /dev/null
+++ b/src/lib/ollama-models.ts
@@ -0,0 +1,35 @@
+export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud";
+export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud";
+
+const LEGACY_MODEL_ALIASES: Record = {
+ "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;
+}
+
+export function formatOllamaModelLabel(modelName: string): string {
+ if (modelName === DEFAULT_OLLAMA_MODEL) {
+ return "Gemma 4 31B Cloud — Padrão Mika";
+ }
+
+ if (modelName.startsWith("gemma4:")) {
+ return modelName.replace("gemma4:", "Gemma 4 ");
+ }
+
+ return modelName;
+}
diff --git a/src/lib/sync-agent-runtime.ts b/src/lib/sync-agent-runtime.ts
new file mode 100644
index 0000000..b4ebbf5
--- /dev/null
+++ b/src/lib/sync-agent-runtime.ts
@@ -0,0 +1,24 @@
+import { invokeFunction } from "@/lib/invoke-function";
+
+type RuntimeSyncScope = "cronjobs" | "integrations" | "all";
+
+export async function syncAgentRuntime(
+ agentInstanceId: string,
+ scope: RuntimeSyncScope = "all",
+) {
+ return await invokeFunction<{
+ success: boolean;
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ cronjobs_synced_count: number;
+ integrations_synced_count: number;
+ runtime_responses: {
+ cronjobs: unknown;
+ integrations: unknown;
+ };
+ }>("sync-agent-runtime", {
+ agent_instance_id: agentInstanceId,
+ scope,
+ });
+}
diff --git a/src/lib/sync-agent-skills.ts b/src/lib/sync-agent-skills.ts
new file mode 100644
index 0000000..f52e289
--- /dev/null
+++ b/src/lib/sync-agent-skills.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { invokeFunction } from "@/lib/invoke-function";
+
+export interface SyncAgentSkillsResponse {
+ success?: boolean;
+ agent_instance_id?: string;
+ public_url?: string;
+ public_domain?: string;
+ synced_count?: number;
+ runtime_response?: unknown;
+}
+
+export async function syncAgentSkills(agentInstanceId: string) {
+ return await invokeFunction("sync-agent-skills", {
+ agent_instance_id: agentInstanceId,
+ });
+}
diff --git a/src/routes/admin.agente.$id.tsx b/src/routes/admin.agente.$id.tsx
index c922d8d..aff3a95 100644
--- a/src/routes/admin.agente.$id.tsx
+++ b/src/routes/admin.agente.$id.tsx
@@ -16,6 +16,11 @@ import {
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { invokeFunction } from "@/lib/invoke-function";
+import {
+ DEFAULT_OLLAMA_MODEL,
+ formatOllamaModelLabel,
+ normalizeOllamaModelSelection,
+} from "@/lib/ollama-models";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -34,19 +39,6 @@ export const Route = createFileRoute("/admin/agente/$id")({
component: AgentDetailPage,
});
-const MODEL_OPTIONS = [
- {
- value: "openrouter/google/gemma-4-27b-a4b-it",
- label: "Gemma 4 27B — Rápido e gratuito (Basic/Starter)",
- plans: ["basic", "starter"],
- },
- {
- value: "openrouter/google/gemma-4-31b-it",
- label: "Gemma 4 31B — Mais capaz (Professional)",
- plans: ["professional", "enterprise"],
- },
-];
-
interface AgentDetail {
id: string;
user_id: string;
@@ -73,6 +65,15 @@ interface AgentDetail {
subscription: { plans: { slug: string; name: string } | null } | null;
}
+interface OllamaModelRow {
+ name: string;
+ raw_name: string;
+ modified_at: string | null;
+ size: number | null;
+ digest: string | null;
+ details: Record;
+}
+
function AgentDetailPage() {
const { id } = Route.useParams();
const { user, loading: authLoading } = useAuth();
@@ -178,6 +179,19 @@ function AgentDetailPage() {
},
});
+ const { data: availableModels } = useQuery({
+ queryKey: ["ollama-models"],
+ enabled: isAdmin === true,
+ staleTime: 5 * 60_000,
+ queryFn: async () => {
+ const { data, error } = await invokeFunction<{
+ models?: OllamaModelRow[];
+ }>("list-ollama-models");
+ if (error) throw new Error(error.message);
+ return data?.models ?? [];
+ },
+ });
+
// Backfill: se o agente não tem telegram_user_chat_id mas já recebeu mensagens,
// pega a primeira mensagem incoming e popula automaticamente.
useEffect(() => {
@@ -210,11 +224,7 @@ function AgentDetailPage() {
// ===== Estado do formulário =====
const fullName = agent?.profile?.full_name?.trim() || "Usuário";
const firstName = fullName.split(" ")[0] || "Usuário";
- const planSlug = agent?.subscription?.plans?.slug ?? "basic";
- const isPro = ["professional", "enterprise"].includes(planSlug);
- const defaultModel = isPro
- ? "openrouter/google/gemma-4-31b-it"
- : "openrouter/google/gemma-4-27b-a4b-it";
+ const defaultModel = DEFAULT_OLLAMA_MODEL;
const cfg = (agent?.model_config ?? {}) as Record;
const defaultAgentName = agent?.agent_name?.trim() || cfg.agent_name || `Mika de ${firstName}`;
@@ -231,16 +241,29 @@ function AgentDetailPage() {
const [tts, setTts] = useState("disabled");
const [busy, setBusy] = useState(false);
const [initialized, setInitialized] = useState(false);
+ const modelOptions = useMemo(() => {
+ const options = new Map();
+ options.set(DEFAULT_OLLAMA_MODEL, formatOllamaModelLabel(DEFAULT_OLLAMA_MODEL));
+
+ for (const row of availableModels ?? []) {
+ options.set(row.name, formatOllamaModelLabel(row.name));
+ }
+
+ const currentModel = normalizeOllamaModelSelection(cfg.model || cfg.provider || defaultModel);
+ options.set(currentModel, formatOllamaModelLabel(currentModel));
+
+ return Array.from(options.entries()).map(([value, label]) => ({ value, label }));
+ }, [availableModels, cfg.model, cfg.provider, defaultModel]);
useEffect(() => {
if (!agent || initialized) return;
setAgentName(defaultAgentName);
setSoul(defaultSoul);
- setModel(cfg.provider || defaultModel);
+ setModel(normalizeOllamaModelSelection(cfg.model || cfg.provider || defaultModel));
setStt(cfg.stt || "local");
setTts(cfg.tts || "disabled");
setInitialized(true);
- }, [agent, initialized, defaultAgentName, defaultSoul, cfg.provider, cfg.stt, cfg.tts, defaultModel]);
+ }, [agent, initialized, defaultAgentName, defaultSoul, cfg.model, cfg.provider, cfg.stt, cfg.tts, defaultModel]);
// ===== Auth guard =====
useEffect(() => {
@@ -479,13 +502,16 @@ function AgentDetailPage() {
- {MODEL_OPTIONS.map((m) => (
+ {modelOptions.map((m) => (
{m.label}
))}
+
+ Catálogo carregado dinamicamente do Ollama Cloud.
+
diff --git a/src/routes/painel.cronjobs.$id.tsx b/src/routes/painel.cronjobs.$id.tsx
index 4704c8b..a7809d9 100644
--- a/src/routes/painel.cronjobs.$id.tsx
+++ b/src/routes/painel.cronjobs.$id.tsx
@@ -17,11 +17,13 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
+ type ScheduledJob,
useCronjob,
useDeleteCronjob,
useUpdateCronjobStatus,
} from "@/hooks/use-cronjobs";
import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations";
+import { syncAgentRuntime } from "@/lib/sync-agent-runtime";
export const Route = createFileRoute("/painel/cronjobs/$id")({
component: CronjobDetailPage,
@@ -65,6 +67,7 @@ function CronjobDetailPage() {
const isActive = job.status === "active";
const isAutoPaused = job.status === "auto_paused";
+ const hasRuntimeError = job.runtime_state === "error" || job.runtime_last_status === "error";
async function toggle() {
try {
@@ -73,6 +76,11 @@ function CronjobDetailPage() {
status: isActive ? "paused" : "active",
});
toast.success(isActive ? "Pausada." : "Ativada.");
+
+ const { error: syncError } = await syncAgentRuntime(job!.agent_instance_id, "cronjobs");
+ if (syncError) {
+ toast.warning("Status salvo, mas o runtime do agente não sincronizou.");
+ }
} catch (e) {
toast.error(e instanceof Error ? e.message : "Erro");
}
@@ -82,6 +90,12 @@ function CronjobDetailPage() {
try {
await deleteMut.mutateAsync(job!.id);
toast.success("Excluída.");
+
+ const { error: syncError } = await syncAgentRuntime(job!.agent_instance_id, "cronjobs");
+ if (syncError) {
+ toast.warning("Automação removida, mas o runtime do agente não sincronizou.");
+ }
+
navigate({ to: "/painel/cronjobs" });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Erro");
@@ -102,11 +116,15 @@ function CronjobDetailPage() {
{isActive &&
Ativa}
{job.status === "paused" &&
Pausada}
+ {job.status === "error" &&
Erro}
{isAutoPaused && (
Auto-pausada
)}
+ {hasRuntimeError && !isAutoPaused && (
+
Runtime com erro
+ )}
@@ -138,6 +156,22 @@ function CronjobDetailPage() {
)}
+ {hasRuntimeError && (
+
+
Falha reportada pelo runtime
+
+ {job.runtime_last_error ?? "O runtime marcou a última execução como erro."}
+
+
+ )}
+
+ {job.runtime_last_delivery_error && (
+
+
Falha na entrega do resultado
+
{job.runtime_last_delivery_error}
+
+ )}
+
{missingMcps.length > 0 && isActive && (
@@ -182,6 +216,23 @@ function CronjobDetailPage() {
: "Nunca"
}
/>
+
+
+
{job.description && }
@@ -265,3 +316,29 @@ function Section({
);
}
+
+function formatRuntimeState(state: ScheduledJob["runtime_state"]): string {
+ switch (state) {
+ case "scheduled":
+ return "Agendado";
+ case "paused":
+ return "Pausado";
+ case "completed":
+ return "Concluído";
+ case "error":
+ return "Erro";
+ default:
+ return "Desconhecido";
+ }
+}
+
+function formatRuntimeLastStatus(status: ScheduledJob["runtime_last_status"]): string {
+ switch (status) {
+ case "ok":
+ return "Sucesso";
+ case "error":
+ return "Erro";
+ default:
+ return "Ainda sem execução";
+ }
+}
diff --git a/src/routes/painel.integracoes.$slug.tsx b/src/routes/painel.integracoes.$slug.tsx
index 508c985..0335280 100644
--- a/src/routes/painel.integracoes.$slug.tsx
+++ b/src/routes/painel.integracoes.$slug.tsx
@@ -119,7 +119,11 @@ function IntegrationDetailPage() {
async function handleRefresh() {
setRefreshing(true);
- const { error } = await invokeFunction("refresh-integration-token", {
+ const { data, error } = await invokeFunction<{
+ success: boolean;
+ expires_at: string | null;
+ runtime_sync_warning?: string | null;
+ }>("refresh-integration-token", {
integration_id: integration!.id,
});
setRefreshing(false);
@@ -127,6 +131,9 @@ function IntegrationDetailPage() {
toast.error(error.message);
} else {
toast.success("Token renovado.");
+ if (data?.runtime_sync_warning) {
+ toast.warning("Token renovado, mas o runtime do agente não sincronizou.");
+ }
}
queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
}
diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx
index 0c1eb4b..90494f9 100644
--- a/src/routes/painel.skills.$id.tsx
+++ b/src/routes/painel.skills.$id.tsx
@@ -12,6 +12,7 @@ import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { useSkill } from "@/hooks/use-skills";
+import { syncAgentSkills } from "@/lib/sync-agent-skills";
import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge";
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
import { Button } from "@/components/ui/button";
@@ -131,7 +132,13 @@ function SkillDetailPage() {
// Publish version
const publishVersion = useMutation({
mutationFn: async (versionId: string) => {
- const { data, error } = await supabase.functions.invoke("publish-skill-version", {
+ const { data, error } = await supabase.functions.invoke<{
+ success?: boolean;
+ no_op?: boolean;
+ version_number?: number;
+ synced?: boolean;
+ sync_error?: string;
+ }>("publish-skill-version", {
body: { skill_version_id: versionId },
});
if (error) throw error;
@@ -140,6 +147,10 @@ function SkillDetailPage() {
onSuccess: (data) => {
if (data.no_op) {
toast.info("Esta versão já está publicada");
+ } else if (data.synced === false) {
+ toast.warning(`Versão ${data.version_number} publicada, mas o sync falhou.`, {
+ description: data.sync_error || "Tente novamente após o próximo deploy.",
+ });
} else {
toast.success(`Versão ${data.version_number} publicada!`);
}
@@ -170,6 +181,13 @@ function SkillDetailPage() {
toast.success("Skill arquivada");
qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] });
+ void syncAgentSkills(skill.data.agent_instance_id).then(({ error }) => {
+ if (error) {
+ toast.warning("Skill arquivada, mas o sync com o container falhou.", {
+ description: error.message,
+ });
+ }
+ });
navigate({ to: "/painel/skills" });
},
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"),
diff --git a/src/routes/painel.skills.preview.tsx b/src/routes/painel.skills.preview.tsx
index 38b4920..0c35c2b 100644
--- a/src/routes/painel.skills.preview.tsx
+++ b/src/routes/painel.skills.preview.tsx
@@ -118,12 +118,20 @@ function SkillPreviewPage() {
// 3. Optionally publish
if (publish) {
- const { data: pubData, error: pubErr } = await supabase.functions.invoke(
+ const { data: pubData, error: pubErr } = await supabase.functions.invoke<{
+ success?: boolean;
+ synced?: boolean;
+ sync_error?: string;
+ }>(
"publish-skill-version",
{ body: { skill_version_id: ver.id } },
);
if (pubErr) {
toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
+ } else if (pubData?.synced === false) {
+ toast.warning("Skill publicada, mas o sync com o container falhou.", {
+ description: pubData.sync_error || "Tente novamente após o próximo deploy.",
+ });
} else {
toast.success("Skill publicada com sucesso!");
}
diff --git a/supabase/functions/_shared/hermes-config.ts b/supabase/functions/_shared/hermes-config.ts
new file mode 100644
index 0000000..634b583
--- /dev/null
+++ b/supabase/functions/_shared/hermes-config.ts
@@ -0,0 +1,23 @@
+export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud";
+export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud";
+
+const LEGACY_MODEL_ALIASES: Record = {
+ "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;
+}
diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts
index 4429b27..1fe73a6 100644
--- a/supabase/functions/_shared/railway.ts
+++ b/supabase/functions/_shared/railway.ts
@@ -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 {
+ const mutation = `
+ mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) {
+ serviceDomainCreate(input: $input) {
+ id
+ domain
+ suffix
+ }
+ }
+ `;
+
+ const input: Record = {
+ 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 {
+ 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 {
const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`;
diff --git a/supabase/functions/_shared/runtime-sync.ts b/supabase/functions/_shared/runtime-sync.ts
new file mode 100644
index 0000000..4c85532
--- /dev/null
+++ b/supabase/functions/_shared/runtime-sync.ts
@@ -0,0 +1,1035 @@
+import {
+ ensureRailwayServiceDomain,
+ getServiceContext,
+} from "./railway.ts";
+
+interface AgentInstanceRecord {
+ id: string;
+ user_id: string;
+ railway_service_id: string | null;
+ vps_pool_id: string | null;
+}
+
+interface ActiveSkillRow {
+ id: string;
+ agent_instance_id: string;
+ name: string;
+ description: string;
+ trigger_keywords: string;
+ current_version_id: string;
+ updated_at: string;
+}
+
+interface SkillVersionRow {
+ id: string;
+ version_number: number;
+ markdown_content: string;
+ form_inputs: Record | null;
+ created_at: string;
+}
+
+interface ScheduledJobRow {
+ id: string;
+ agent_instance_id: string;
+ name: string;
+ description: string | null;
+ natural_language_input: string;
+ cron_expression: string;
+ human_readable: string;
+ action_prompt: string;
+ required_mcp_slugs: string[] | null;
+ status: string;
+ auto_paused_reason: string | null;
+ last_run_at: string | null;
+ next_run_at: string | null;
+ timezone: string;
+ created_at: string;
+ updated_at: string;
+}
+
+interface AvailableMcpRow {
+ id: string;
+ slug: string;
+ name: string;
+ provider: string;
+ description: string;
+ oauth_authorize_url: string;
+ oauth_token_url: string;
+ oauth_revoke_url: string | null;
+ required_scopes: string[] | null;
+ supports_refresh_token: boolean;
+}
+
+interface UserIntegrationRow {
+ id: string;
+ user_id: string;
+ mcp_id: string;
+ status: string;
+ connected_account_email: string | null;
+ connected_account_name: string | null;
+ granted_scopes: string[] | null;
+ token_expires_at: string | null;
+ last_refreshed_at: string | null;
+ error_message: string | null;
+ access_token_vault_id: string | null;
+ refresh_token_vault_id: string | null;
+ created_at: string;
+ updated_at: string;
+ mcp: AvailableMcpRow | AvailableMcpRow[] | null;
+}
+
+interface RuntimeTarget {
+ serviceId: string;
+ projectId: string;
+ environmentId: string;
+ publicUrl: string;
+ publicDomain: string;
+}
+
+type RuntimeSyncScope = "cronjobs" | "integrations" | "all";
+
+export interface AgentSkillsSyncPayload {
+ agent_instance_id: string;
+ synced_at: string;
+ skills: Array<{
+ skill_id: string;
+ version_id: string;
+ version_number: number;
+ name: string;
+ description: string;
+ trigger_keywords: string;
+ updated_at: string;
+ created_at: string;
+ form_inputs: Record | null;
+ markdown_content: string;
+ }>;
+}
+
+export interface AgentSkillsSyncResult {
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ synced_count: number;
+ response: unknown;
+}
+
+export interface AgentCronjobsSyncPayload {
+ agent_instance_id: string;
+ synced_at: string;
+ cronjobs: Array<{
+ job_id: string;
+ name: string;
+ description: string | null;
+ natural_language_input: string;
+ cron_expression: string;
+ human_readable: string;
+ action_prompt: string;
+ required_mcp_slugs: string[];
+ status: string;
+ auto_paused_reason: string | null;
+ last_run_at: string | null;
+ next_run_at: string | null;
+ timezone: string;
+ created_at: string;
+ updated_at: string;
+ }>;
+}
+
+export interface AgentCronjobsSyncResult {
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ synced_count: number;
+ response: unknown;
+}
+
+interface RuntimeManagedCronjobRecord {
+ id: string;
+ state?: string | null;
+ last_status?: string | null;
+ last_error?: string | null;
+ last_delivery_error?: string | null;
+ last_run_at?: string | null;
+ next_run_at?: string | null;
+}
+
+interface RuntimeCronjobsListResponse {
+ cronjobs?: RuntimeManagedCronjobRecord[];
+}
+
+export interface AgentCronjobsRuntimePullResult {
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ synced_count: number;
+ updated_count: number;
+ response: unknown;
+}
+
+interface RuntimeMcpHint {
+ server_url: string | null;
+ transport: "streamable-http" | null;
+ auth_mode: "oauth" | "unknown";
+ notes: string[];
+}
+
+export interface AgentIntegrationsSyncPayload {
+ agent_instance_id: string;
+ user_id: string;
+ synced_at: string;
+ integrations: Array<{
+ integration_id: string;
+ mcp_id: string;
+ slug: string;
+ name: string;
+ provider: string;
+ description: string;
+ status: string;
+ connected_account_email: string | null;
+ connected_account_name: string | null;
+ granted_scopes: string[];
+ required_scopes: string[];
+ token_expires_at: string | null;
+ last_refreshed_at: string | null;
+ error_message: string | null;
+ oauth_authorize_url: string;
+ oauth_token_url: string;
+ oauth_revoke_url: string | null;
+ supports_refresh_token: boolean;
+ access_token: string | null;
+ refresh_token: string | null;
+ runtime_mcp: RuntimeMcpHint;
+ created_at: string;
+ updated_at: string;
+ }>;
+}
+
+export interface AgentIntegrationsSyncResult {
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ synced_count: number;
+ response: unknown;
+}
+
+export interface AgentRuntimeSyncResult {
+ agent_instance_id: string;
+ public_url: string;
+ public_domain: string;
+ cronjobs_synced_count: number;
+ integrations_synced_count: number;
+ responses: {
+ cronjobs: unknown;
+ integrations: unknown;
+ };
+}
+
+function yamlQuoted(value: string): string {
+ return JSON.stringify(value ?? "");
+}
+
+function ensureStringArray(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+ : [];
+}
+
+function ensureSkillFrontmatter(skill: {
+ name: string;
+ description: string;
+ trigger_keywords: string;
+ markdown_content: string;
+}): string {
+ const markdown = (skill.markdown_content ?? "").trim();
+ if (markdown.startsWith("---")) {
+ return markdown;
+ }
+
+ return [
+ "---",
+ `name: ${yamlQuoted(skill.name)}`,
+ `description: ${yamlQuoted(skill.description)}`,
+ `trigger_keywords: ${yamlQuoted(skill.trigger_keywords)}`,
+ "---",
+ "",
+ markdown,
+ ].join("\n");
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function asNullableString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
+}
+
+function normalizeRuntimeState(value: unknown): "scheduled" | "paused" | "completed" | "error" | null {
+ switch (value) {
+ case "scheduled":
+ case "paused":
+ case "completed":
+ case "error":
+ return value;
+ default:
+ return null;
+ }
+}
+
+function normalizeRuntimeLastStatus(value: unknown): "ok" | "error" | null {
+ switch (value) {
+ case "ok":
+ case "error":
+ return value;
+ default:
+ return null;
+ }
+}
+
+function getOfficialRuntimeMcpHint(slug: string): RuntimeMcpHint {
+ switch (slug) {
+ case "notion":
+ return {
+ server_url: "https://mcp.notion.com/mcp",
+ transport: "streamable-http",
+ auth_mode: "oauth",
+ notes: [
+ "Official hosted MCP available via interactive OAuth.",
+ "Mika runtime currently uses native provider bridge tools for headless execution.",
+ ],
+ };
+ case "todoist":
+ return {
+ server_url: "https://ai.todoist.net/mcp",
+ transport: "streamable-http",
+ auth_mode: "oauth",
+ notes: [
+ "Official hosted MCP available via Todoist AI MCP docs.",
+ "Mika runtime currently uses native provider bridge tools for headless execution.",
+ ],
+ };
+ case "calcom":
+ return {
+ server_url: "https://mcp.cal.com/mcp",
+ transport: "streamable-http",
+ auth_mode: "oauth",
+ notes: [
+ "Official hosted MCP available via interactive OAuth.",
+ "Mika runtime currently uses native provider bridge tools for headless execution.",
+ ],
+ };
+ default:
+ return {
+ server_url: null,
+ transport: null,
+ auth_mode: "unknown",
+ notes: ["No official remote MCP endpoint mapped in Mika runtime yet."],
+ };
+ }
+}
+
+// deno-lint-ignore no-explicit-any
+async function loadAgentRecord(supabase: any, agentInstanceId: string): Promise {
+ const { data: agent, error: agentErr } = await supabase
+ .from("agent_instances")
+ .select("id, user_id, railway_service_id, vps_pool_id")
+ .eq("id", agentInstanceId)
+ .maybeSingle();
+
+ if (agentErr || !agent) {
+ throw new Error(`agent_instance not found: ${agentErr?.message ?? agentInstanceId}`);
+ }
+
+ return agent as AgentInstanceRecord;
+}
+
+async function loadAgentServiceTarget(
+ // deno-lint-ignore no-explicit-any
+ supabase: any,
+ agentInstanceId: string,
+ railwayToken: string,
+): Promise<{
+ agent: AgentInstanceRecord;
+ serviceId: string;
+ projectId: string;
+ environmentId: string;
+}> {
+ const agent = await loadAgentRecord(supabase, agentInstanceId);
+
+ if (!agent.railway_service_id) {
+ throw new Error("agent has no railway_service_id");
+ }
+
+ let projectId: string | null = null;
+ let environmentId: string | null = null;
+
+ if (agent.vps_pool_id) {
+ const { data: pool, error: poolErr } = await supabase
+ .from("vps_pool")
+ .select("railway_project_id, railway_environment_id")
+ .eq("id", agent.vps_pool_id)
+ .maybeSingle();
+ if (poolErr) {
+ throw new Error(`failed to load vps_pool for agent ${agent.id}: ${poolErr.message}`);
+ }
+ projectId = pool?.railway_project_id ?? null;
+ environmentId = pool?.railway_environment_id ?? null;
+ }
+
+ if (!projectId || !environmentId) {
+ const ctx = await getServiceContext({
+ token: railwayToken,
+ serviceId: agent.railway_service_id,
+ });
+ projectId = projectId ?? ctx.projectId;
+ environmentId = environmentId ?? ctx.environmentId;
+ }
+
+ if (!projectId || !environmentId) {
+ throw new Error("failed to resolve railway project/environment for agent");
+ }
+
+ return {
+ agent,
+ serviceId: agent.railway_service_id,
+ projectId,
+ environmentId,
+ };
+}
+
+async function resolveRuntimeTarget(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+}): Promise {
+ const target = await loadAgentServiceTarget(opts.supabase, opts.agentInstanceId, opts.railwayToken);
+ const publicDomain = await ensureRailwayServiceDomain({
+ token: opts.railwayToken,
+ serviceId: target.serviceId,
+ environmentId: target.environmentId,
+ projectId: target.projectId,
+ });
+
+ return {
+ serviceId: target.serviceId,
+ projectId: target.projectId,
+ environmentId: target.environmentId,
+ publicDomain: publicDomain.domain,
+ publicUrl: `https://${publicDomain.domain}`,
+ };
+}
+
+// deno-lint-ignore no-explicit-any
+async function vaultDecryptSecret(supabase: any, secretId: string | null): Promise {
+ if (!secretId) return null;
+
+ const { data, error } = await supabase
+ .rpc("vault_decrypt_secret", { secret_id: secretId })
+ .single();
+
+ if (error || !data) {
+ return null;
+ }
+
+ if (typeof data === "string") {
+ return data;
+ }
+
+ if (Array.isArray(data) && data.length > 0 && typeof data[0]?.decrypted_secret === "string") {
+ return data[0].decrypted_secret;
+ }
+
+ if (typeof (data as { decrypted_secret?: unknown }).decrypted_secret === "string") {
+ return (data as { decrypted_secret: string }).decrypted_secret;
+ }
+
+ return null;
+}
+
+async function postPayloadToAgent(opts: {
+ publicUrl: string;
+ apiKey: string;
+ path: string;
+ payload: unknown;
+ maxAttempts?: number;
+}): Promise {
+ const maxAttempts = opts.maxAttempts ?? 4;
+ let lastError: Error | null = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ const res = await fetch(`${opts.publicUrl.replace(/\/$/, "")}${opts.path}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${opts.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(opts.payload),
+ });
+
+ if (res.ok) {
+ return await res.json().catch(() => ({}));
+ }
+
+ const detail = await res.text().catch(() => "");
+ const shouldRetry = ![400, 401, 403].includes(res.status);
+ lastError = new Error(`${opts.path} HTTP ${res.status}: ${detail}`);
+ if (!shouldRetry || attempt === maxAttempts) {
+ throw lastError;
+ }
+ } catch (err) {
+ lastError = err instanceof Error ? err : new Error(String(err));
+ if (attempt === maxAttempts) {
+ throw lastError;
+ }
+ }
+
+ await sleep(attempt * 1000);
+ }
+
+ throw lastError ?? new Error(`${opts.path} sync failed`);
+}
+
+async function getPayloadFromAgent(opts: {
+ publicUrl: string;
+ apiKey: string;
+ path: string;
+ maxAttempts?: number;
+}): Promise {
+ const maxAttempts = opts.maxAttempts ?? 4;
+ let lastError: Error | null = null;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ const res = await fetch(`${opts.publicUrl.replace(/\/$/, "")}${opts.path}`, {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${opts.apiKey}`,
+ },
+ });
+
+ if (res.ok) {
+ return await res.json().catch(() => ({}));
+ }
+
+ const detail = await res.text().catch(() => "");
+ const shouldRetry = ![400, 401, 403].includes(res.status);
+ lastError = new Error(`${opts.path} HTTP ${res.status}: ${detail}`);
+ if (!shouldRetry || attempt === maxAttempts) {
+ throw lastError;
+ }
+ } catch (err) {
+ lastError = err instanceof Error ? err : new Error(String(err));
+ if (attempt === maxAttempts) {
+ throw lastError;
+ }
+ }
+
+ await sleep(attempt * 1000);
+ }
+
+ throw lastError ?? new Error(`${opts.path} fetch failed`);
+}
+
+async function pullCronjobsRuntimeStateFromTarget(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ publicUrl: string;
+ publicDomain: string;
+ apiKey: string;
+}): Promise {
+ const response = await getPayloadFromAgent({
+ publicUrl: opts.publicUrl,
+ apiKey: opts.apiKey,
+ path: "/api/cronjobs",
+ });
+
+ const runtimeCronjobs = Array.isArray((response as RuntimeCronjobsListResponse)?.cronjobs)
+ ? ((response as RuntimeCronjobsListResponse).cronjobs ?? [])
+ : [];
+
+ const syncedAt = new Date().toISOString();
+ let updatedCount = 0;
+
+ for (const runtimeJob of runtimeCronjobs) {
+ const jobId = asNullableString(runtimeJob?.id);
+ if (!jobId) continue;
+
+ const { error } = await opts.supabase
+ .from("scheduled_jobs")
+ .update({
+ last_run_at: asNullableString(runtimeJob.last_run_at),
+ next_run_at: asNullableString(runtimeJob.next_run_at),
+ runtime_state: normalizeRuntimeState(runtimeJob.state),
+ runtime_last_status: normalizeRuntimeLastStatus(runtimeJob.last_status),
+ runtime_last_error: asNullableString(runtimeJob.last_error),
+ runtime_last_delivery_error: asNullableString(runtimeJob.last_delivery_error),
+ runtime_synced_at: syncedAt,
+ })
+ .eq("id", jobId)
+ .eq("agent_instance_id", opts.agentInstanceId);
+
+ if (error) {
+ throw new Error(`failed to reconcile runtime cronjob ${jobId}: ${error.message}`);
+ }
+
+ updatedCount += 1;
+ }
+
+ await opts.supabase
+ .from("agent_instances")
+ .update({ last_health_check_at: syncedAt })
+ .eq("id", opts.agentInstanceId);
+
+ return {
+ agent_instance_id: opts.agentInstanceId,
+ public_url: opts.publicUrl,
+ public_domain: opts.publicDomain,
+ synced_count: runtimeCronjobs.length,
+ updated_count: updatedCount,
+ response,
+ };
+}
+
+export async function buildActiveSkillsPayload(
+ // deno-lint-ignore no-explicit-any
+ supabase: any,
+ agentInstanceId: string,
+): Promise {
+ const { data: skillsData, error: skillsErr } = await supabase
+ .from("skills")
+ .select("id, agent_instance_id, name, description, trigger_keywords, current_version_id, updated_at")
+ .eq("agent_instance_id", agentInstanceId)
+ .eq("status", "active")
+ .not("current_version_id", "is", null)
+ .order("updated_at", { ascending: false });
+
+ if (skillsErr) {
+ throw new Error(`failed to load skills: ${skillsErr.message}`);
+ }
+
+ const skills = (skillsData ?? []) as ActiveSkillRow[];
+ const versionIds = Array.from(
+ new Set(skills.map((skill) => skill.current_version_id).filter(Boolean)),
+ );
+
+ let versionsById = new Map();
+ if (versionIds.length > 0) {
+ const { data: versionsData, error: versionsErr } = await supabase
+ .from("skill_versions")
+ .select("id, version_number, markdown_content, form_inputs, created_at")
+ .in("id", versionIds);
+
+ if (versionsErr) {
+ throw new Error(`failed to load skill_versions: ${versionsErr.message}`);
+ }
+
+ versionsById = new Map(
+ ((versionsData ?? []) as SkillVersionRow[]).map((version) => [version.id, version]),
+ );
+ }
+
+ const payloadSkills = skills.flatMap((skill) => {
+ const version = versionsById.get(skill.current_version_id);
+ if (!version) return [];
+
+ return [{
+ skill_id: skill.id,
+ version_id: version.id,
+ version_number: version.version_number,
+ name: skill.name,
+ description: skill.description,
+ trigger_keywords: skill.trigger_keywords,
+ updated_at: skill.updated_at,
+ created_at: version.created_at,
+ form_inputs: version.form_inputs ?? null,
+ markdown_content: ensureSkillFrontmatter({
+ name: skill.name,
+ description: skill.description,
+ trigger_keywords: skill.trigger_keywords,
+ markdown_content: version.markdown_content,
+ }),
+ }];
+ });
+
+ return {
+ agent_instance_id: agentInstanceId,
+ synced_at: new Date().toISOString(),
+ skills: payloadSkills,
+ };
+}
+
+export async function pushSkillsPayloadToAgent(opts: {
+ publicUrl: string;
+ apiKey: string;
+ payload: AgentSkillsSyncPayload;
+ maxAttempts?: number;
+}): Promise {
+ return await postPayloadToAgent({
+ publicUrl: opts.publicUrl,
+ apiKey: opts.apiKey,
+ path: "/api/skills/sync",
+ payload: opts.payload,
+ maxAttempts: opts.maxAttempts,
+ });
+}
+
+export async function syncAgentSkillsSnapshot(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+ apiKey: string;
+}): Promise {
+ if (!opts.railwayToken) {
+ throw new Error("RAILWAY_API_TOKEN not configured");
+ }
+ if (!opts.apiKey) {
+ throw new Error("HERMES_API_SERVER_KEY not configured");
+ }
+
+ const payload = await buildActiveSkillsPayload(opts.supabase, opts.agentInstanceId);
+ const target = await resolveRuntimeTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ railwayToken: opts.railwayToken,
+ });
+ const response = await pushSkillsPayloadToAgent({
+ publicUrl: target.publicUrl,
+ apiKey: opts.apiKey,
+ payload,
+ });
+
+ return {
+ agent_instance_id: opts.agentInstanceId,
+ public_url: target.publicUrl,
+ public_domain: target.publicDomain,
+ synced_count: payload.skills.length,
+ response,
+ };
+}
+
+export async function buildManagedCronjobsPayload(
+ // deno-lint-ignore no-explicit-any
+ supabase: any,
+ agentInstanceId: string,
+): Promise {
+ const { data, error } = await supabase
+ .from("scheduled_jobs")
+ .select(
+ "id, agent_instance_id, name, description, natural_language_input, cron_expression, human_readable, action_prompt, required_mcp_slugs, status, auto_paused_reason, last_run_at, next_run_at, timezone, created_at, updated_at",
+ )
+ .eq("agent_instance_id", agentInstanceId)
+ .neq("status", "archived")
+ .order("created_at", { ascending: true });
+
+ if (error) {
+ throw new Error(`failed to load scheduled_jobs: ${error.message}`);
+ }
+
+ const rows = (data ?? []) as ScheduledJobRow[];
+
+ return {
+ agent_instance_id: agentInstanceId,
+ synced_at: new Date().toISOString(),
+ cronjobs: rows.map((job) => ({
+ job_id: job.id,
+ name: job.name,
+ description: job.description ?? null,
+ natural_language_input: job.natural_language_input,
+ cron_expression: job.cron_expression,
+ human_readable: job.human_readable,
+ action_prompt: job.action_prompt,
+ required_mcp_slugs: ensureStringArray(job.required_mcp_slugs),
+ status: job.status,
+ auto_paused_reason: job.auto_paused_reason ?? null,
+ last_run_at: job.last_run_at ?? null,
+ next_run_at: job.next_run_at ?? null,
+ timezone: job.timezone,
+ created_at: job.created_at,
+ updated_at: job.updated_at,
+ })),
+ };
+}
+
+export async function pushCronjobsPayloadToAgent(opts: {
+ publicUrl: string;
+ apiKey: string;
+ payload: AgentCronjobsSyncPayload;
+ maxAttempts?: number;
+}): Promise {
+ return await postPayloadToAgent({
+ publicUrl: opts.publicUrl,
+ apiKey: opts.apiKey,
+ path: "/api/cronjobs/sync",
+ payload: opts.payload,
+ maxAttempts: opts.maxAttempts,
+ });
+}
+
+export async function syncAgentCronjobsSnapshot(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+ apiKey: string;
+}): Promise {
+ if (!opts.railwayToken) {
+ throw new Error("RAILWAY_API_TOKEN not configured");
+ }
+ if (!opts.apiKey) {
+ throw new Error("HERMES_API_SERVER_KEY not configured");
+ }
+
+ const payload = await buildManagedCronjobsPayload(opts.supabase, opts.agentInstanceId);
+ const target = await resolveRuntimeTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ railwayToken: opts.railwayToken,
+ });
+ const response = await pushCronjobsPayloadToAgent({
+ publicUrl: target.publicUrl,
+ apiKey: opts.apiKey,
+ payload,
+ });
+ const pullResult = await pullCronjobsRuntimeStateFromTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ publicUrl: target.publicUrl,
+ publicDomain: target.publicDomain,
+ apiKey: opts.apiKey,
+ });
+
+ return {
+ agent_instance_id: opts.agentInstanceId,
+ public_url: target.publicUrl,
+ public_domain: target.publicDomain,
+ synced_count: payload.cronjobs.length,
+ response: {
+ push: response,
+ pull: pullResult.response,
+ updated_count: pullResult.updated_count,
+ },
+ };
+}
+
+export async function pullAgentCronjobsRuntimeState(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+ apiKey: string;
+}): Promise {
+ if (!opts.railwayToken) {
+ throw new Error("RAILWAY_API_TOKEN not configured");
+ }
+ if (!opts.apiKey) {
+ throw new Error("HERMES_API_SERVER_KEY not configured");
+ }
+
+ const target = await resolveRuntimeTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ railwayToken: opts.railwayToken,
+ });
+
+ return await pullCronjobsRuntimeStateFromTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ publicUrl: target.publicUrl,
+ publicDomain: target.publicDomain,
+ apiKey: opts.apiKey,
+ });
+}
+
+export async function buildManagedIntegrationsPayload(
+ // deno-lint-ignore no-explicit-any
+ supabase: any,
+ agentInstanceId: string,
+): Promise {
+ const agent = await loadAgentRecord(supabase, agentInstanceId);
+
+ const { data, error } = await supabase
+ .from("user_integrations")
+ .select(
+ "id, user_id, mcp_id, status, connected_account_email, connected_account_name, granted_scopes, token_expires_at, last_refreshed_at, error_message, access_token_vault_id, refresh_token_vault_id, created_at, updated_at, mcp:available_mcps(id, slug, name, provider, description, oauth_authorize_url, oauth_token_url, oauth_revoke_url, required_scopes, supports_refresh_token)",
+ )
+ .eq("user_id", agent.user_id)
+ .order("created_at", { ascending: true });
+
+ if (error) {
+ throw new Error(`failed to load user_integrations: ${error.message}`);
+ }
+
+ const rows = (data ?? []) as UserIntegrationRow[];
+ const integrations = [];
+
+ for (const row of rows) {
+ const mcp = Array.isArray(row.mcp) ? row.mcp[0] : row.mcp;
+ if (!mcp) continue;
+
+ const accessToken = row.status === "active"
+ ? await vaultDecryptSecret(supabase, row.access_token_vault_id)
+ : null;
+ const refreshToken = row.status === "active"
+ ? await vaultDecryptSecret(supabase, row.refresh_token_vault_id)
+ : null;
+
+ integrations.push({
+ integration_id: row.id,
+ mcp_id: row.mcp_id,
+ slug: mcp.slug,
+ name: mcp.name,
+ provider: mcp.provider,
+ description: mcp.description,
+ status: row.status,
+ connected_account_email: row.connected_account_email ?? null,
+ connected_account_name: row.connected_account_name ?? null,
+ granted_scopes: ensureStringArray(row.granted_scopes),
+ required_scopes: ensureStringArray(mcp.required_scopes),
+ token_expires_at: row.token_expires_at ?? null,
+ last_refreshed_at: row.last_refreshed_at ?? null,
+ error_message: row.error_message ?? null,
+ oauth_authorize_url: mcp.oauth_authorize_url,
+ oauth_token_url: mcp.oauth_token_url,
+ oauth_revoke_url: mcp.oauth_revoke_url ?? null,
+ supports_refresh_token: !!mcp.supports_refresh_token,
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ runtime_mcp: getOfficialRuntimeMcpHint(mcp.slug),
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ });
+ }
+
+ return {
+ agent_instance_id: agentInstanceId,
+ user_id: agent.user_id,
+ synced_at: new Date().toISOString(),
+ integrations,
+ };
+}
+
+export async function pushIntegrationsPayloadToAgent(opts: {
+ publicUrl: string;
+ apiKey: string;
+ payload: AgentIntegrationsSyncPayload;
+ maxAttempts?: number;
+}): Promise {
+ return await postPayloadToAgent({
+ publicUrl: opts.publicUrl,
+ apiKey: opts.apiKey,
+ path: "/api/integrations/sync",
+ payload: opts.payload,
+ maxAttempts: opts.maxAttempts,
+ });
+}
+
+export async function syncAgentIntegrationsSnapshot(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+ apiKey: string;
+}): Promise {
+ if (!opts.railwayToken) {
+ throw new Error("RAILWAY_API_TOKEN not configured");
+ }
+ if (!opts.apiKey) {
+ throw new Error("HERMES_API_SERVER_KEY not configured");
+ }
+
+ const payload = await buildManagedIntegrationsPayload(opts.supabase, opts.agentInstanceId);
+ const target = await resolveRuntimeTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ railwayToken: opts.railwayToken,
+ });
+ const response = await pushIntegrationsPayloadToAgent({
+ publicUrl: target.publicUrl,
+ apiKey: opts.apiKey,
+ payload,
+ });
+
+ return {
+ agent_instance_id: opts.agentInstanceId,
+ public_url: target.publicUrl,
+ public_domain: target.publicDomain,
+ synced_count: payload.integrations.length,
+ response,
+ };
+}
+
+export async function syncAgentRuntimeSnapshot(opts: {
+ // deno-lint-ignore no-explicit-any
+ supabase: any;
+ agentInstanceId: string;
+ railwayToken: string;
+ apiKey: string;
+ scope?: RuntimeSyncScope;
+}): Promise {
+ if (!opts.railwayToken) {
+ throw new Error("RAILWAY_API_TOKEN not configured");
+ }
+ if (!opts.apiKey) {
+ throw new Error("HERMES_API_SERVER_KEY not configured");
+ }
+
+ const scope = opts.scope ?? "all";
+ const target = await resolveRuntimeTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ railwayToken: opts.railwayToken,
+ });
+
+ let cronjobsSyncedCount = 0;
+ let integrationsSyncedCount = 0;
+ let cronjobsResponse: unknown = { skipped: true };
+ let integrationsResponse: unknown = { skipped: true };
+
+ if (scope === "cronjobs" || scope === "all") {
+ const cronjobsPayload = await buildManagedCronjobsPayload(opts.supabase, opts.agentInstanceId);
+ const cronjobsPushResponse = await pushCronjobsPayloadToAgent({
+ publicUrl: target.publicUrl,
+ apiKey: opts.apiKey,
+ payload: cronjobsPayload,
+ });
+ const cronjobsPullResult = await pullCronjobsRuntimeStateFromTarget({
+ supabase: opts.supabase,
+ agentInstanceId: opts.agentInstanceId,
+ publicUrl: target.publicUrl,
+ publicDomain: target.publicDomain,
+ apiKey: opts.apiKey,
+ });
+ cronjobsResponse = {
+ push: cronjobsPushResponse,
+ pull: cronjobsPullResult.response,
+ updated_count: cronjobsPullResult.updated_count,
+ };
+ cronjobsSyncedCount = cronjobsPayload.cronjobs.length;
+ }
+
+ if (scope === "integrations" || scope === "all") {
+ const integrationsPayload = await buildManagedIntegrationsPayload(opts.supabase, opts.agentInstanceId);
+ integrationsResponse = await pushIntegrationsPayloadToAgent({
+ publicUrl: target.publicUrl,
+ apiKey: opts.apiKey,
+ payload: integrationsPayload,
+ });
+ integrationsSyncedCount = integrationsPayload.integrations.length;
+ }
+
+ return {
+ agent_instance_id: opts.agentInstanceId,
+ public_url: target.publicUrl,
+ public_domain: target.publicDomain,
+ cronjobs_synced_count: cronjobsSyncedCount,
+ integrations_synced_count: integrationsSyncedCount,
+ responses: {
+ cronjobs: cronjobsResponse,
+ integrations: integrationsResponse,
+ },
+ };
+}
diff --git a/supabase/functions/disconnect-integration/index.ts b/supabase/functions/disconnect-integration/index.ts
index 4a940c8..7080753 100644
--- a/supabase/functions/disconnect-integration/index.ts
+++ b/supabase/functions/disconnect-integration/index.ts
@@ -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(
diff --git a/supabase/functions/keep-alive-agents/index.ts b/supabase/functions/keep-alive-agents/index.ts
index f306675..90aa1db 100644
--- a/supabase/functions/keep-alive-agents/index.ts
+++ b/supabase/functions/keep-alive-agents/index.ts
@@ -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);
});
diff --git a/supabase/functions/list-ollama-models/index.ts b/supabase/functions/list-ollama-models/index.ts
new file mode 100644
index 0000000..486d840
--- /dev/null
+++ b/supabase/functions/list-ollama-models/index.ts
@@ -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;
+}
+
+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,
+ );
+ }
+});
diff --git a/supabase/functions/oauth-callback/index.ts b/supabase/functions/oauth-callback/index.ts
index 17d0d2d..352150c 100644
--- a/supabase/functions/oauth-callback/index.ts
+++ b/supabase/functions/oauth-callback/index.ts
@@ -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");
diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts
index 6fb762c..1ac44c3 100644
--- a/supabase/functions/provision-agent/index.ts
+++ b/supabase/functions/provision-agent/index.ts
@@ -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 = {
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 = {
+ 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,
diff --git a/supabase/functions/publish-skill-version/index.ts b/supabase/functions/publish-skill-version/index.ts
index b7064a9..4edbb4c 100644
--- a/supabase/functions/publish-skill-version/index.ts
+++ b/supabase/functions/publish-skill-version/index.ts
@@ -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" } },
);
});
diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts
index 416a91e..4bd1af2 100644
--- a/supabase/functions/railway-webhook/index.ts
+++ b/supabase/functions/railway-webhook/index.ts
@@ -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 {
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(
+ `⚠️ Agente subiu, mas o sync operacional falhou\n\n` +
+ `👤 Cliente: ${fullName}\n` +
+ `🚀 Railway: ${agent.railway_service_id}\n` +
+ `❗ Erro: ${runtimeSyncError}\n\n` +
+ `➡️ Revisar no admin`,
+ );
+ }
+ }
+
+ 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(
+ `⚠️ Agente subiu, mas o sync de skills falhou\n\n` +
+ `👤 Cliente: ${fullName}\n` +
+ `🚀 Railway: ${agent.railway_service_id}\n` +
+ `❗ Erro: ${skillsSyncError}\n\n` +
+ `➡️ Revisar no admin`,
+ );
+ }
+ }
+
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
- if (wasProvisioning) {
+ if (wasProvisioning && !skillsSyncError && !runtimeSyncError) {
const fullName = await loadFullName();
await notifyAdmin(
`✅ Agente provisionado automaticamente!\n\n` +
diff --git a/supabase/functions/refresh-integration-token/index.ts b/supabase/functions/refresh-integration-token/index.ts
index 1de3936..8f19fb8 100644
--- a/supabase/functions/refresh-integration-token/index.ts
+++ b/supabase/functions/refresh-integration-token/index.ts
@@ -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(
diff --git a/supabase/functions/sync-agent-runtime/index.ts b/supabase/functions/sync-agent-runtime/index.ts
new file mode 100644
index 0000000..524d490
--- /dev/null
+++ b/supabase/functions/sync-agent-runtime/index.ts
@@ -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 });
+ }
+});
diff --git a/supabase/functions/sync-agent-skills/index.ts b/supabase/functions/sync-agent-skills/index.ts
new file mode 100644
index 0000000..4bdd14e
--- /dev/null
+++ b/supabase/functions/sync-agent-skills/index.ts
@@ -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 });
+ }
+});
diff --git a/supabase/functions/update-agent-config/index.ts b/supabase/functions/update-agent-config/index.ts
index 2491410..fdd82a1 100644
--- a/supabase/functions/update-agent-config/index.ts
+++ b/supabase/functions/update-agent-config/index.ts
@@ -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 = {
+ 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,
diff --git a/supabase/migrations/20260430121500_runtime_cronjob_observability.sql b/supabase/migrations/20260430121500_runtime_cronjob_observability.sql
new file mode 100644
index 0000000..bbfa0c6
--- /dev/null
+++ b/supabase/migrations/20260430121500_runtime_cronjob_observability.sql
@@ -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);