mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 04:16:40 +00:00
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:
parent
cb54fa4666
commit
d87ea2657c
31 changed files with 4019 additions and 95 deletions
63
.github/workflows/validate.yml
vendored
Normal file
63
.github/workflows/validate.yml
vendored
Normal 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
1804
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
35
src/lib/ollama-models.ts
Normal 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;
|
||||
}
|
||||
24
src/lib/sync-agent-runtime.ts
Normal file
24
src/lib/sync-agent-runtime.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
18
src/lib/sync-agent-skills.ts
Normal file
18
src/lib/sync-agent-skills.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
}
|
||||
|
|
|
|||
23
supabase/functions/_shared/hermes-config.ts
Normal file
23
supabase/functions/_shared/hermes-config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud";
|
||||
export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud";
|
||||
|
||||
const LEGACY_MODEL_ALIASES: Record<string, string> = {
|
||||
"openrouter/google/gemma-4-27b-a4b-it": DEFAULT_OLLAMA_MODEL,
|
||||
"openrouter/google/gemma-4-31b-it": DEFAULT_OLLAMA_MODEL,
|
||||
"ollama-cloud/gemma4:31b-cloud": DEFAULT_OLLAMA_MODEL,
|
||||
};
|
||||
|
||||
export function normalizeOllamaModelSelection(value?: string | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_OLLAMA_MODEL;
|
||||
|
||||
const mapped = LEGACY_MODEL_ALIASES[trimmed];
|
||||
if (mapped) return mapped;
|
||||
|
||||
if (trimmed.includes("/") && trimmed.includes(":")) {
|
||||
const candidate = trimmed.split("/").pop()?.trim();
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
|
@ -4,15 +4,13 @@ const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2";
|
|||
|
||||
/**
|
||||
* Start command padrão dos containers Hermes.
|
||||
* - Verifica HERMES_SUSPENDED no início: se true, dorme infinitamente (agente "pausado")
|
||||
* - Aplica HERMES_SOUL_OVERRIDE em /opt/data/SOUL.md se presente
|
||||
* - Inicia o gateway Hermes
|
||||
* - Encaminha para o entrypoint custom da imagem `hermes-agent-custom`
|
||||
* - O próprio entrypoint aplica SOUL.md, model/provider, STT/TTS e suspensão
|
||||
*
|
||||
* IMPORTANTE: este comando deve ser idêntico ao configurado nos serviços Railway
|
||||
* existentes. Para serviços antigos, atualize manualmente via UI/Agent do Railway.
|
||||
*/
|
||||
export const HERMES_START_COMMAND =
|
||||
`/bin/bash -c 'if [ "$HERMES_SUSPENDED" = "true" ]; then echo "Agent suspended" && sleep infinity; fi && if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/SOUL.md; fi && /opt/hermes/docker/entrypoint.sh gateway run'`;
|
||||
export const HERMES_START_COMMAND = `/opt/hermes-custom/entrypoint.sh`;
|
||||
|
||||
export interface RailwayError {
|
||||
message: string;
|
||||
|
|
@ -308,6 +306,151 @@ export async function getServiceEnvironmentId(opts: {
|
|||
return (await getServiceContext(opts)).environmentId;
|
||||
}
|
||||
|
||||
export interface RailwayServiceDomainInfo {
|
||||
id: string;
|
||||
domain: string;
|
||||
suffix?: string | null;
|
||||
certificateStatus?: string | null;
|
||||
}
|
||||
|
||||
export async function listRailwayServiceDomains(opts: {
|
||||
token: string;
|
||||
serviceId: string;
|
||||
environmentId: string;
|
||||
projectId?: string | null;
|
||||
}): Promise<{ serviceDomains: RailwayServiceDomainInfo[]; customDomains: RailwayServiceDomainInfo[] }> {
|
||||
const query = `
|
||||
query Domains($environmentId: String!, $serviceId: String!, $projectId: String) {
|
||||
domains(environmentId: $environmentId, serviceId: $serviceId, projectId: $projectId) {
|
||||
serviceDomains {
|
||||
id
|
||||
domain
|
||||
suffix
|
||||
}
|
||||
customDomains {
|
||||
id
|
||||
domain
|
||||
status {
|
||||
certificateStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const res = await railwayQuery<{
|
||||
domains: {
|
||||
serviceDomains?: { id: string; domain: string; suffix?: string | null }[];
|
||||
customDomains?: { id: string; domain: string; status?: { certificateStatus?: string | null } | null }[];
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
environmentId: opts.environmentId,
|
||||
serviceId: opts.serviceId,
|
||||
projectId: opts.projectId ?? null,
|
||||
},
|
||||
opts.token,
|
||||
);
|
||||
|
||||
if (res.errors?.length) {
|
||||
throw new Error(`domains query failed: ${JSON.stringify(res.errors)}`);
|
||||
}
|
||||
|
||||
const serviceDomains = (res.data?.domains?.serviceDomains ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
domain: item.domain,
|
||||
suffix: item.suffix ?? null,
|
||||
certificateStatus: "ISSUED",
|
||||
}));
|
||||
|
||||
const customDomains = (res.data?.domains?.customDomains ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
domain: item.domain,
|
||||
certificateStatus: item.status?.certificateStatus ?? null,
|
||||
}));
|
||||
|
||||
return { serviceDomains, customDomains };
|
||||
}
|
||||
|
||||
export async function createRailwayServiceDomain(opts: {
|
||||
token: string;
|
||||
serviceId: string;
|
||||
environmentId: string;
|
||||
targetPort?: number;
|
||||
}): Promise<RailwayServiceDomainInfo> {
|
||||
const mutation = `
|
||||
mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) {
|
||||
serviceDomainCreate(input: $input) {
|
||||
id
|
||||
domain
|
||||
suffix
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const input: Record<string, unknown> = {
|
||||
serviceId: opts.serviceId,
|
||||
environmentId: opts.environmentId,
|
||||
};
|
||||
if (opts.targetPort !== undefined) {
|
||||
input.targetPort = opts.targetPort;
|
||||
}
|
||||
|
||||
const res = await railwayQuery<{
|
||||
serviceDomainCreate: { id: string; domain: string; suffix?: string | null };
|
||||
}>(mutation, { input }, opts.token);
|
||||
|
||||
if (res.errors?.length) {
|
||||
throw new Error(`serviceDomainCreate failed: ${JSON.stringify(res.errors)}`);
|
||||
}
|
||||
|
||||
const created = res.data?.serviceDomainCreate;
|
||||
if (!created?.domain) {
|
||||
throw new Error("serviceDomainCreate returned no domain");
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
domain: created.domain,
|
||||
suffix: created.suffix ?? null,
|
||||
certificateStatus: "ISSUED",
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureRailwayServiceDomain(opts: {
|
||||
token: string;
|
||||
serviceId: string;
|
||||
environmentId: string;
|
||||
projectId?: string | null;
|
||||
targetPort?: number;
|
||||
}): Promise<RailwayServiceDomainInfo> {
|
||||
const existing = await listRailwayServiceDomains({
|
||||
token: opts.token,
|
||||
serviceId: opts.serviceId,
|
||||
environmentId: opts.environmentId,
|
||||
projectId: opts.projectId,
|
||||
});
|
||||
|
||||
const serviceDomain = existing.serviceDomains.find((item) => item.domain);
|
||||
if (serviceDomain) {
|
||||
return serviceDomain;
|
||||
}
|
||||
|
||||
const issuedCustomDomain = existing.customDomains.find((item) =>
|
||||
item.domain && (!item.certificateStatus || item.certificateStatus === "ISSUED")
|
||||
);
|
||||
if (issuedCustomDomain) {
|
||||
return issuedCustomDomain;
|
||||
}
|
||||
|
||||
return await createRailwayServiceDomain({
|
||||
token: opts.token,
|
||||
serviceId: opts.serviceId,
|
||||
environmentId: opts.environmentId,
|
||||
targetPort: opts.targetPort,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */
|
||||
export async function deleteTelegramWebhook(botToken: string): Promise<void> {
|
||||
const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`;
|
||||
|
|
|
|||
1035
supabase/functions/_shared/runtime-sync.ts
Normal file
1035
supabase/functions/_shared/runtime-sync.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,10 @@
|
|||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { revokeToken, type ProviderSlug } from "../_shared/oauth-providers.ts";
|
||||
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
|
|
@ -164,8 +168,36 @@ Deno.serve(async (req) => {
|
|||
return jsonResponse({ error: "Falha ao remover integração" }, 500);
|
||||
}
|
||||
|
||||
// TODO Fase 5: notify Hermes container that MCP was disconnected
|
||||
return jsonResponse({ success: true, paused_jobs_count: pausedCount });
|
||||
const { data: agent } = await admin
|
||||
.from("agent_instances")
|
||||
.select("id")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
let runtimeSyncError: string | null = null;
|
||||
if (agent?.id) {
|
||||
try {
|
||||
await syncAgentRuntimeSnapshot({
|
||||
supabase: admin,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
scope: "all",
|
||||
});
|
||||
} catch (syncErr) {
|
||||
runtimeSyncError = syncErr instanceof Error ? syncErr.message : "unknown";
|
||||
console.error(
|
||||
"disconnect-integration runtime sync warning",
|
||||
runtimeSyncError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
paused_jobs_count: pausedCount,
|
||||
runtime_sync_warning: runtimeSyncError,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("disconnect-integration fatal", err instanceof Error ? err.message : "unknown");
|
||||
return jsonResponse(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// keep-alive-agents
|
||||
// Mantém todos os containers Railway dos agentes ativos "acordados" fazendo
|
||||
// uma request GET /getMe ao Telegram para cada agente. Isso força tráfego de
|
||||
// saída no container, evitando que o Railway hiberne instâncias ociosas.
|
||||
// Mantém todos os containers Railway dos agentes ativos "acordados" e aproveita
|
||||
// o ciclo para reconciliar o estado operacional dos cronjobs de volta no banco.
|
||||
//
|
||||
// Estratégia:
|
||||
// 1. Faz GET /getMe no Telegram quando o agente já tem bot configurado
|
||||
// 2. Puxa /api/cronjobs do runtime Hermes e atualiza scheduled_jobs
|
||||
//
|
||||
// Substitui completamente o UptimeRobot — não precisa de configuração externa
|
||||
// por agente. Roda via pg_cron a cada 4 minutos.
|
||||
|
|
@ -10,14 +13,18 @@
|
|||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { pullAgentCronjobsRuntimeState } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
interface AgentRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
telegram_bot_token_vault_id: string;
|
||||
railway_service_id: string | null;
|
||||
telegram_bot_token_vault_id: string | null;
|
||||
telegram_bot_username: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -28,12 +35,11 @@ Deno.serve(async (req) => {
|
|||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// 1) Buscar agentes ativos com token configurado
|
||||
// 1) Buscar agentes ativos; Telegram e runtime são tratados separadamente
|
||||
const { data: agents, error: agentsErr } = await supabase
|
||||
.from("agent_instances")
|
||||
.select("id, user_id, telegram_bot_token_vault_id, telegram_bot_username")
|
||||
.eq("status", "active")
|
||||
.not("telegram_bot_token_vault_id", "is", null);
|
||||
.select("id, user_id, railway_service_id, telegram_bot_token_vault_id, telegram_bot_username")
|
||||
.eq("status", "active");
|
||||
|
||||
if (agentsErr) {
|
||||
console.error("keep-alive: failed to load agents:", agentsErr.message);
|
||||
|
|
@ -41,12 +47,18 @@ 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,
|
||||
|
|
@ -54,30 +66,59 @@ Deno.serve(async (req) => {
|
|||
|
||||
if (secretErr || !secret?.[0]?.decrypted_secret) {
|
||||
console.warn(`keep-alive: missing token for agent ${agent.id} (${agent.telegram_bot_username ?? "?"})`);
|
||||
failed++;
|
||||
return;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
failed++;
|
||||
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;
|
||||
}
|
||||
|
||||
success++;
|
||||
try {
|
||||
await pullAgentCronjobsRuntimeState({
|
||||
supabase,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
});
|
||||
runtimeSyncSuccess++;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn(`keep-alive: exception for agent ${agent.id}: ${msg}`);
|
||||
failed++;
|
||||
console.warn(`keep-alive: runtime sync exception for agent ${agent.id}: ${msg}`);
|
||||
runtimeSyncFailed++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = { total: list.length, success, failed };
|
||||
const summary = {
|
||||
total: list.length,
|
||||
telegram_success: telegramSuccess,
|
||||
telegram_failed: telegramFailed,
|
||||
telegram_skipped: telegramSkipped,
|
||||
runtime_sync_enabled: runtimeSyncEnabled,
|
||||
runtime_sync_success: runtimeSyncSuccess,
|
||||
runtime_sync_failed: runtimeSyncFailed,
|
||||
runtime_sync_skipped: runtimeSyncSkipped,
|
||||
};
|
||||
console.log("keep-alive summary:", JSON.stringify(summary));
|
||||
return jsonResponse(200, summary);
|
||||
});
|
||||
|
|
|
|||
110
supabase/functions/list-ollama-models/index.ts
Normal file
110
supabase/functions/list-ollama-models/index.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import {
|
||||
DEFAULT_OLLAMA_MODEL,
|
||||
normalizeOllamaModelSelection,
|
||||
} from "../_shared/hermes-config.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const OLLAMA_API_KEY = Deno.env.get("OLLAMA_API_KEY");
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
interface OllamaTagModel {
|
||||
name?: string;
|
||||
model?: string;
|
||||
modified_at?: string;
|
||||
size?: number;
|
||||
digest?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const jwt = authHeader.replace(/^Bearer\s+/i, "");
|
||||
if (!jwt) return jsonResponse({ error: "missing authorization" }, 401);
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const { data: userData, error: userErr } = await admin.auth.getUser(jwt);
|
||||
if (userErr || !userData?.user) {
|
||||
return jsonResponse({ error: "invalid token" }, 401);
|
||||
}
|
||||
|
||||
const { data: isAdmin, error: roleErr } = await admin.rpc("has_role", {
|
||||
_user_id: userData.user.id,
|
||||
_role: "admin",
|
||||
});
|
||||
if (roleErr || !isAdmin) {
|
||||
return jsonResponse({ error: "admin role required" }, 403);
|
||||
}
|
||||
|
||||
if (!OLLAMA_API_KEY) {
|
||||
return jsonResponse({ error: "OLLAMA_API_KEY not configured" }, 500);
|
||||
}
|
||||
|
||||
const res = await fetch("https://ollama.com/api/tags", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${OLLAMA_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return jsonResponse(
|
||||
{
|
||||
error: `ollama tags request failed: ${res.status}`,
|
||||
detail: text,
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const models = Array.isArray(payload?.models) ? (payload.models as OllamaTagModel[]) : [];
|
||||
|
||||
const normalized = models
|
||||
.map((item) => {
|
||||
const raw = item.name || item.model || "";
|
||||
const name = normalizeOllamaModelSelection(raw);
|
||||
return {
|
||||
name,
|
||||
raw_name: raw,
|
||||
modified_at: item.modified_at ?? null,
|
||||
size: item.size ?? null,
|
||||
digest: item.digest ?? null,
|
||||
details: item.details ?? {},
|
||||
};
|
||||
})
|
||||
.filter((item) => /(?:-cloud|:cloud)$/.test(item.name))
|
||||
.sort((a, b) => {
|
||||
if (a.name === DEFAULT_OLLAMA_MODEL) return -1;
|
||||
if (b.name === DEFAULT_OLLAMA_MODEL) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
models: normalized,
|
||||
default_model: DEFAULT_OLLAMA_MODEL,
|
||||
source_endpoint: "https://ollama.com/api/tags",
|
||||
});
|
||||
} catch (err) {
|
||||
return jsonResponse(
|
||||
{ error: err instanceof Error ? err.message : "unexpected error" },
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -9,6 +9,10 @@ import {
|
|||
getProviderEnv,
|
||||
type ProviderSlug,
|
||||
} from "../_shared/oauth-providers.ts";
|
||||
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
function siteUrl(): string {
|
||||
return Deno.env.get("SITE_URL") ?? "https://798b89e5-0dc6-412a-81be-a4b6dfea7b6c.lovable.app";
|
||||
|
|
@ -175,7 +179,29 @@ Deno.serve(async (req) => {
|
|||
return redirect("/painel/integracoes?error=db_error");
|
||||
}
|
||||
|
||||
// TODO Fase 5: notify Hermes container that MCP is now available
|
||||
const { data: agent } = await admin
|
||||
.from("agent_instances")
|
||||
.select("id")
|
||||
.eq("user_id", stateRow.user_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (agent?.id) {
|
||||
try {
|
||||
await syncAgentRuntimeSnapshot({
|
||||
supabase: admin,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
scope: "all",
|
||||
});
|
||||
} catch (syncErr) {
|
||||
console.error(
|
||||
"oauth-callback runtime sync warning",
|
||||
syncErr instanceof Error ? syncErr.message : String(syncErr),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(`/painel/integracoes?status=success&mcp=${encodeURIComponent(slug)}`);
|
||||
} catch (err) {
|
||||
console.error("oauth-callback fatal", err instanceof Error ? err.message : "unknown");
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import {
|
|||
findRailwayServiceByName,
|
||||
upsertRailwayVariableCollection,
|
||||
} from "../_shared/railway.ts";
|
||||
import {
|
||||
DEFAULT_OLLAMA_MODEL,
|
||||
DEFAULT_OLLAMA_PROVIDER,
|
||||
normalizeOllamaModelSelection,
|
||||
} from "../_shared/hermes-config.ts";
|
||||
|
||||
interface RequestBody {
|
||||
agent_instance_id: string;
|
||||
|
|
@ -128,8 +133,7 @@ Deno.serve(async (req) => {
|
|||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
|
||||
const isPro = ["professional", "enterprise"].includes(planSlug);
|
||||
console.log(`[provision-agent] plano=${planSlug} isPro=${isPro}`);
|
||||
console.log(`[provision-agent] plano=${planSlug}`);
|
||||
|
||||
// 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade)
|
||||
const { data: pool, error: poolErr } = await supabase
|
||||
|
|
@ -215,11 +219,15 @@ Deno.serve(async (req) => {
|
|||
const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DomCo. exclusivamente para ${fullName}. Seu estilo: Direto e objetivo, sempre em português brasileiro, respostas curtas no Telegram, use emojis com moderação, trate ${firstName} pelo primeiro nome. Suas prioridades: produtividade, automação proativa. Identidade: você é ${agentName} da DomCo., nunca se identifique como Hermes ou qualquer outro modelo.`;
|
||||
const soulContent = body.soul_content?.trim() || defaultSoul;
|
||||
|
||||
const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
|
||||
|
||||
const envVars: Record<string, string> = {
|
||||
HERMES_HOME: "/opt/data/.hermes",
|
||||
API_SERVER_ENABLED: "true",
|
||||
API_SERVER_KEY: Deno.env.get("HERMES_API_SERVER_KEY") ?? "",
|
||||
GATEWAY_ALLOW_ALL_USERS: "false",
|
||||
HERMES_MODEL_DEFAULT: modelFinal,
|
||||
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
|
||||
HERMES_SOUL_OVERRIDE: soulContent,
|
||||
HERMES_STT_PROVIDER: sttProvider,
|
||||
HERMES_TTS_PROVIDER: ttsProvider,
|
||||
|
|
@ -230,10 +238,7 @@ Deno.serve(async (req) => {
|
|||
TELEGRAM_HOME_CHANNEL: chatIdStr,
|
||||
};
|
||||
|
||||
// Modelo é definido pelo config.yaml embutido na imagem custom (ollama-cloud + gemma4:31b-cloud).
|
||||
// NÃO injetar HERMES_MODEL como env var — sobrescreve o config.yaml e quebra o bot.
|
||||
const agentNameFinal = agentName;
|
||||
const modelFinal = isPro ? "ollama-cloud/gemma4:31b-cloud" : "ollama-cloud/gemma4:31b-cloud";
|
||||
|
||||
// 7) Criar serviço no Railway
|
||||
const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`;
|
||||
|
|
@ -307,7 +312,8 @@ Deno.serve(async (req) => {
|
|||
vps_pool_id: pool.id,
|
||||
agent_name: agentNameFinal,
|
||||
model_config: {
|
||||
provider: modelFinal,
|
||||
provider: DEFAULT_OLLAMA_PROVIDER,
|
||||
model: modelFinal,
|
||||
stt: sttProvider,
|
||||
tts: ttsProvider,
|
||||
agent_name: agentNameFinal,
|
||||
|
|
@ -431,15 +437,10 @@ async function handleUpdateExistingService(
|
|||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
|
||||
const isPro = ["professional", "enterprise"].includes(planSlug);
|
||||
|
||||
const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`;
|
||||
const soulContent = body.soul_content?.trim() || defaultSoul;
|
||||
|
||||
const defaultModel = isPro
|
||||
? "openrouter/google/gemma-4-31b-it"
|
||||
: "openrouter/google/gemma-4-27b-a4b-it";
|
||||
const model = body.model || defaultModel;
|
||||
const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
|
||||
const sttProvider = body.stt_provider || "local";
|
||||
const ttsProvider = body.tts_provider || "disabled";
|
||||
|
||||
|
|
@ -470,10 +471,9 @@ async function handleUpdateExistingService(
|
|||
return jsonResponse(500, { error: "failed to resolve railway project/environment" });
|
||||
}
|
||||
|
||||
// Upsert das vars principais (não mexemos em token Telegram aqui — preservado)
|
||||
// NÃO injetar HERMES_MODEL: a imagem custom já tem config.yaml com ollama-cloud/gemma4:31b-cloud.
|
||||
// Sobrescrever via env var quebra o bot (model: "" / 404 not found).
|
||||
const variables: Record<string, string> = {
|
||||
HERMES_MODEL_DEFAULT: model,
|
||||
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
|
||||
HERMES_SOUL_OVERRIDE: soulContent,
|
||||
HERMES_STT_PROVIDER: sttProvider,
|
||||
HERMES_TTS_PROVIDER: ttsProvider,
|
||||
|
|
@ -515,7 +515,8 @@ async function handleUpdateExistingService(
|
|||
.from("agent_instances")
|
||||
.update({
|
||||
model_config: {
|
||||
provider: model,
|
||||
provider: DEFAULT_OLLAMA_PROVIDER,
|
||||
model,
|
||||
stt: sttProvider,
|
||||
tts: ttsProvider,
|
||||
agent_name: agentName,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// Promove uma skill_version a "live" de forma atômica e idempotente.
|
||||
// Garantia adicional: unique index parcial skill_versions_one_live_per_skill no banco.
|
||||
// TODO Fase 5: dispatch SSH deploy to container after publish
|
||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ Deno.serve(async (req) => {
|
|||
// Carrega versão + skill (verifica ownership e estado atual)
|
||||
const { data: versionRow, error: vErr } = await admin
|
||||
.from("skill_versions")
|
||||
.select("id, skill_id, version_number, is_live, skills!inner(id, user_id)")
|
||||
.select("id, skill_id, version_number, is_live, skills!inner(id, user_id, agent_instance_id)")
|
||||
.eq("id", skill_version_id)
|
||||
.maybeSingle();
|
||||
|
||||
|
|
@ -81,6 +83,8 @@ Deno.serve(async (req) => {
|
|||
}
|
||||
|
||||
const skillId: string = versionRow.skill_id;
|
||||
// @ts-expect-error nested
|
||||
const agentInstanceId: string = versionRow.skills.agent_instance_id;
|
||||
|
||||
// Postgres não permite transação multi-statement via supabase-js.
|
||||
// Estratégia: 1) zera todos is_live da skill, 2) marca a alvo como live, 3) atualiza skills.
|
||||
|
|
@ -141,8 +145,35 @@ Deno.serve(async (req) => {
|
|||
});
|
||||
}
|
||||
|
||||
let syncResult:
|
||||
| { synced: true; public_url: string; public_domain: string; synced_count: number }
|
||||
| { synced: false; sync_error: string } = { synced: true, public_url: "", public_domain: "", synced_count: 0 };
|
||||
|
||||
try {
|
||||
const result = await syncAgentSkillsSnapshot({
|
||||
supabase: admin,
|
||||
agentInstanceId,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
});
|
||||
syncResult = {
|
||||
synced: true,
|
||||
public_url: result.public_url,
|
||||
public_domain: result.public_domain,
|
||||
synced_count: result.synced_count,
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("publish skill sync failed:", msg);
|
||||
syncResult = { synced: false, sync_error: msg };
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, version_number: versionRow.version_number }),
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
version_number: versionRow.version_number,
|
||||
...syncResult,
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,11 +10,17 @@
|
|||
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import {
|
||||
syncAgentRuntimeSnapshot,
|
||||
syncAgentSkillsSnapshot,
|
||||
} from "../_shared/runtime-sync.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN");
|
||||
const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID");
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
async function notifyAdmin(message: string): Promise<void> {
|
||||
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
|
||||
|
|
@ -138,8 +144,61 @@ Deno.serve(async (req) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
|
||||
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 && !skillsSyncError && !runtimeSyncError) {
|
||||
const fullName = await loadFullName();
|
||||
await notifyAdmin(
|
||||
`✅ <b>Agente provisionado automaticamente!</b>\n\n` +
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import {
|
|||
type ProviderSlug,
|
||||
refreshAccessToken,
|
||||
} from "../_shared/oauth-providers.ts";
|
||||
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
|
|
@ -174,7 +178,33 @@ Deno.serve(async (req) => {
|
|||
})
|
||||
.eq("id", integration_id);
|
||||
|
||||
return jsonResponse({ success: true, expires_at: expiresAt });
|
||||
let runtimeSyncError: string | null = null;
|
||||
const { data: agent } = await admin
|
||||
.from("agent_instances")
|
||||
.select("id")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (agent?.id) {
|
||||
try {
|
||||
await syncAgentRuntimeSnapshot({
|
||||
supabase: admin,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
scope: "integrations",
|
||||
});
|
||||
} catch (syncErr) {
|
||||
runtimeSyncError = syncErr instanceof Error ? syncErr.message : String(syncErr);
|
||||
console.error("refresh-integration-token runtime sync warning", runtimeSyncError);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
expires_at: expiresAt,
|
||||
runtime_sync_warning: runtimeSyncError,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("refresh-integration-token fatal", err instanceof Error ? err.message : "unknown");
|
||||
return jsonResponse(
|
||||
|
|
|
|||
109
supabase/functions/sync-agent-runtime/index.ts
Normal file
109
supabase/functions/sync-agent-runtime/index.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
type RuntimeSyncScope = "cronjobs" | "integrations" | "all";
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function isValidScope(scope: unknown): scope is RuntimeSyncScope {
|
||||
return scope === "cronjobs" || scope === "integrations" || scope === "all";
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const jwt = authHeader.replace(/^Bearer\s+/i, "");
|
||||
if (!jwt) {
|
||||
return jsonResponse(401, { error: "missing authorization" });
|
||||
}
|
||||
|
||||
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: { headers: { Authorization: `Bearer ${jwt}` } },
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||
if (userErr || !userData?.user) {
|
||||
return jsonResponse(401, { error: "invalid token" });
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
let body: { agent_instance_id?: string; scope?: RuntimeSyncScope };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid json body" });
|
||||
}
|
||||
|
||||
if (!body.agent_instance_id) {
|
||||
return jsonResponse(400, { error: "agent_instance_id required" });
|
||||
}
|
||||
|
||||
if (body.scope && !isValidScope(body.scope)) {
|
||||
return jsonResponse(400, { error: "invalid scope" });
|
||||
}
|
||||
|
||||
const { data: agent, error: agentErr } = await supabase
|
||||
.from("agent_instances")
|
||||
.select("id, user_id")
|
||||
.eq("id", body.agent_instance_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (agentErr || !agent) {
|
||||
return jsonResponse(404, { error: "agent_instance not found" });
|
||||
}
|
||||
|
||||
const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", {
|
||||
_user_id: userData.user.id,
|
||||
_role: "admin",
|
||||
});
|
||||
if (roleErr) {
|
||||
return jsonResponse(500, { error: "failed to resolve role" });
|
||||
}
|
||||
|
||||
if (agent.user_id !== userData.user.id && !isAdmin) {
|
||||
return jsonResponse(403, { error: "forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncAgentRuntimeSnapshot({
|
||||
supabase,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
scope: body.scope ?? "all",
|
||||
});
|
||||
|
||||
return jsonResponse(200, {
|
||||
success: true,
|
||||
agent_instance_id: result.agent_instance_id,
|
||||
public_url: result.public_url,
|
||||
public_domain: result.public_domain,
|
||||
cronjobs_synced_count: result.cronjobs_synced_count,
|
||||
integrations_synced_count: result.integrations_synced_count,
|
||||
runtime_responses: result.responses,
|
||||
});
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
console.error("sync-agent-runtime failed:", detail);
|
||||
return jsonResponse(500, { error: "runtime sync failed", detail });
|
||||
}
|
||||
});
|
||||
97
supabase/functions/sync-agent-skills/index.ts
Normal file
97
supabase/functions/sync-agent-skills/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts";
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
||||
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const jwt = authHeader.replace(/^Bearer\s+/i, "");
|
||||
if (!jwt) {
|
||||
return jsonResponse(401, { error: "missing authorization" });
|
||||
}
|
||||
|
||||
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
global: { headers: { Authorization: `Bearer ${jwt}` } },
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||
if (userErr || !userData?.user) {
|
||||
return jsonResponse(401, { error: "invalid token" });
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
let body: { agent_instance_id?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse(400, { error: "invalid json body" });
|
||||
}
|
||||
|
||||
if (!body.agent_instance_id) {
|
||||
return jsonResponse(400, { error: "agent_instance_id required" });
|
||||
}
|
||||
|
||||
const { data: agent, error: agentErr } = await supabase
|
||||
.from("agent_instances")
|
||||
.select("id, user_id")
|
||||
.eq("id", body.agent_instance_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (agentErr || !agent) {
|
||||
return jsonResponse(404, { error: "agent_instance not found" });
|
||||
}
|
||||
|
||||
const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", {
|
||||
_user_id: userData.user.id,
|
||||
_role: "admin",
|
||||
});
|
||||
if (roleErr) {
|
||||
return jsonResponse(500, { error: "failed to resolve role" });
|
||||
}
|
||||
|
||||
if (agent.user_id !== userData.user.id && !isAdmin) {
|
||||
return jsonResponse(403, { error: "forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncAgentSkillsSnapshot({
|
||||
supabase,
|
||||
agentInstanceId: agent.id,
|
||||
railwayToken: RAILWAY_API_TOKEN,
|
||||
apiKey: HERMES_API_SERVER_KEY,
|
||||
});
|
||||
|
||||
return jsonResponse(200, {
|
||||
success: true,
|
||||
agent_instance_id: result.agent_instance_id,
|
||||
public_url: result.public_url,
|
||||
public_domain: result.public_domain,
|
||||
synced_count: result.synced_count,
|
||||
runtime_response: result.response,
|
||||
});
|
||||
} catch (e) {
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
console.error("sync-agent-skills failed:", detail);
|
||||
return jsonResponse(500, { error: "skills sync failed", detail });
|
||||
}
|
||||
});
|
||||
|
|
@ -10,6 +10,11 @@ import {
|
|||
getServiceContext,
|
||||
upsertRailwayVariableCollection,
|
||||
} from "../_shared/railway.ts";
|
||||
import {
|
||||
DEFAULT_OLLAMA_MODEL,
|
||||
DEFAULT_OLLAMA_PROVIDER,
|
||||
normalizeOllamaModelSelection,
|
||||
} from "../_shared/hermes-config.ts";
|
||||
|
||||
interface RequestBody {
|
||||
agent_instance_id: string;
|
||||
|
|
@ -102,7 +107,11 @@ Deno.serve(async (req) => {
|
|||
}
|
||||
|
||||
// 4) Upsert variáveis (incluindo HERMES_SOUL_OVERRIDE editado pelo admin)
|
||||
const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
HERMES_MODEL_DEFAULT: model,
|
||||
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
|
||||
HERMES_SOUL_OVERRIDE: body.soul_content,
|
||||
HERMES_STT_PROVIDER: body.stt_provider || "local",
|
||||
HERMES_TTS_PROVIDER: body.tts_provider || "disabled",
|
||||
|
|
@ -134,7 +143,8 @@ Deno.serve(async (req) => {
|
|||
.from("agent_instances")
|
||||
.update({
|
||||
model_config: {
|
||||
provider: body.model,
|
||||
provider: DEFAULT_OLLAMA_PROVIDER,
|
||||
model,
|
||||
stt: body.stt_provider || "local",
|
||||
tts: body.tts_provider || "disabled",
|
||||
agent_name: body.agent_name ?? null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue