fix: wire mika runtime cron and skill actions

This commit is contained in:
Felipe Domingues 2026-05-28 09:00:46 -03:00
parent 6c0ccff305
commit e9f22e6ceb
16 changed files with 1562 additions and 1310 deletions

View file

@ -47,11 +47,14 @@ jobs:
run: | run: |
files=( files=(
supabase/functions/_shared/hermes-config.ts supabase/functions/_shared/hermes-config.ts
supabase/functions/_shared/default-skills.ts
supabase/functions/_shared/runtime-sync.ts supabase/functions/_shared/runtime-sync.ts
supabase/functions/sync-agent-runtime/index.ts supabase/functions/sync-agent-runtime/index.ts
supabase/functions/sync-agent-skills/index.ts supabase/functions/sync-agent-skills/index.ts
supabase/functions/keep-alive-agents/index.ts supabase/functions/keep-alive-agents/index.ts
supabase/functions/provision-agent/index.ts supabase/functions/provision-agent/index.ts
supabase/functions/create-cronjob-from-agent/index.ts
supabase/functions/create-skill-from-agent/index.ts
supabase/functions/publish-skill-version/index.ts supabase/functions/publish-skill-version/index.ts
supabase/functions/railway-webhook/index.ts supabase/functions/railway-webhook/index.ts
supabase/functions/update-agent-config/index.ts supabase/functions/update-agent-config/index.ts

View file

@ -24,8 +24,10 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud):
- `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET` - `PADDLE_API_KEY` / `PADDLE_WEBHOOK_SECRET`
- `SUPABASE_SERVICE_ROLE_KEY` - `SUPABASE_SERVICE_ROLE_KEY`
- `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes. - `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes.
- `OLLAMA_API_KEY` — chave do Ollama Cloud, injetada como env var no container Hermes (imagem custom `ghcr.io/domfelipe/hermes-agent-custom:latest`). - `OLLAMA_API_KEY` — chave do Ollama Cloud, injetada como env var no container Hermes.
- `HERMES_API_SERVER_KEY` — token usado pelo API server interno do Hermes (`API_SERVER_KEY`). Valor atual: `HermesRailwayKey2026SecureToken123456`. - `HERMES_RUNTIME_IMAGE` — opcional; imagem Docker usada no Railway. Default: `ghcr.io/domfelipe/hermes-agent-custom:latest`.
- `HERMES_API_SERVER_KEY` — token usado pelo API server interno do Hermes (`API_SERVER_KEY`). Gere um valor forte por ambiente e nunca versionado no repo.
- `INTERNAL_FUNCTION_SECRET` — segredo server-to-server usado pelo runtime Hermes para chamar Edge Functions internas, como `create-cronjob-from-agent`.
- `TELEGRAM_MANAGER_BOT_TOKEN` — token do bot manager (`@mika_managerbot`) usado para criar bots dos clientes em 1 toque via Bot Management Mode do BotFather. - `TELEGRAM_MANAGER_BOT_TOKEN` — token do bot manager (`@mika_managerbot`) usado para criar bots dos clientes em 1 toque via Bot Management Mode do BotFather.
- `TELEGRAM_MANAGER_BOT_USERNAME` — username do bot manager, padrão `mika_managerbot` (sem `@`). - `TELEGRAM_MANAGER_BOT_USERNAME` — username do bot manager, padrão `mika_managerbot` (sem `@`).
@ -42,17 +44,21 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud):
``` ```
Resposta esperada: `{ "webhook_set": "...", "telegram_response": { "ok": true } }`. Resposta esperada: `{ "webhook_set": "...", "telegram_response": { "ok": true } }`.
> **Imagem Docker custom**: o container roda `ghcr.io/domfelipe/hermes-agent-custom:latest`, que já contém o `SOUL.md` embutido. Por isso não passamos mais `HERMES_SOUL_OVERRIDE` via env var, e o Dockerfile define o `CMD` (sem `startCommand` no Railway). Secrets removidos do projeto: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HERMES_SOUL_OVERRIDE`. > **Imagem Docker custom**: por padrão o container roda `ghcr.io/domfelipe/hermes-agent-custom:latest`, mas `HERMES_RUNTIME_IMAGE` pode apontar para uma tag de branch/sha durante smoke. A imagem já contém o `SOUL.md` embutido. Por isso não passamos mais `HERMES_SOUL_OVERRIDE` via env var, e o Dockerfile define o `CMD` (sem `startCommand` no Railway). Secrets removidos do projeto: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HERMES_SOUL_OVERRIDE`.
> **Suspend/Resume via flag, não via stop.** Railway não permite parar containers sem redeploy. As Edge Functions `suspend-agent`/`resume-agent` usam a env var `HERMES_SUSPENDED` + `serviceInstanceRedeploy`:
>
> - `suspend` → upsert `HERMES_SUSPENDED=true` e redeploy → container entra em `sleep infinity`.
> - `resume` → upsert `HERMES_SUSPENDED=""` e redeploy → container sobe normal.
>
> Isso depende do start command verificar a flag. Novos serviços já são provisionados com o comando correto (`HERMES_START_COMMAND` em `_shared/railway.ts`):
>
> ```bash
> /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'
> ```
>
> **Para serviços Railway existentes (provisionados antes desta mudança):** atualize manualmente o Start Command via Railway UI/Agent para o comando acima — caso contrário `suspend-agent` apenas marcará `status='suspended'` no banco mas o container continuará rodando.
> **Suspend/Resume via flag, não via stop.** Railway não permite parar containers sem redeploy. As Edge Functions `suspend-agent`/`resume-agent` usam a env var `HERMES_SUSPENDED` + `serviceInstanceRedeploy`:
> - `suspend` → upsert `HERMES_SUSPENDED=true` e redeploy → container entra em `sleep infinity`.
> - `resume` → upsert `HERMES_SUSPENDED=""` e redeploy → container sobe normal.
>
> Isso depende do start command verificar a flag. Novos serviços já são provisionados com o comando correto (`HERMES_START_COMMAND` em `_shared/railway.ts`):
> ```bash
> /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'
> ```
> **Para serviços Railway existentes (provisionados antes desta mudança):** atualize manualmente o Start Command via Railway UI/Agent para o comando acima — caso contrário `suspend-agent` apenas marcará `status='suspended'` no banco mas o container continuará rodando.
- `OPENROUTER_API_KEY`**obrigatório**. Injetado em cada container Hermes provisionado para que o agente possa chamar os modelos `openrouter/google/gemma-4-*-it`. Sem isso o `provision-agent` retorna 500. - `OPENROUTER_API_KEY`**obrigatório**. Injetado em cada container Hermes provisionado para que o agente possa chamar os modelos `openrouter/google/gemma-4-*-it`. Sem isso o `provision-agent` retorna 500.
- `ADMIN_TELEGRAM_BOT_TOKEN` — token do bot de admin (ex: `@mika_test2_bot`) usado pelo `payments-webhook` para enviar notificações de novos clientes, falhas de pagamento e cancelamentos. - `ADMIN_TELEGRAM_BOT_TOKEN` — token do bot de admin (ex: `@mika_test2_bot`) usado pelo `payments-webhook` para enviar notificações de novos clientes, falhas de pagamento e cancelamentos.
- `ADMIN_TELEGRAM_CHAT_ID` — chat ID do admin que recebe as notificações (ex: `179720882`). - `ADMIN_TELEGRAM_CHAT_ID` — chat ID do admin que recebe as notificações (ex: `179720882`).
@ -155,29 +161,29 @@ Para cada provider abaixo, crie um OAuth app e configure a **Redirect URI**:
https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/oauth-callback https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/oauth-callback
``` ```
| Provider | Console | Scopes mínimos | | Provider | Console | Scopes mínimos |
|----------|---------|----------------| | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Google Workspace** | [console.cloud.google.com](https://console.cloud.google.com) → APIs & Services → Credentials → OAuth 2.0 Client ID (Web app) | `openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar` | | **Google Workspace** | [console.cloud.google.com](https://console.cloud.google.com) → APIs & Services → Credentials → OAuth 2.0 Client ID (Web app) | `openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar` |
| **Microsoft 365** | [Azure Portal](https://portal.azure.com) → App registrations → New registration → Web | `openid email profile offline_access Mail.Read Calendars.ReadWrite` | | **Microsoft 365** | [Azure Portal](https://portal.azure.com) → App registrations → New registration → Web | `openid email profile offline_access Mail.Read Calendars.ReadWrite` |
| **Notion** | [notion.so/my-integrations](https://www.notion.so/my-integrations) → New integration (Public) | (definidos na integração) | | **Notion** | [notion.so/my-integrations](https://www.notion.so/my-integrations) → New integration (Public) | (definidos na integração) |
| **Todoist** | [developer.todoist.com](https://developer.todoist.com/appconsole.html) → App management → Create app | `data:read_write` | | **Todoist** | [developer.todoist.com](https://developer.todoist.com/appconsole.html) → App management → Create app | `data:read_write` |
| **Cal.com** | [app.cal.com/settings/developer](https://app.cal.com/settings/developer/oauth-clients) → New OAuth Client | `READ_BOOKING WRITE_BOOKING READ_PROFILE` | | **Cal.com** | [app.cal.com/settings/developer](https://app.cal.com/settings/developer/oauth-clients) → New OAuth Client | `READ_BOOKING WRITE_BOOKING READ_PROFILE` |
#### 2. Adicionar os 10 secrets no Lovable Cloud #### 2. Adicionar os 10 secrets no Lovable Cloud
Pelo menu **Connectors → Lovable Cloud → Secrets**, adicione: Pelo menu **Connectors → Lovable Cloud → Secrets**, adicione:
``` ```
GOOGLE_OAUTH_CLIENT_ID GOOGLE_CLIENT_ID
GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_CLIENT_SECRET
MICROSOFT_OAUTH_CLIENT_ID MICROSOFT_CLIENT_ID
MICROSOFT_OAUTH_CLIENT_SECRET MICROSOFT_CLIENT_SECRET
NOTION_OAUTH_CLIENT_ID NOTION_CLIENT_ID
NOTION_OAUTH_CLIENT_SECRET NOTION_CLIENT_SECRET
TODOIST_OAUTH_CLIENT_ID TODOIST_CLIENT_ID
TODOIST_OAUTH_CLIENT_SECRET TODOIST_CLIENT_SECRET
CALCOM_OAUTH_CLIENT_ID CALCOM_CLIENT_ID
CALCOM_OAUTH_CLIENT_SECRET CALCOM_CLIENT_SECRET
``` ```
#### 3. Verificar publicação Realtime #### 3. Verificar publicação Realtime
@ -215,8 +221,10 @@ A view `user_integration_limits` e `user_jobs_limits` usam dados de `user_integr
- `user_roles` + enum `app_role` + função `has_role()` (substitui `is_admin` em profiles, mais seguro) - `user_roles` + enum `app_role` + função `has_role()` (substitui `is_admin` em profiles, mais seguro)
- Trigger `on_agent_instance_provisioning` usando `pg_net` chama `provision-agent` quando uma agent_instance entra em status `provisioning` - Trigger `on_agent_instance_provisioning` usando `pg_net` chama `provision-agent` quando uma agent_instance entra em status `provisioning`
- **Edge Functions**: - **Edge Functions**:
- `provision-agent` (verify_jwt=false): cria serviço Docker no Railway via GraphQL API, configura variáveis (TELEGRAM_BOT_TOKEN do Vault, OPENCODE_ZEN_API_KEY, etc.) e dispara deploy. Apaga webhook do Telegram antes (Hermes opera em polling). Retry com backoff exponencial até 5 tentativas. - `provision-agent` (verify_jwt=false): cria serviço Docker no Railway via GraphQL API, configura Telegram/modelo/soul e o contrato runtime→plataforma (`MIKA_CREATE_CRONJOB_URL`, `MIKA_CREATE_SKILL_URL`, `MIKA_AGENT_INSTANCE_ID`, `MIKA_INTERNAL_FUNCTION_SECRET`), e dispara deploy. Apaga webhook do Telegram antes (Hermes opera em polling). Retry com backoff exponencial até 5 tentativas.
- `railway-webhook` (verify_jwt=false): recebe eventos do Railway. SUCCESS → marca agent como `active`; FAILED/CRASHED → marca como `error`. - `railway-webhook` (verify_jwt=false): recebe eventos do Railway. SUCCESS → marca agent como `active`; FAILED/CRASHED → marca como `error`.
- `create-cronjob-from-agent` / `create-skill-from-agent` (verify_jwt=false): endpoints server-to-server chamados pelo runtime Hermes com `X-Internal-Secret`.
- `railway-webhook` garante um pacote padrao de skills antes de sincronizar `/api/skills/sync` no runtime.
- **Helper compartilhado**: `supabase/functions/_shared/railway.ts` - **Helper compartilhado**: `supabase/functions/_shared/railway.ts`
### Etapas pós-deploy (uma única vez) — **OBRIGATÓRIAS** ### Etapas pós-deploy (uma única vez) — **OBRIGATÓRIAS**
@ -292,4 +300,3 @@ bun dev # dev server (porta 8080)
bun run build # build de produção bun run build # build de produção
bun run typecheck bun run typecheck
``` ```

1525
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -75,7 +75,7 @@
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2", "vite-tsconfig-paths": "^6.0.2",
"zod": "^4.3.6" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.32.0", "@eslint/js": "^9.32.0",

View file

@ -11,7 +11,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { invokeFunction } from "@/lib/invoke-function"; import { invokeFunction } from "@/lib/invoke-function";
import { syncAgentRuntime } from "@/lib/sync-agent-runtime"; import { syncAgentRuntime } from "@/lib/sync-agent-runtime";
import { useCreateCronjob } from "@/hooks/use-cronjobs"; import { markCronjobRuntimeSyncError, useCreateCronjob } from "@/hooks/use-cronjobs";
import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations"; import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations";
import { useAgentInstance } from "@/hooks/use-agent-instance"; import { useAgentInstance } from "@/hooks/use-agent-instance";
import { useProfile } from "@/hooks/use-profile"; import { useProfile } from "@/hooks/use-profile";
@ -54,9 +54,12 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
const { data: integrations = [] } = useUserIntegrations(); const { data: integrations = [] } = useUserIntegrations();
const createMut = useCreateCronjob(); const createMut = useCreateCronjob();
const tz = profile && "timezone" in profile && typeof (profile as { timezone?: string }).timezone === "string" const tz =
? (profile as { timezone: string }).timezone profile &&
: "America/Sao_Paulo"; "timezone" in profile &&
typeof (profile as { timezone?: string }).timezone === "string"
? (profile as { timezone: string }).timezone
: "America/Sao_Paulo";
const mcpsBySlug = useMemo(() => { const mcpsBySlug = useMemo(() => {
const m = new Map<string, { id: string; name: string }>(); const m = new Map<string, { id: string; name: string }>();
@ -83,10 +86,10 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
return; return;
} }
setParsing(true); setParsing(true);
const { data, error } = await invokeFunction<ParseResult>( const { data, error } = await invokeFunction<ParseResult>("parse-cronjob-natural-language", {
"parse-cronjob-natural-language", natural_language_input: trimmed,
{ natural_language_input: trimmed, user_timezone: tz }, user_timezone: tz,
); });
setParsing(false); setParsing(false);
if (error || !data) { if (error || !data) {
toast.error(error?.message ?? "Não conseguimos interpretar. Tente reescrever."); toast.error(error?.message ?? "Não conseguimos interpretar. Tente reescrever.");
@ -131,11 +134,14 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
timezone: tz, timezone: tz,
next_run_at: parsed?.next_run_at ?? null, next_run_at: parsed?.next_run_at ?? null,
}); });
toast.success("Automação criada!");
const { error: syncError } = await syncAgentRuntime(agent.id, "cronjobs"); const { error: syncError } = await syncAgentRuntime(agent.id, "cronjobs");
if (syncError) { if (syncError) {
toast.warning("Automação criada, mas o runtime do agente não sincronizou."); await markCronjobRuntimeSyncError(job.id, syncError.message);
toast.error("Automação criada, mas não foi ativada no agente.", {
description: "Corrija o runtime e tente sincronizar novamente pelo painel.",
});
} else {
toast.success("Automação criada e sincronizada!");
} }
onCreated?.(job.id); onCreated?.(job.id);
@ -208,26 +214,29 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
parsed.confidence === "high" parsed.confidence === "high"
? "success" ? "success"
: parsed.confidence === "medium" : parsed.confidence === "medium"
? "secondary" ? "secondary"
: "destructive"; : "destructive";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-6 space-y-4"> <div className="rounded-xl border border-border bg-card p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold">Revisar interpretação</h2> <h2 className="text-lg font-semibold">Revisar interpretação</h2>
<Badge variant={confidenceColor as "success" | "secondary" | "destructive"}> <Badge variant={confidenceColor as "success" | "secondary" | "destructive"}>
Confiança: {parsed.confidence === "high" ? "alta" : parsed.confidence === "medium" ? "média" : "baixa"} Confiança:{" "}
{parsed.confidence === "high"
? "alta"
: parsed.confidence === "medium"
? "média"
: "baixa"}
</Badge> </Badge>
</div> </div>
{parsed.warnings.length > 0 && ( {parsed.warnings.length > 0 && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm"> <div className="rounded-md border border-warning/30 bg-warning/10 p-3 text-sm">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<div> <div>
<p className="font-medium text-amber-700 dark:text-amber-400"> <p className="font-medium text-warning">Suposições da IA confira:</p>
Suposições da IA confira:
</p>
<ul className="list-disc list-inside mt-1 text-muted-foreground"> <ul className="list-disc list-inside mt-1 text-muted-foreground">
{parsed.warnings.map((w, i) => ( {parsed.warnings.map((w, i) => (
<li key={i}>{w}</li> <li key={i}>{w}</li>
@ -317,7 +326,11 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
variant={connected ? "success" : "destructive"} variant={connected ? "success" : "destructive"}
className="gap-1" className="gap-1"
> >
{connected ? <CheckCircle2 className="h-3 w-3" /> : <Plug className="h-3 w-3" />} {connected ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<Plug className="h-3 w-3" />
)}
{mcp?.name ?? slug} {mcp?.name ?? slug}
{!connected && " (não conectado)"} {!connected && " (não conectado)"}
</Badge> </Badge>
@ -331,7 +344,9 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
Conecte as integrações faltantes antes de criar. Conecte as integrações faltantes antes de criar.
</p> </p>
<Button asChild size="sm" variant="outline" className="mt-2"> <Button asChild size="sm" variant="outline" className="mt-2">
<Link to="/painel/integracoes" search={{}}>Ir para Integrações</Link> <Link to="/painel/integracoes" search={{}}>
Ir para Integrações
</Link>
</Button> </Button>
</div> </div>
)} )}
@ -351,11 +366,7 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button <Button variant="ghost" onClick={() => setStep("input")} disabled={createMut.isPending}>
variant="ghost"
onClick={() => setStep("input")}
disabled={createMut.isPending}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar <ArrowLeft className="h-4 w-4 mr-2" /> Voltar
</Button> </Button>
<Button <Button

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { MessageCircle, ExternalLink, CheckCircle2 } from "lucide-react"; import { MessageCircle, ExternalLink, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -14,6 +13,11 @@ interface Props {
} }
const EMOJIS = ["🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀"]; const EMOJIS = ["🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀", "🎉", "✨", "⭐", "🚀"];
const EMOJI_POSITIONS = EMOJIS.map((_, index) => ({
x: ((index * 73) % 320) - 160,
y: ((index * 47) % 240) - 120,
rotate: ((index * 31) % 80) - 40,
}));
export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinish }: Props) { export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinish }: Props) {
const { received } = useTelegramFirstMessage({ const { received } = useTelegramFirstMessage({
@ -22,15 +26,7 @@ export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinis
enabled: true, enabled: true,
}); });
const positions = useMemo( const positions = EMOJI_POSITIONS;
() =>
EMOJIS.map(() => ({
x: (Math.random() - 0.5) * 320,
y: (Math.random() - 0.5) * 240,
rotate: (Math.random() - 0.5) * 80,
})),
[],
);
return ( return (
<div className="px-6 py-8 max-w-xl mx-auto"> <div className="px-6 py-8 max-w-xl mx-auto">
@ -56,11 +52,7 @@ export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinis
</div> </div>
<Button asChild size="lg" className="mt-6"> <Button asChild size="lg" className="mt-6">
<a <a href={`https://t.me/${botUsername}`} target="_blank" rel="noopener noreferrer">
href={`https://t.me/${botUsername}`}
target="_blank"
rel="noopener noreferrer"
>
Abrir meu bot no Telegram <ExternalLink className="ml-2 h-4 w-4" /> Abrir meu bot no Telegram <ExternalLink className="ml-2 h-4 w-4" />
</a> </a>
</Button> </Button>
@ -103,8 +95,8 @@ export function StepWaiting({ agentInstanceId, botUsername, connectedAt, onFinis
</div> </div>
<h2 className="mt-4 text-2xl font-bold tracking-tight">🎉 Mika conectado!</h2> <h2 className="mt-4 text-2xl font-bold tracking-tight">🎉 Mika conectado!</h2>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Você recebeu a primeira resposta do seu agente. Ele ainda está em modo de teste, Você recebeu a primeira resposta do seu agente. Ele ainda está em modo de teste, mas
mas em breve vai responder de verdade. em breve vai responder de verdade.
</p> </p>
<Button size="lg" className="mt-8 min-w-56" onClick={onFinish}> <Button size="lg" className="mt-8 min-w-56" onClick={onFinish}>

View file

@ -638,10 +638,14 @@ const SidebarMenuSkeleton = React.forwardRef<
showIcon?: boolean; showIcon?: boolean;
} }
>(({ className, showIcon = false, ...props }, ref) => { >(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%. const id = React.useId();
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`; let hash = 0;
}, []); for (let i = 0; i < id.length; i += 1) {
hash = (hash * 31 + id.charCodeAt(i)) % 40;
}
return `${hash + 50}%`;
}, [id]);
return ( return (
<div <div

View file

@ -142,16 +142,29 @@ export function useCreateCronjob() {
}); });
} }
export async function markCronjobRuntimeSyncError(id: string, detail: string) {
const message = detail.slice(0, 2000);
const { error } = await supabase
.from("scheduled_jobs")
.update({
status: "error",
auto_paused_reason: "Falha ao sincronizar esta automação com o runtime do agente.",
runtime_state: "error",
runtime_last_status: "error",
runtime_last_error: message,
})
.eq("id", id);
if (error) throw error;
}
export function useUpdateCronjobStatus() { export function useUpdateCronjobStatus() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ id, status }: { id: string; status: "active" | "paused" }) => { mutationFn: async ({ id, status }: { id: string; status: "active" | "paused" }) => {
const update: { status: "active" | "paused"; auto_paused_reason?: null } = { status }; const update: { status: "active" | "paused"; auto_paused_reason?: null } = { status };
if (status === "paused") update.auto_paused_reason = null; if (status === "paused") update.auto_paused_reason = null;
const { error } = await supabase const { error } = await supabase.from("scheduled_jobs").update(update).eq("id", id);
.from("scheduled_jobs")
.update(update)
.eq("id", id);
if (error) throw error; if (error) throw error;
}, },
onSuccess: () => { onSuccess: () => {

View file

@ -89,11 +89,11 @@ function IntegrationDetailPage() {
</Link> </Link>
</Button> </Button>
<div className="rounded-xl border border-border bg-card p-8 text-center"> <div className="rounded-xl border border-border bg-card p-8 text-center">
<p className="text-muted-foreground"> <p className="text-muted-foreground">Você ainda não conectou {mcp.name}.</p>
Você ainda não conectou {mcp.name}.
</p>
<Button asChild className="mt-4"> <Button asChild className="mt-4">
<Link to="/painel/integracoes" search={{}}>Conectar</Link> <Link to="/painel/integracoes" search={{}}>
Conectar
</Link>
</Button> </Button>
</div> </div>
</div> </div>
@ -248,9 +248,7 @@ function IntegrationDetailPage() {
className="flex items-center justify-between text-sm border-b border-border last:border-0 pb-2 last:pb-0" className="flex items-center justify-between text-sm border-b border-border last:border-0 pb-2 last:pb-0"
> >
<span>{j.name}</span> <span>{j.name}</span>
<Badge variant={j.status === "active" ? "success" : "secondary"}> <Badge variant={j.status === "active" ? "success" : "secondary"}>{j.status}</Badge>
{j.status}
</Badge>
</li> </li>
))} ))}
</ul> </ul>
@ -271,25 +269,20 @@ function IntegrationDetailPage() {
{(status === "expired" || status === "revoked" || status === "error") && ( {(status === "expired" || status === "revoked" || status === "error") && (
<Button <Button
onClick={async () => { onClick={async () => {
const { data, error } = await invokeFunction<{ authorize_url: string }>( const { data, error } = await invokeFunction<{ auth_url: string }>("oauth-start", {
"oauth-start", mcp_slug: mcp.slug,
{ mcp_slug: mcp.slug }, });
); if (error || !data?.auth_url) {
if (error || !data?.authorize_url) {
toast.error(error?.message ?? "Falha ao iniciar reconexão."); toast.error(error?.message ?? "Falha ao iniciar reconexão.");
return; return;
} }
window.location.href = data.authorize_url; window.location.href = data.auth_url;
}} }}
> >
Reconectar Reconectar
</Button> </Button>
)} )}
<Button <Button variant="destructive" onClick={() => setDisconnectOpen(true)} className="ml-auto">
variant="destructive"
onClick={() => setDisconnectOpen(true)}
className="ml-auto"
>
<Unplug className="h-4 w-4 mr-2" /> Desconectar <Unplug className="h-4 w-4 mr-2" /> Desconectar
</Button> </Button>
</div> </div>
@ -300,14 +293,12 @@ function IntegrationDetailPage() {
setDisconnectOpen(o); setDisconnectOpen(o);
if (!o) { if (!o) {
// se desconectou, volta para a lista // se desconectou, volta para a lista
queryClient queryClient.invalidateQueries({ queryKey: ["user-integrations"] }).then(() => {
.invalidateQueries({ queryKey: ["user-integrations"] }) const stillConnected = integs.some((i) => i.id === integration.id);
.then(() => { if (!stillConnected) {
const stillConnected = integs.some((i) => i.id === integration.id); navigate({ to: "/painel/integracoes", search: {} });
if (!stillConnected) { }
navigate({ to: "/painel/integracoes", search: {} }); });
}
});
} }
}} }}
integrationId={integration.id} integrationId={integration.id}

View file

@ -60,3 +60,5 @@ verify_jwt = false
[functions.create-cronjob-from-agent] [functions.create-cronjob-from-agent]
verify_jwt = false verify_jwt = false
[functions.create-skill-from-agent]
verify_jwt = false

View file

@ -0,0 +1,301 @@
import type { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
type GenericTable = {
Row: Record<string, unknown>;
Insert: Record<string, unknown>;
Update: Record<string, unknown>;
Relationships: [];
};
type GenericDatabase = {
public: {
Tables: Record<string, GenericTable>;
Views: Record<string, GenericTable>;
Functions: Record<string, { Args: Record<string, unknown>; Returns: unknown }>;
};
};
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
export interface DefaultSkillsEnsureResult {
agent_instance_id: string;
created_count: number;
skipped_count: number;
errors: string[];
}
interface DefaultSkillTemplate {
name: string;
description: string;
trigger_keywords: string;
form_inputs: Record<string, unknown>;
markdown_content: string;
}
const DEFAULT_HERMES_SKILLS: DefaultSkillTemplate[] = [
{
name: "Resumo diario",
description: "Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.",
trigger_keywords: "resumo diario, resumo do dia, briefing diario, revisar dia",
form_inputs: {
name: "Resumo diario",
description: "Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.",
trigger_keywords: "resumo diario, resumo do dia, briefing diario, revisar dia",
expected_inputs: "Periodo desejado e ferramentas conectadas, quando houver.",
steps: "Consolidar agenda, tarefas e pontos pendentes. Apontar prioridades e riscos.",
required_tools: ["calendar_optional", "tasks_optional", "notes_optional"],
success_criteria: "Usuario recebe um resumo acionavel e curto.",
example_use_case: "Me manda meu resumo diario as 8h.",
},
markdown_content: `---
name: Resumo diario
description: Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.
trigger_keywords: resumo diario, resumo do dia, briefing diario, revisar dia
---
## Quando usar
Use quando o usuario pedir um resumo do dia, briefing, revisao diaria ou preparacao rapida para comecar/encerrar o expediente.
## Inputs esperados
- Periodo desejado, se o usuario informar.
- Ferramentas conectadas relevantes, como agenda, tarefas ou notas.
## Passo a passo
1. Identifique o periodo solicitado. Se nao houver periodo, use hoje no fuso do usuario.
2. Consulte agenda, tarefas e notas quando essas integracoes estiverem disponiveis.
3. Organize a resposta em compromissos, tarefas importantes, pendencias e sugestao de foco.
4. Se uma integracao necessaria nao estiver conectada, explique isso de forma curta e ofereca um resumo com o contexto disponivel.
## Ferramentas necessarias
- Calendar opcional.
- Tasks opcional.
- Notes opcional.
## Criterio de sucesso
O usuario recebe um resumo curto, confiavel e acionavel, sem inventar dados ausentes.
## Exemplo
"Me manda meu resumo diario as 8h."`,
},
{
name: "Planejamento semanal",
description:
"Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.",
trigger_keywords:
"planejar semana, planejamento semanal, prioridades da semana, organizar semana",
form_inputs: {
name: "Planejamento semanal",
description:
"Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.",
trigger_keywords:
"planejar semana, planejamento semanal, prioridades da semana, organizar semana",
expected_inputs: "Objetivos, restricoes e contexto da semana.",
steps: "Levantar objetivos, quebrar em acoes, priorizar e sugerir agenda.",
required_tools: ["calendar_optional", "tasks_optional"],
success_criteria: "Usuario sai com prioridades e proximas acoes claras.",
example_use_case: "Me ajuda a planejar minha semana.",
},
markdown_content: `---
name: Planejamento semanal
description: Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.
trigger_keywords: planejar semana, planejamento semanal, prioridades da semana, organizar semana
---
## Quando usar
Use quando o usuario pedir ajuda para planejar a semana, organizar prioridades, distribuir tarefas ou revisar foco semanal.
## Inputs esperados
- Objetivos da semana.
- Prazos, reunioes ou restricoes conhecidas.
- Tarefas pendentes, se houver integracao disponivel.
## Passo a passo
1. Liste os objetivos principais mencionados pelo usuario.
2. Quebre cada objetivo em proximas acoes pequenas.
3. Sugira uma ordem de prioridade realista.
4. Se houver agenda conectada, proponha blocos de foco sem assumir disponibilidade que nao foi verificada.
5. Termine com um plano curto e facil de revisar.
## Ferramentas necessarias
- Calendar opcional.
- Tasks opcional.
## Criterio de sucesso
O usuario recebe prioridades claras, proximas acoes e uma sugestao de distribuicao da semana.
## Exemplo
"Me ajuda a planejar minha semana."`,
},
{
name: "Preparar reuniao",
description:
"Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.",
trigger_keywords: "preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao",
form_inputs: {
name: "Preparar reuniao",
description:
"Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.",
trigger_keywords: "preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao",
expected_inputs: "Nome da reuniao, participantes ou tema.",
steps: "Localizar contexto, resumir objetivo, sugerir pauta e perguntas.",
required_tools: ["calendar_optional", "email_optional", "notes_optional"],
success_criteria: "Usuario chega preparado para a reuniao.",
example_use_case: "Prepara meu briefing para a reuniao com o cliente.",
},
markdown_content: `---
name: Preparar reuniao
description: Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.
trigger_keywords: preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao
---
## Quando usar
Use quando o usuario pedir preparacao para uma reuniao, briefing, pauta ou contexto antes de falar com alguem.
## Inputs esperados
- Nome, horario, participantes ou tema da reuniao.
- Materiais ou contexto fornecidos pelo usuario.
## Passo a passo
1. Identifique qual reuniao ou tema o usuario quer preparar.
2. Consulte agenda, emails ou notas quando houver integracao disponivel.
3. Resuma objetivo, contexto conhecido, riscos e decisoes pendentes.
4. Sugira uma pauta curta e perguntas uteis.
5. Se faltar contexto, diga exatamente o que falta.
## Ferramentas necessarias
- Calendar opcional.
- Email opcional.
- Notes opcional.
## Criterio de sucesso
O usuario recebe um briefing pratico para entrar na reuniao com clareza.
## Exemplo
"Prepara meu briefing para a reuniao com o cliente."`,
},
];
export async function ensureDefaultSkillsForAgent(
supabase: SupabaseAdminClient,
agentInstanceId: string,
): Promise<DefaultSkillsEnsureResult> {
const result: DefaultSkillsEnsureResult = {
agent_instance_id: agentInstanceId,
created_count: 0,
skipped_count: 0,
errors: [],
};
const { data: agentData, error: agentErr } = await supabase
.from("agent_instances")
.select("id, user_id")
.eq("id", agentInstanceId)
.maybeSingle();
const agent = agentData as { id: string; user_id: string } | null;
if (agentErr || !agent) {
throw new Error(
`agent_instance not found for default skills: ${agentErr?.message ?? agentInstanceId}`,
);
}
for (const template of DEFAULT_HERMES_SKILLS) {
const { data: existing, error: existingErr } = await supabase
.from("skills")
.select("id")
.eq("user_id", agent.user_id)
.eq("name", template.name)
.neq("status", "archived")
.maybeSingle();
if (existingErr) {
result.errors.push(
`${template.name}: failed to check existing skill (${existingErr.message})`,
);
continue;
}
if (existing) {
result.skipped_count += 1;
continue;
}
const { data: skillData, error: skillErr } = await supabase
.from("skills")
.insert({
user_id: agent.user_id,
agent_instance_id: agent.id,
name: template.name,
description: template.description,
trigger_keywords: template.trigger_keywords,
status: "draft",
})
.select("id")
.single();
const skill = skillData as { id: string } | null;
if (skillErr || !skill) {
result.errors.push(
`${template.name}: failed to create skill (${skillErr?.message ?? "unknown"})`,
);
continue;
}
const { data: versionData, error: versionErr } = await supabase
.from("skill_versions")
.insert({
skill_id: skill.id,
version_number: 1,
markdown_content: template.markdown_content,
form_inputs: template.form_inputs,
is_live: true,
created_by: agent.user_id,
})
.select("id")
.single();
const version = versionData as { id: string } | null;
if (versionErr || !version) {
result.errors.push(
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
);
continue;
}
const { error: updateErr } = await supabase
.from("skills")
.update({
current_version_id: version.id,
status: "active",
updated_at: new Date().toISOString(),
})
.eq("id", skill.id);
if (updateErr) {
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
continue;
}
result.created_count += 1;
}
return result;
}

View file

@ -23,12 +23,30 @@ import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n";
import { corsHeaders } from "../_shared/cors.ts"; import { corsHeaders } from "../_shared/cors.ts";
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
type GenericTable = {
Row: Record<string, unknown>;
Insert: Record<string, unknown>;
Update: Record<string, unknown>;
Relationships: [];
};
type GenericDatabase = {
public: {
Tables: Record<string, GenericTable>;
Views: Record<string, GenericTable>;
Functions: Record<string, { Args: Record<string, unknown>; Returns: unknown }>;
};
};
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; const INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? ""; const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? "";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
const CONTRACT_VERSION = "2026-05-28";
const MODEL = "google/gemini-2.5-flash"; const MODEL = "google/gemini-2.5-flash";
@ -66,6 +84,13 @@ function constantTimeEq(a: string, b: string): boolean {
return diff === 0; return diff === 0;
} }
function isAuthorized(req: Request): boolean {
const received = req.headers.get("x-internal-secret") ?? "";
return (
!!INTERNAL_FUNCTION_SECRET && !!received && constantTimeEq(INTERNAL_FUNCTION_SECRET, received)
);
}
function buildSystemPrompt(tz: string): string { function buildSystemPrompt(tz: string): string {
return `Você é um parser de descrições de cronjobs em português para o assistente Mika. Receba uma descrição em linguagem natural e retorne APENAS JSON válido, sem markdown, sem explicações. return `Você é um parser de descrições de cronjobs em português para o assistente Mika. Receba uma descrição em linguagem natural e retorne APENAS JSON válido, sem markdown, sem explicações.
@ -90,12 +115,16 @@ interface ParsedJob {
function tryParseJson(text: string): ParsedJob | null { function tryParseJson(text: string): ParsedJob | null {
try { try {
return JSON.parse(text) as ParsedJob; return JSON.parse(text) as ParsedJob;
} catch (_) { /* segue */ } } catch (_) {
/* segue */
}
const match = text.match(/\{[\s\S]*\}/); const match = text.match(/\{[\s\S]*\}/);
if (match) { if (match) {
try { try {
return JSON.parse(match[0]) as ParsedJob; return JSON.parse(match[0]) as ParsedJob;
} catch (_) { /* falhou */ } } catch (_) {
/* falhou */
}
} }
return null; return null;
} }
@ -123,16 +152,56 @@ async function parseWithAI(input: string, tz: string): Promise<ParsedJob | null>
return tryParseJson(content); return tryParseJson(content);
} }
async function markJobRuntimeSyncError(
admin: SupabaseAdminClient,
jobId: string,
syncError: string,
): Promise<void> {
const message = syncError.slice(0, 2000);
const { error } = await admin
.from("scheduled_jobs")
.update({
status: "error",
auto_paused_reason: "Falha ao sincronizar esta automação com o runtime do agente.",
runtime_state: "error",
runtime_last_status: "error",
runtime_last_error: message,
})
.eq("id", jobId);
if (error) {
console.error("failed to mark scheduled_job runtime sync error:", error);
}
}
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
// 1) Auth: apenas X-Internal-Secret (chamada server-to-server do runtime) // 1) Auth: apenas X-Internal-Secret (chamada server-to-server do runtime)
const received = req.headers.get("x-internal-secret") ?? ""; if (!isAuthorized(req)) {
if (!INTERNAL_FUNCTION_SECRET || !received || !constantTimeEq(INTERNAL_FUNCTION_SECRET, received)) {
return json({ error: "unauthorized" }, 401); return json({ error: "unauthorized" }, 401);
} }
if (req.method === "GET" || req.method === "HEAD") {
return json({
success: true,
endpoint: "create-cronjob-from-agent",
contract_version: CONTRACT_VERSION,
expected_header: "X-Internal-Secret",
required_body_fields: ["agent_instance_id", "natural_language_input"],
optional_body_fields: [
"cron_expression",
"action_prompt",
"required_mcp_slugs",
"name",
"description",
"timezone",
],
});
}
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
// 2) Body // 2) Body
let body: RequestBody; let body: RequestBody;
try { try {
@ -144,7 +213,8 @@ Deno.serve(async (req) => {
if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400); if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400);
const input = (body.natural_language_input ?? "").trim(); const input = (body.natural_language_input ?? "").trim();
if (input.length < 5) return json({ error: "natural_language_input too short" }, 400); if (input.length < 5) return json({ error: "natural_language_input too short" }, 400);
if (input.length > 1000) return json({ error: "natural_language_input too long (max 1000)" }, 400); if (input.length > 1000)
return json({ error: "natural_language_input too long (max 1000)" }, 400);
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
@ -172,7 +242,9 @@ Deno.serve(async (req) => {
let cron = (body.cron_expression ?? "").trim(); let cron = (body.cron_expression ?? "").trim();
let actionPrompt = (body.action_prompt ?? "").trim(); let actionPrompt = (body.action_prompt ?? "").trim();
let reqSlugs = Array.isArray(body.required_mcp_slugs) let reqSlugs = Array.isArray(body.required_mcp_slugs)
? body.required_mcp_slugs.filter((s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s)) ? body.required_mcp_slugs.filter(
(s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s),
)
: []; : [];
if (!cron || !actionPrompt) { if (!cron || !actionPrompt) {
@ -208,7 +280,9 @@ Deno.serve(async (req) => {
let humanReadable = cron; let humanReadable = cron;
try { try {
humanReadable = cronstrue.toString(cron, { locale: "pt_BR" }); humanReadable = cronstrue.toString(cron, { locale: "pt_BR" });
} catch (_) { /* fallback */ } } catch (_) {
/* fallback */
}
// 7) Nome: usa o fornecido ou deriva do input // 7) Nome: usa o fornecido ou deriva do input
const name = (body.name ?? "").trim() || input.slice(0, 80); const name = (body.name ?? "").trim() || input.slice(0, 80);
@ -245,7 +319,8 @@ Deno.serve(async (req) => {
return json({ error: "failed to create cronjob", detail: insertErr.message }, 500); return json({ error: "failed to create cronjob", detail: insertErr.message }, 500);
} }
// 9) Push para o runtime (best-effort; não derruba a criação se falhar) // 9) Push para o runtime. Se falhar, o job fica registrado como erro,
// mas não fica ativo na UI como se estivesse realmente agendado.
let syncOk = false; let syncOk = false;
let syncError: string | null = null; let syncError: string | null = null;
try { try {
@ -259,7 +334,23 @@ Deno.serve(async (req) => {
syncOk = true; syncOk = true;
} catch (e) { } catch (e) {
syncError = e instanceof Error ? e.message : String(e); syncError = e instanceof Error ? e.message : String(e);
console.error("runtime sync failed (job created anyway):", syncError); console.error("runtime sync failed; marking job as error:", syncError);
await markJobRuntimeSyncError(admin, inserted.id, syncError);
return json(
{
success: false,
job_id: inserted.id,
name: inserted.name,
cron_expression: inserted.cron_expression,
human_readable: inserted.human_readable,
next_run_at: inserted.next_run_at,
required_mcp_slugs: inserted.required_mcp_slugs,
status: "error",
runtime_sync_ok: false,
runtime_sync_error: syncError,
},
502,
);
} }
return json({ return json({
@ -270,6 +361,7 @@ Deno.serve(async (req) => {
human_readable: inserted.human_readable, human_readable: inserted.human_readable,
next_run_at: inserted.next_run_at, next_run_at: inserted.next_run_at,
required_mcp_slugs: inserted.required_mcp_slugs, required_mcp_slugs: inserted.required_mcp_slugs,
status: inserted.status,
runtime_sync_ok: syncOk, runtime_sync_ok: syncOk,
runtime_sync_error: syncError, runtime_sync_error: syncError,
}); });

View file

@ -0,0 +1,334 @@
// create-skill-from-agent
// Endpoint server-to-server chamado pelo runtime Mika/Hermes quando o usuario
// pede pelo Telegram para criar uma nova skill.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.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 INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? "";
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
const MODEL = "google/gemini-2.5-flash";
const CONTRACT_VERSION = "2026-05-28";
const MAX_MARKDOWN_LEN = 50000;
interface RequestBody {
agent_instance_id: string;
natural_language_input: string;
name?: string;
description?: string;
trigger_keywords?: string;
markdown_content?: string;
}
interface GeneratedSkill {
name: string;
description: string;
trigger_keywords: string;
markdown_content: string;
}
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function constantTimeEq(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
function isAuthorized(req: Request): boolean {
const received = req.headers.get("x-internal-secret") ?? "";
return (
!!INTERNAL_FUNCTION_SECRET && !!received && constantTimeEq(INTERNAL_FUNCTION_SECRET, received)
);
}
function normalizeText(value: unknown, fallback: string, max = 200): string {
const text = typeof value === "string" ? value.trim() : "";
return (text || fallback).slice(0, max);
}
function extractFrontmatterField(markdown: string, field: string): string | null {
const match = markdown.match(new RegExp(`^${field}:\\s*(.+)$`, "im"));
return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null;
}
function ensureSkillFrontmatter(skill: GeneratedSkill): string {
const markdown = skill.markdown_content
.trim()
.replace(/^```(?:markdown)?\s*/i, "")
.replace(/```$/i, "")
.trim();
const body = markdown.replace(/^---\s*[\s\S]*?\n---\s*/i, "").trim();
return `---
name: ${skill.name}
description: ${skill.description}
trigger_keywords: ${skill.trigger_keywords}
---
${body}`.slice(0, MAX_MARKDOWN_LEN);
}
function tryParseJson(text: string): GeneratedSkill | null {
try {
return JSON.parse(text) as GeneratedSkill;
} catch (_) {
// segue
}
const match = text.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]) as GeneratedSkill;
} catch (_) {
return null;
}
}
async function generateSkillFromInput(input: string): Promise<GeneratedSkill | null> {
if (!LOVABLE_API_KEY) return null;
const systemPrompt = `Voce cria skills para o Hermes Agent no padrao agentskills.io.
Retorne APENAS JSON valido, sem markdown fence e sem comentarios.
Formato:
{"name":"Nome curto","description":"Descricao curta","trigger_keywords":"palavra, sinonimo, frase","markdown_content":"---\\nname: ...\\ndescription: ...\\ntrigger_keywords: ...\\n---\\n\\n## Quando usar\\n...\\n\\n## Inputs esperados\\n...\\n\\n## Passo a passo\\n1. ...\\n\\n## Ferramentas necessarias\\n...\\n\\n## Criterio de sucesso\\n..."}
Use portugues brasileiro, comandos claros, e nao prometa executar ferramentas que nao foram citadas ou conectadas.`;
const res = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 1800,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: input },
],
}),
});
if (!res.ok) {
console.error("create-skill-from-agent AI gateway status:", res.status);
return null;
}
const data = await res.json();
const content: string = data?.choices?.[0]?.message?.content ?? "";
return tryParseJson(content);
}
function buildFormInputs(skill: GeneratedSkill, originalInput: string): Record<string, unknown> {
return {
name: skill.name,
description: skill.description,
trigger_keywords: skill.trigger_keywords,
expected_inputs: originalInput,
steps: "Criada via Hermes a partir de instrucao em linguagem natural.",
required_tools: [],
success_criteria: "A skill possui gatilhos claros, passos executaveis e criterio de sucesso.",
example_use_case: originalInput,
};
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
if (!isAuthorized(req)) {
return json({ error: "unauthorized" }, 401);
}
if (req.method === "GET" || req.method === "HEAD") {
return json({
success: true,
endpoint: "create-skill-from-agent",
contract_version: CONTRACT_VERSION,
expected_header: "X-Internal-Secret",
required_body_fields: ["agent_instance_id", "natural_language_input"],
optional_body_fields: ["name", "description", "trigger_keywords", "markdown_content"],
});
}
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
let body: RequestBody;
try {
body = await req.json();
} catch {
return json({ error: "invalid json body" }, 400);
}
if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400);
const input = (body.natural_language_input ?? "").trim();
if (input.length < 10) return json({ error: "natural_language_input too short" }, 400);
if (input.length > 3000)
return json({ error: "natural_language_input too long (max 3000)" }, 400);
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: agent, error: agentErr } = await admin
.from("agent_instances")
.select("id, user_id")
.eq("id", body.agent_instance_id)
.maybeSingle();
if (agentErr || !agent) return json({ error: "agent_instance not found" }, 404);
let generated: GeneratedSkill | null = null;
if (body.markdown_content?.trim()) {
const markdown = body.markdown_content.trim();
generated = {
name: normalizeText(
body.name ?? extractFrontmatterField(markdown, "name"),
"Skill criada pelo Hermes",
80,
),
description: normalizeText(
body.description ?? extractFrontmatterField(markdown, "description"),
"Skill criada via Hermes.",
240,
),
trigger_keywords: normalizeText(
body.trigger_keywords ?? extractFrontmatterField(markdown, "trigger_keywords"),
input.slice(0, 120),
240,
),
markdown_content: markdown,
};
} else {
generated = await generateSkillFromInput(input);
}
if (!generated?.markdown_content?.trim()) {
return json({ error: "failed to generate skill content" }, 422);
}
const skillName = normalizeText(body.name ?? generated.name, "Skill criada pelo Hermes", 80);
const skillDescription = normalizeText(
body.description ?? generated.description,
"Skill criada via Hermes.",
240,
);
const skillTriggerKeywords = normalizeText(
body.trigger_keywords ?? generated.trigger_keywords,
input.slice(0, 120),
240,
);
const skill: GeneratedSkill = {
name: skillName,
description: skillDescription,
trigger_keywords: skillTriggerKeywords,
markdown_content: ensureSkillFrontmatter({
name: skillName,
description: skillDescription,
trigger_keywords: skillTriggerKeywords,
markdown_content: generated.markdown_content,
}),
};
const { data: insertedSkill, error: skillErr } = await admin
.from("skills")
.insert({
user_id: agent.user_id,
agent_instance_id: agent.id,
name: skill.name,
description: skill.description,
trigger_keywords: skill.trigger_keywords,
status: "draft",
})
.select("id")
.single();
if (skillErr || !insertedSkill) {
const code = (skillErr as { code?: string } | null)?.code ?? "";
if (code === "P0001") return json({ error: "no active subscription" }, 402);
if (code === "P0002") return json({ error: "skill limit reached for plan" }, 403);
if (code === "23505") return json({ error: "skill name already exists" }, 409);
console.error("create-skill-from-agent insert skill failed:", skillErr);
return json({ error: "failed to create skill", detail: skillErr?.message }, 500);
}
const { data: version, error: versionErr } = await admin
.from("skill_versions")
.insert({
skill_id: insertedSkill.id,
version_number: 1,
markdown_content: skill.markdown_content,
form_inputs: buildFormInputs(skill, input),
is_live: true,
created_by: agent.user_id,
})
.select("id")
.single();
if (versionErr || !version) {
await admin.from("skills").update({ status: "archived" }).eq("id", insertedSkill.id);
console.error("create-skill-from-agent insert version failed:", versionErr);
return json({ error: "failed to create skill version", detail: versionErr?.message }, 500);
}
const { error: publishErr } = await admin
.from("skills")
.update({
current_version_id: version.id,
status: "active",
updated_at: new Date().toISOString(),
})
.eq("id", insertedSkill.id);
if (publishErr) {
console.error("create-skill-from-agent publish failed:", publishErr);
return json({ error: "failed to publish skill", detail: publishErr.message }, 500);
}
try {
const syncResult = await syncAgentSkillsSnapshot({
supabase: admin,
agentInstanceId: agent.id,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
return json({
success: true,
skill_id: insertedSkill.id,
skill_version_id: version.id,
name: skill.name,
description: skill.description,
trigger_keywords: skill.trigger_keywords,
status: "active",
runtime_sync_ok: true,
synced_count: syncResult.synced_count,
});
} catch (e) {
const syncError = e instanceof Error ? e.message : String(e);
await admin.from("skills").update({ status: "testing" }).eq("id", insertedSkill.id);
console.error("create-skill-from-agent skills sync failed:", syncError);
return json(
{
success: false,
skill_id: insertedSkill.id,
skill_version_id: version.id,
name: skill.name,
status: "testing",
runtime_sync_ok: false,
runtime_sync_error: syncError,
},
502,
);
}
});

65
supabase/functions/deno.lock generated Normal file
View file

@ -0,0 +1,65 @@
{
"version": "5",
"specifiers": {
"npm:@supabase/supabase-js@2": "2.106.2"
},
"npm": {
"@supabase/auth-js@2.106.2": {
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"dependencies": [
"tslib"
]
},
"@supabase/functions-js@2.106.2": {
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"dependencies": [
"tslib"
]
},
"@supabase/phoenix@0.4.2": {
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A=="
},
"@supabase/postgrest-js@2.106.2": {
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"dependencies": [
"tslib"
]
},
"@supabase/realtime-js@2.106.2": {
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"dependencies": [
"@supabase/phoenix",
"tslib"
]
},
"@supabase/storage-js@2.106.2": {
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"dependencies": [
"iceberg-js",
"tslib"
]
},
"@supabase/supabase-js@2.106.2": {
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"dependencies": [
"@supabase/auth-js",
"@supabase/functions-js",
"@supabase/postgrest-js",
"@supabase/realtime-js",
"@supabase/storage-js"
]
},
"iceberg-js@0.8.1": {
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
},
"remote": {
"https://esm.sh/@supabase/supabase-js@2.45.4": "1108d69216995335d057dbd15907caaf514a4d1ef082f097050573b9d589a57c",
"https://esm.sh/@supabase/supabase-js@2.57.4": "05a369085eb4a4c99d85ccece97f0cf1e05357122e0e74373da1f0e91b014902",
"https://esm.sh/cron-parser@4.9.0": "d55208635006ce12cce8101d42835ea617d48d335b2e6ec9316169ba42d64813",
"https://esm.sh/cronstrue@2.50.0/i18n": "cf6a244bd4498587470f730691105754b801fe645a53fabde956c56cfe30f492"
}
}

View file

@ -22,6 +22,23 @@ import {
normalizeOllamaModelSelection, normalizeOllamaModelSelection,
} from "../_shared/hermes-config.ts"; } from "../_shared/hermes-config.ts";
type GenericTable = {
Row: Record<string, unknown>;
Insert: Record<string, unknown>;
Update: Record<string, unknown>;
Relationships: [];
};
type GenericDatabase = {
public: {
Tables: Record<string, GenericTable>;
Views: Record<string, GenericTable>;
Functions: Record<string, { Args: Record<string, unknown>; Returns: unknown }>;
};
};
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
interface RequestBody { interface RequestBody {
agent_instance_id: string; agent_instance_id: string;
agent_name?: string; agent_name?: string;
@ -31,28 +48,77 @@ interface RequestBody {
tts_provider?: string; tts_provider?: string;
} }
interface SubscriptionWithPlan {
plans?: { slug?: string | null } | { slug?: string | null }[] | null;
}
interface AgentInstanceRow {
id: string;
user_id: string;
uuid_tenant: string;
status: string;
telegram_bot_token_vault_id?: string | null;
telegram_bot_username?: string | null;
telegram_user_chat_id?: string | number | null;
railway_service_id?: string | null;
agent_name?: string | null;
vps_pool_id?: string | null;
}
function getPlanSlug(subscription: SubscriptionWithPlan | null): string {
const plans = subscription?.plans;
const plan = Array.isArray(plans) ? plans[0] : plans;
return plan?.slug ?? "basic";
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN"); const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
const INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
const HERMES_RUNTIME_IMAGE =
Deno.env.get("HERMES_RUNTIME_IMAGE") ?? "ghcr.io/domfelipe/hermes-agent-custom:latest";
const MIKA_RUNTIME_CONTRACT_VERSION = "2026-05-28";
const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN"); 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 ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID");
function buildRuntimePlatformEnv(agentInstanceId: string): Record<string, string> {
const functionsBaseUrl = `${SUPABASE_URL.replace(/\/$/, "")}/functions/v1`;
const createCronjobUrl = `${functionsBaseUrl}/create-cronjob-from-agent`;
const createSkillUrl = `${functionsBaseUrl}/create-skill-from-agent`;
return {
AGENT_INSTANCE_ID: agentInstanceId,
HERMES_AGENT_INSTANCE_ID: agentInstanceId,
HERMES_CREATE_CRONJOB_URL: createCronjobUrl,
HERMES_CREATE_SKILL_URL: createSkillUrl,
HERMES_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET,
HERMES_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl,
HERMES_RUNTIME_CONTRACT_VERSION: MIKA_RUNTIME_CONTRACT_VERSION,
INTERNAL_FUNCTION_SECRET,
MIKA_AGENT_INSTANCE_ID: agentInstanceId,
MIKA_CREATE_CRONJOB_URL: createCronjobUrl,
MIKA_CREATE_SKILL_URL: createSkillUrl,
MIKA_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET,
MIKA_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl,
MIKA_RUNTIME_CONTRACT_VERSION,
SUPABASE_URL,
};
}
async function notifyAdmin(message: string): Promise<void> { async function notifyAdmin(message: string): Promise<void> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
try { try {
await fetch( await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, {
`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", body: JSON.stringify({
headers: { "Content-Type": "application/json" }, chat_id: ADMIN_TELEGRAM_CHAT_ID,
body: JSON.stringify({ text: message,
chat_id: ADMIN_TELEGRAM_CHAT_ID, parse_mode: "HTML",
text: message, }),
parse_mode: "HTML", });
}),
},
);
} catch (e) { } catch (e) {
console.error("notifyAdmin failed:", e); console.error("notifyAdmin failed:", e);
} }
@ -93,7 +159,7 @@ Deno.serve(async (req) => {
const { data: agent, error: agentErr } = await supabase const { data: agent, error: agentErr } = await supabase
.from("agent_instances") .from("agent_instances")
.select( .select(
"id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, agent_name", "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, agent_name, vps_pool_id",
) )
.eq("id", body.agent_instance_id) .eq("id", body.agent_instance_id)
.maybeSingle(); .maybeSingle();
@ -105,29 +171,33 @@ Deno.serve(async (req) => {
if (agent.status !== "provisioning") { if (agent.status !== "provisioning") {
console.log(`[provision-agent] status atual=${agent.status}, abortando`); console.log(`[provision-agent] status atual=${agent.status}, abortando`);
return jsonResponse(409, { error: "agent_instance is not in provisioning status", status: agent.status }); return jsonResponse(409, {
error: "agent_instance is not in provisioning status",
status: agent.status,
});
} }
// Se já existe railway_service_id → fluxo de UPDATE (não tenta criar novo serviço) // Se já existe railway_service_id → fluxo de UPDATE (não tenta criar novo serviço)
if (agent.railway_service_id) { if (agent.railway_service_id) {
console.log(`[provision-agent] railway_service_id já existe (${agent.railway_service_id}) → modo update`); console.log(
`[provision-agent] railway_service_id já existe (${agent.railway_service_id}) → modo update`,
);
return await handleUpdateExistingService(supabase, agent, body); return await handleUpdateExistingService(supabase, agent, body);
} }
// 1b) Carregar profile (full_name → nome do agente) // 1b) Carregar profile (full_name → nome do agente)
const { data: profile } = await supabase const { data: profileData } = await supabase
.from("profiles") .from("profiles")
.select("full_name") .select("full_name")
.eq("id", agent.user_id) .eq("id", agent.user_id)
.maybeSingle(); .maybeSingle();
const profile = profileData as { full_name?: string | null } | null;
const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const fullName = (profile?.full_name?.trim() || "Usuário").toString();
const firstName = fullName.split(" ")[0] || "Usuário"; const firstName = fullName.split(" ")[0] || "Usuário";
// Prioridade: body > coluna agent_name no DB > default "Mika de {firstName}" // Prioridade: body > coluna agent_name no DB > default "Mika de {firstName}"
const agentName = const agentName =
body.agent_name?.trim() || body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`;
(agent.agent_name?.trim() ?? "") ||
`Mika de ${firstName}`;
console.log(`[provision-agent] profile carregado: ${fullName} → agent_name=${agentName}`); console.log(`[provision-agent] profile carregado: ${fullName} → agent_name=${agentName}`);
// 1c) Carregar subscription ativa (para definir modelo Pro vs Basic) // 1c) Carregar subscription ativa (para definir modelo Pro vs Basic)
@ -140,8 +210,7 @@ Deno.serve(async (req) => {
.limit(1) .limit(1)
.maybeSingle(); .maybeSingle();
// deno-lint-ignore no-explicit-any const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null);
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
console.log(`[provision-agent] plano=${planSlug}`); console.log(`[provision-agent] plano=${planSlug}`);
// 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade) // 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade)
@ -166,7 +235,9 @@ Deno.serve(async (req) => {
); );
return jsonResponse(503, { error: "no railway pool available" }); return jsonResponse(503, { error: "no railway pool available" });
} }
console.log(`[provision-agent] pool selecionado: ${pool.id} (railway_project=${pool.railway_project_id})`); console.log(
`[provision-agent] pool selecionado: ${pool.id} (railway_project=${pool.railway_project_id})`,
);
// 3) Criar provisioning_job em status running // 3) Criar provisioning_job em status running
const { data: job, error: jobErr } = await supabase const { data: job, error: jobErr } = await supabase
@ -190,14 +261,19 @@ Deno.serve(async (req) => {
if (jobErr || !job) { if (jobErr || !job) {
console.error(`[provision-agent] falha ao criar job: ${jobErr?.message}`); console.error(`[provision-agent] falha ao criar job: ${jobErr?.message}`);
return jsonResponse(500, { error: "failed to create provisioning_job", detail: jobErr?.message }); return jsonResponse(500, {
error: "failed to create provisioning_job",
detail: jobErr?.message,
});
} }
console.log(`[provision-agent] provisioning_job criado: ${job.id}`); console.log(`[provision-agent] provisioning_job criado: ${job.id}`);
// 4) Decrypt do telegram_bot_token (se existir) // 4) Decrypt do telegram_bot_token (se existir)
let telegramBotToken = ""; let telegramBotToken = "";
if (agent.telegram_bot_token_vault_id) { if (agent.telegram_bot_token_vault_id) {
console.log(`[provision-agent] decifrando token do Vault: ${agent.telegram_bot_token_vault_id}`); console.log(
`[provision-agent] decifrando token do Vault: ${agent.telegram_bot_token_vault_id}`,
);
const { data: secret } = await supabase.rpc("vault_decrypt_secret", { const { data: secret } = await supabase.rpc("vault_decrypt_secret", {
secret_id: agent.telegram_bot_token_vault_id, secret_id: agent.telegram_bot_token_vault_id,
}); });
@ -240,9 +316,10 @@ Deno.serve(async (req) => {
const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL);
const envVars: Record<string, string> = { const envVars: Record<string, string> = {
...buildRuntimePlatformEnv(agent.id),
HERMES_HOME: "/opt/data/.hermes", HERMES_HOME: "/opt/data/.hermes",
API_SERVER_ENABLED: "true", API_SERVER_ENABLED: "true",
API_SERVER_KEY: Deno.env.get("HERMES_API_SERVER_KEY") ?? "", API_SERVER_KEY: HERMES_API_SERVER_KEY,
GATEWAY_ALLOW_ALL_USERS: "false", GATEWAY_ALLOW_ALL_USERS: "false",
HERMES_MODEL_DEFAULT: modelFinal, HERMES_MODEL_DEFAULT: modelFinal,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
@ -275,7 +352,9 @@ Deno.serve(async (req) => {
const msg = createErr instanceof Error ? createErr.message : String(createErr); const msg = createErr instanceof Error ? createErr.message : String(createErr);
// Recover from "service already exists" — provavelmente sobra de attempt anterior // Recover from "service already exists" — provavelmente sobra de attempt anterior
if (msg.includes("already exists")) { if (msg.includes("already exists")) {
console.warn(`[provision-agent] serviço já existe, tentando recuperar ID por nome: ${serviceName}`); console.warn(
`[provision-agent] serviço já existe, tentando recuperar ID por nome: ${serviceName}`,
);
const existingId = await findRailwayServiceByName({ const existingId = await findRailwayServiceByName({
token: RAILWAY_API_TOKEN, token: RAILWAY_API_TOKEN,
projectId: pool.railway_project_id, projectId: pool.railway_project_id,
@ -296,11 +375,13 @@ Deno.serve(async (req) => {
serviceId: railwayServiceId, serviceId: railwayServiceId,
environmentId: pool.railway_environment_id, environmentId: pool.railway_environment_id,
projectId: pool.railway_project_id, projectId: pool.railway_project_id,
image: "ghcr.io/domfelipe/hermes-agent-custom:latest", image: HERMES_RUNTIME_IMAGE,
variables: envVars, variables: envVars,
startCommand: HERMES_START_COMMAND, startCommand: HERMES_START_COMMAND,
}); });
console.log(`[provision-agent] serviço configurado com ${Object.keys(envVars).length} env vars`); console.log(
`[provision-agent] serviço configurado com ${Object.keys(envVars).length} env vars`,
);
await deployRailwayService({ await deployRailwayService({
token: RAILWAY_API_TOKEN, token: RAILWAY_API_TOKEN,
@ -346,7 +427,9 @@ Deno.serve(async (req) => {
.update({ railway_service_id: railwayServiceId, status: "running" }) .update({ railway_service_id: railwayServiceId, status: "running" })
.eq("id", job.id); .eq("id", job.id);
console.log(`[provision-agent] sucesso: agent=${agent.id} railway=${railwayServiceId} (aguardando deploy)`); console.log(
`[provision-agent] sucesso: agent=${agent.id} railway=${railwayServiceId} (aguardando deploy)`,
);
// status do agent permanece 'provisioning' — railway-webhook atualiza para 'active' quando deploy subir // status do agent permanece 'provisioning' — railway-webhook atualiza para 'active' quando deploy subir
return jsonResponse(200, { return jsonResponse(200, {
@ -367,8 +450,7 @@ function jsonResponse(status: number, body: unknown) {
} }
async function failJob( async function failJob(
// deno-lint-ignore no-explicit-any supabase: SupabaseAdminClient,
supabase: any,
agent: { id: string }, agent: { id: string },
jobId: string | null, jobId: string | null,
message: string, message: string,
@ -383,8 +465,7 @@ async function failJob(
} }
async function scheduleRetry( async function scheduleRetry(
// deno-lint-ignore no-explicit-any supabase: SupabaseAdminClient,
supabase: any,
agent: { id: string }, agent: { id: string },
jobId: string, jobId: string,
message: string, message: string,
@ -424,27 +505,27 @@ async function scheduleRetry(
* faz upsert das variáveis de ambiente com defaults automáticos e dispara redeploy. * faz upsert das variáveis de ambiente com defaults automáticos e dispara redeploy.
*/ */
async function handleUpdateExistingService( async function handleUpdateExistingService(
// deno-lint-ignore no-explicit-any supabase: SupabaseAdminClient,
supabase: any, agent: AgentInstanceRow,
// deno-lint-ignore no-explicit-any
agent: any,
body: RequestBody, body: RequestBody,
): Promise<Response> { ): Promise<Response> {
const railwayServiceId: string = agent.railway_service_id; const railwayServiceId = agent.railway_service_id;
if (!railwayServiceId) {
return jsonResponse(400, { error: "railway_service_id required for update mode" });
}
// Carregar profile + plano para gerar defaults coerentes // Carregar profile + plano para gerar defaults coerentes
const { data: profile } = await supabase const { data: profileData } = await supabase
.from("profiles") .from("profiles")
.select("full_name") .select("full_name")
.eq("id", agent.user_id) .eq("id", agent.user_id)
.maybeSingle(); .maybeSingle();
const profile = profileData as { full_name?: string | null } | null;
const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const fullName = (profile?.full_name?.trim() || "Usuário").toString();
const firstName = fullName.split(" ")[0] || "Usuário"; const firstName = fullName.split(" ")[0] || "Usuário";
const agentName = const agentName =
body.agent_name?.trim() || body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`;
(agent.agent_name?.trim() ?? "") ||
`Mika de ${firstName}`;
const { data: subscription } = await supabase const { data: subscription } = await supabase
.from("subscriptions") .from("subscriptions")
@ -455,8 +536,7 @@ async function handleUpdateExistingService(
.limit(1) .limit(1)
.maybeSingle(); .maybeSingle();
// deno-lint-ignore no-explicit-any const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null);
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
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 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 soulContent = body.soul_content?.trim() || defaultSoul;
@ -464,18 +544,24 @@ async function handleUpdateExistingService(
const sttProvider = body.stt_provider || "local"; const sttProvider = body.stt_provider || "local";
const ttsProvider = body.tts_provider || "disabled"; const ttsProvider = body.tts_provider || "disabled";
console.log(`[provision-agent:update] agent=${agent.id} service=${railwayServiceId} plano=${planSlug}`); console.log(
`[provision-agent:update] agent=${agent.id} service=${railwayServiceId} plano=${planSlug}`,
);
// Resolver project/environment Railway // Resolver project/environment Railway
let projectId: string | null = null; let projectId: string | null = null;
let environmentId: string | null = null; let environmentId: string | null = null;
if (agent.vps_pool_id) { if (agent.vps_pool_id) {
const { data: pool } = await supabase const { data: poolData } = await supabase
.from("vps_pool") .from("vps_pool")
.select("railway_project_id, railway_environment_id") .select("railway_project_id, railway_environment_id")
.eq("id", agent.vps_pool_id) .eq("id", agent.vps_pool_id)
.maybeSingle(); .maybeSingle();
const pool = poolData as {
railway_project_id?: string | null;
railway_environment_id?: string | null;
} | null;
projectId = pool?.railway_project_id ?? null; projectId = pool?.railway_project_id ?? null;
environmentId = pool?.railway_environment_id ?? null; environmentId = pool?.railway_environment_id ?? null;
} }
@ -492,6 +578,11 @@ async function handleUpdateExistingService(
} }
const variables: Record<string, string> = { const variables: Record<string, string> = {
...buildRuntimePlatformEnv(agent.id),
API_SERVER_ENABLED: "true",
API_SERVER_KEY: HERMES_API_SERVER_KEY,
GATEWAY_ALLOW_ALL_USERS: "false",
HERMES_HOME: "/opt/data/.hermes",
HERMES_MODEL_DEFAULT: model, HERMES_MODEL_DEFAULT: model,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
HERMES_SOUL_OVERRIDE: soulContent, HERMES_SOUL_OVERRIDE: soulContent,
@ -517,14 +608,16 @@ async function handleUpdateExistingService(
variables, variables,
skipDeploys: true, skipDeploys: true,
}); });
console.log(`[provision-agent:update] variáveis atualizadas (${Object.keys(variables).length})`); console.log(
`[provision-agent:update] variáveis atualizadas (${Object.keys(variables).length})`,
);
await configureRailwayService({ await configureRailwayService({
token: RAILWAY_API_TOKEN!, token: RAILWAY_API_TOKEN!,
serviceId: railwayServiceId, serviceId: railwayServiceId,
environmentId, environmentId,
projectId, projectId,
image: "ghcr.io/domfelipe/hermes-agent-custom:latest", image: HERMES_RUNTIME_IMAGE,
variables: {}, variables: {},
startCommand: HERMES_START_COMMAND, startCommand: HERMES_START_COMMAND,
}); });

View file

@ -10,10 +10,33 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { corsHeaders } from "../_shared/cors.ts"; import { corsHeaders } from "../_shared/cors.ts";
import { import { syncAgentRuntimeSnapshot, syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts";
syncAgentRuntimeSnapshot, import { ensureDefaultSkillsForAgent } from "../_shared/default-skills.ts";
syncAgentSkillsSnapshot,
} from "../_shared/runtime-sync.ts"; type GenericTable = {
Row: Record<string, unknown>;
Insert: Record<string, unknown>;
Update: Record<string, unknown>;
Relationships: [];
};
type GenericDatabase = {
public: {
Tables: Record<string, GenericTable>;
Views: Record<string, GenericTable>;
Functions: Record<string, { Args: Record<string, unknown>; Returns: unknown }>;
};
};
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
interface AgentWelcomeRow {
id: string;
user_id: string;
telegram_bot_token_vault_id: string;
telegram_user_chat_id: string | number;
agent_name?: string | null;
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
@ -25,18 +48,15 @@ const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
async function notifyAdmin(message: string): Promise<void> { async function notifyAdmin(message: string): Promise<void> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
try { try {
await fetch( await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, {
`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", body: JSON.stringify({
headers: { "Content-Type": "application/json" }, chat_id: ADMIN_TELEGRAM_CHAT_ID,
body: JSON.stringify({ text: message,
chat_id: ADMIN_TELEGRAM_CHAT_ID, parse_mode: "HTML",
text: message, }),
parse_mode: "HTML", });
}),
},
);
} catch (e) { } catch (e) {
console.error("notifyAdmin failed:", e); console.error("notifyAdmin failed:", e);
} }
@ -64,7 +84,9 @@ Deno.serve(async (req) => {
["sign"], ["sign"],
); );
const macBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody)); const macBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));
const macHex = Array.from(new Uint8Array(macBuf)).map((b) => b.toString(16).padStart(2, "0")).join(""); const macHex = Array.from(new Uint8Array(macBuf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const expected = sig.startsWith("sha256=") ? sig.slice(7) : sig; const expected = sig.startsWith("sha256=") ? sig.slice(7) : sig;
if (expected !== macHex) { if (expected !== macHex) {
console.warn("railway-webhook: invalid signature"); console.warn("railway-webhook: invalid signature");
@ -188,7 +210,10 @@ Deno.serve(async (req) => {
); );
} catch (e) { } catch (e) {
runtimeSyncError = e instanceof Error ? e.message : String(e); runtimeSyncError = e instanceof Error ? e.message : String(e);
console.error(`railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`, runtimeSyncError); console.error(
`railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`,
runtimeSyncError,
);
if (wasProvisioning) { if (wasProvisioning) {
const fullName = await loadFullName(); const fullName = await loadFullName();
await notifyAdmin( await notifyAdmin(
@ -201,6 +226,33 @@ Deno.serve(async (req) => {
} }
} }
let defaultSkillsError: string | null = null;
try {
const defaultSkillsResult = await ensureDefaultSkillsForAgent(supabase, agent.id);
if (defaultSkillsResult.errors.length > 0) {
throw new Error(defaultSkillsResult.errors.join("; "));
}
console.log(
`railway-webhook: default skills agent ${agent.id} (${defaultSkillsResult.created_count} criadas, ${defaultSkillsResult.skipped_count} existentes)`,
);
} catch (e) {
defaultSkillsError = e instanceof Error ? e.message : String(e);
console.error(
`railway-webhook: falha ao garantir skills padrão do agent ${agent.id}:`,
defaultSkillsError,
);
if (wasProvisioning) {
const fullName = await loadFullName();
await notifyAdmin(
`⚠️ <b>Agente subiu, mas as skills padrão falharam</b>\n\n` +
`👤 <b>Cliente:</b> ${fullName}\n` +
`🚀 <b>Railway:</b> <code>${agent.railway_service_id}</code>\n` +
`❗ <b>Erro:</b> ${defaultSkillsError}\n\n` +
`➡️ <a href="https://mika.domco.ai/admin">Revisar no admin</a>`,
);
}
}
let skillsSyncError: string | null = null; let skillsSyncError: string | null = null;
try { try {
const syncResult = await syncAgentSkillsSnapshot({ const syncResult = await syncAgentSkillsSnapshot({
@ -214,7 +266,10 @@ Deno.serve(async (req) => {
); );
} catch (e) { } catch (e) {
skillsSyncError = e instanceof Error ? e.message : String(e); skillsSyncError = e instanceof Error ? e.message : String(e);
console.error(`railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`, skillsSyncError); console.error(
`railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`,
skillsSyncError,
);
if (wasProvisioning) { if (wasProvisioning) {
const fullName = await loadFullName(); const fullName = await loadFullName();
await notifyAdmin( await notifyAdmin(
@ -228,7 +283,7 @@ Deno.serve(async (req) => {
} }
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning) // Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
if (wasProvisioning && !skillsSyncError && !runtimeSyncError) { if (wasProvisioning && !skillsSyncError && !runtimeSyncError && !defaultSkillsError) {
const fullName = await loadFullName(); const fullName = await loadFullName();
await notifyAdmin( await notifyAdmin(
`✅ <b>Agente provisionado automaticamente!</b>\n\n` + `✅ <b>Agente provisionado automaticamente!</b>\n\n` +
@ -243,10 +298,7 @@ Deno.serve(async (req) => {
} }
if (upper === "FAILED" || upper === "CRASHED") { if (upper === "FAILED" || upper === "CRASHED") {
await supabase await supabase.from("agent_instances").update({ status: "error" }).eq("id", agent.id);
.from("agent_instances")
.update({ status: "error" })
.eq("id", agent.id);
await supabase await supabase
.from("provisioning_jobs") .from("provisioning_jobs")
@ -286,12 +338,15 @@ function jsonResponse(status: number, body: unknown) {
}); });
} }
// deno-lint-ignore no-explicit-any async function sendWelcomeMessage(
async function sendWelcomeMessage(supabase: any, agent: any): Promise<void> { supabase: SupabaseAdminClient,
agent: AgentWelcomeRow,
): Promise<void> {
// Decifra o token do bot // Decifra o token do bot
const { data: secret } = await supabase.rpc("vault_decrypt_secret", { const { data: secretData } = await supabase.rpc("vault_decrypt_secret", {
secret_id: agent.telegram_bot_token_vault_id, secret_id: agent.telegram_bot_token_vault_id,
}); });
const secret = secretData as { decrypted_secret?: string | null }[] | null;
const token: string = secret?.[0]?.decrypted_secret ?? ""; const token: string = secret?.[0]?.decrypted_secret ?? "";
if (!token) { if (!token) {
console.warn("sendWelcomeMessage: token vazio, abortando"); console.warn("sendWelcomeMessage: token vazio, abortando");