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

* Implement Mika runtime sync and go-live controls

* Add CI validation workflow

* Align CI with validated runtime checks

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

63
.github/workflows/validate.yml vendored Normal file
View file

@ -0,0 +1,63 @@
name: Validate Mika Agent Assist
on:
pull_request:
branches:
- main
push:
branches:
- main
- "codex/**"
workflow_dispatch:
jobs:
frontend-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build frontend
run: npm run build
edge-functions:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: 2.7.14
- name: Type-check edge functions
shell: bash
run: |
files=(
supabase/functions/_shared/hermes-config.ts
supabase/functions/_shared/runtime-sync.ts
supabase/functions/sync-agent-runtime/index.ts
supabase/functions/sync-agent-skills/index.ts
supabase/functions/keep-alive-agents/index.ts
supabase/functions/provision-agent/index.ts
supabase/functions/publish-skill-version/index.ts
supabase/functions/railway-webhook/index.ts
supabase/functions/update-agent-config/index.ts
supabase/functions/list-ollama-models/index.ts
supabase/functions/oauth-callback/index.ts
supabase/functions/disconnect-integration/index.ts
supabase/functions/refresh-integration-token/index.ts
)
deno check --config supabase/functions/deno.json "${files[@]}"

1804
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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) {
<h3 className="font-semibold truncate">{job.name}</h3>
{isActive && <Badge variant="success">Ativa</Badge>}
{job.status === "paused" && <Badge variant="secondary">Pausada</Badge>}
{job.status === "error" && <Badge variant="destructive">Erro</Badge>}
{isAutoPaused && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" /> Auto-pausada
</Badge>
)}
{hasRuntimeError && !isAutoPaused && (
<Badge variant="destructive">Runtime com erro</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{job.human_readable}
@ -92,6 +109,10 @@ export function CronjobCard({ job }: Props) {
<p className="text-xs text-destructive">{job.auto_paused_reason}</p>
)}
{hasRuntimeError && runtimeErrorText && (
<p className="text-xs text-destructive">{runtimeErrorText}</p>
)}
{missingMcps.length > 0 && isActive && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
<span className="text-amber-700 dark:text-amber-400 font-medium">

View file

@ -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";

View file

@ -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);

View file

@ -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"),
});

View file

@ -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;

View file

@ -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

35
src/lib/ollama-models.ts Normal file
View file

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

View file

@ -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,
});
}

View file

@ -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<SyncAgentSkillsResponse>("sync-agent-skills", {
agent_instance_id: agentInstanceId,
});
}

View file

@ -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<string, unknown>;
}
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<string, string | undefined>;
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<string, string>();
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() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
{modelOptions.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Catálogo carregado dinamicamente do Ollama Cloud.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

View file

@ -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() {
<div className="flex items-center gap-2 mt-2">
{isActive && <Badge variant="success">Ativa</Badge>}
{job.status === "paused" && <Badge variant="secondary">Pausada</Badge>}
{job.status === "error" && <Badge variant="destructive">Erro</Badge>}
{isAutoPaused && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" /> Auto-pausada
</Badge>
)}
{hasRuntimeError && !isAutoPaused && (
<Badge variant="destructive">Runtime com erro</Badge>
)}
</div>
</div>
<div className="flex gap-2">
@ -138,6 +156,22 @@ function CronjobDetailPage() {
</div>
)}
{hasRuntimeError && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4 text-sm">
<p className="font-medium text-destructive">Falha reportada pelo runtime</p>
<p className="text-muted-foreground mt-1">
{job.runtime_last_error ?? "O runtime marcou a última execução como erro."}
</p>
</div>
)}
{job.runtime_last_delivery_error && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
<p className="font-medium text-amber-700 dark:text-amber-400">Falha na entrega do resultado</p>
<p className="text-muted-foreground mt-1">{job.runtime_last_delivery_error}</p>
</div>
)}
{missingMcps.length > 0 && isActive && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
<p className="font-medium text-amber-700 dark:text-amber-400">
@ -182,6 +216,23 @@ function CronjobDetailPage() {
: "Nunca"
}
/>
<Section label="Estado no runtime" value={formatRuntimeState(job.runtime_state)} />
<Section
label="Último resultado do runtime"
value={formatRuntimeLastStatus(job.runtime_last_status)}
/>
<Section
label="Última sincronização do runtime"
value={
job.runtime_synced_at
? new Date(job.runtime_synced_at).toLocaleString("pt-BR", {
timeZone: job.timezone,
dateStyle: "full",
timeStyle: "short",
})
: "Ainda não sincronizado"
}
/>
<Section label="Fuso horário" value={job.timezone} />
<Section label="Descrição original (NL)" value={job.natural_language_input} />
{job.description && <Section label="Notas" value={job.description} />}
@ -265,3 +316,29 @@ function Section({
</div>
);
}
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";
}
}

View file

@ -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"] });
}

View file

@ -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"),

View file

@ -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!");
}

View file

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

View file

@ -4,15 +4,13 @@ const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2";
/**
* Start command padrão dos containers Hermes.
* - Verifica HERMES_SUSPENDED no início: se true, dorme infinitamente (agente "pausado")
* - Aplica HERMES_SOUL_OVERRIDE em /opt/data/SOUL.md se presente
* - Inicia o gateway Hermes
* - Encaminha para o entrypoint custom da imagem `hermes-agent-custom`
* - O próprio entrypoint aplica SOUL.md, model/provider, STT/TTS e suspensão
*
* IMPORTANTE: este comando deve ser idêntico ao configurado nos serviços Railway
* existentes. Para serviços antigos, atualize manualmente via UI/Agent do Railway.
*/
export const HERMES_START_COMMAND =
`/bin/bash -c 'if [ "$HERMES_SUSPENDED" = "true" ]; then echo "Agent suspended" && sleep infinity; fi && if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/SOUL.md; fi && /opt/hermes/docker/entrypoint.sh gateway run'`;
export const HERMES_START_COMMAND = `/opt/hermes-custom/entrypoint.sh`;
export interface RailwayError {
message: string;
@ -308,6 +306,151 @@ export async function getServiceEnvironmentId(opts: {
return (await getServiceContext(opts)).environmentId;
}
export interface RailwayServiceDomainInfo {
id: string;
domain: string;
suffix?: string | null;
certificateStatus?: string | null;
}
export async function listRailwayServiceDomains(opts: {
token: string;
serviceId: string;
environmentId: string;
projectId?: string | null;
}): Promise<{ serviceDomains: RailwayServiceDomainInfo[]; customDomains: RailwayServiceDomainInfo[] }> {
const query = `
query Domains($environmentId: String!, $serviceId: String!, $projectId: String) {
domains(environmentId: $environmentId, serviceId: $serviceId, projectId: $projectId) {
serviceDomains {
id
domain
suffix
}
customDomains {
id
domain
status {
certificateStatus
}
}
}
}
`;
const res = await railwayQuery<{
domains: {
serviceDomains?: { id: string; domain: string; suffix?: string | null }[];
customDomains?: { id: string; domain: string; status?: { certificateStatus?: string | null } | null }[];
};
}>(
query,
{
environmentId: opts.environmentId,
serviceId: opts.serviceId,
projectId: opts.projectId ?? null,
},
opts.token,
);
if (res.errors?.length) {
throw new Error(`domains query failed: ${JSON.stringify(res.errors)}`);
}
const serviceDomains = (res.data?.domains?.serviceDomains ?? []).map((item) => ({
id: item.id,
domain: item.domain,
suffix: item.suffix ?? null,
certificateStatus: "ISSUED",
}));
const customDomains = (res.data?.domains?.customDomains ?? []).map((item) => ({
id: item.id,
domain: item.domain,
certificateStatus: item.status?.certificateStatus ?? null,
}));
return { serviceDomains, customDomains };
}
export async function createRailwayServiceDomain(opts: {
token: string;
serviceId: string;
environmentId: string;
targetPort?: number;
}): Promise<RailwayServiceDomainInfo> {
const mutation = `
mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) {
serviceDomainCreate(input: $input) {
id
domain
suffix
}
}
`;
const input: Record<string, unknown> = {
serviceId: opts.serviceId,
environmentId: opts.environmentId,
};
if (opts.targetPort !== undefined) {
input.targetPort = opts.targetPort;
}
const res = await railwayQuery<{
serviceDomainCreate: { id: string; domain: string; suffix?: string | null };
}>(mutation, { input }, opts.token);
if (res.errors?.length) {
throw new Error(`serviceDomainCreate failed: ${JSON.stringify(res.errors)}`);
}
const created = res.data?.serviceDomainCreate;
if (!created?.domain) {
throw new Error("serviceDomainCreate returned no domain");
}
return {
id: created.id,
domain: created.domain,
suffix: created.suffix ?? null,
certificateStatus: "ISSUED",
};
}
export async function ensureRailwayServiceDomain(opts: {
token: string;
serviceId: string;
environmentId: string;
projectId?: string | null;
targetPort?: number;
}): Promise<RailwayServiceDomainInfo> {
const existing = await listRailwayServiceDomains({
token: opts.token,
serviceId: opts.serviceId,
environmentId: opts.environmentId,
projectId: opts.projectId,
});
const serviceDomain = existing.serviceDomains.find((item) => item.domain);
if (serviceDomain) {
return serviceDomain;
}
const issuedCustomDomain = existing.customDomains.find((item) =>
item.domain && (!item.certificateStatus || item.certificateStatus === "ISSUED")
);
if (issuedCustomDomain) {
return issuedCustomDomain;
}
return await createRailwayServiceDomain({
token: opts.token,
serviceId: opts.serviceId,
environmentId: opts.environmentId,
targetPort: opts.targetPort,
});
}
/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */
export async function deleteTelegramWebhook(botToken: string): Promise<void> {
const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`;

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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