mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
fix: wire mika runtime cron and skill actions
This commit is contained in:
parent
6c0ccff305
commit
e9f22e6ceb
16 changed files with 1562 additions and 1310 deletions
3
.github/workflows/validate.yml
vendored
3
.github/workflows/validate.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -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`).
|
||||||
|
|
@ -156,7 +162,7 @@ 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) |
|
||||||
|
|
@ -168,16 +174,16 @@ https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/oauth-callback
|
||||||
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
1525
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,7 +54,10 @@ 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 &&
|
||||||
|
"timezone" in profile &&
|
||||||
|
typeof (profile as { timezone?: string }).timezone === "string"
|
||||||
? (profile as { timezone: string }).timezone
|
? (profile as { timezone: string }).timezone
|
||||||
: "America/Sao_Paulo";
|
: "America/Sao_Paulo";
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -216,18 +222,21 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
|
||||||
<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
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: () => {
|
||||||
|
|
|
||||||
|
|
@ -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,9 +293,7 @@ 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"] })
|
|
||||||
.then(() => {
|
|
||||||
const stillConnected = integs.some((i) => i.id === integration.id);
|
const stillConnected = integs.some((i) => i.id === integration.id);
|
||||||
if (!stillConnected) {
|
if (!stillConnected) {
|
||||||
navigate({ to: "/painel/integracoes", search: {} });
|
navigate({ to: "/painel/integracoes", search: {} });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
301
supabase/functions/_shared/default-skills.ts
Normal file
301
supabase/functions/_shared/default-skills.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
334
supabase/functions/create-skill-from-agent/index.ts
Normal file
334
supabase/functions/create-skill-from-agent/index.ts
Normal 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
65
supabase/functions/deno.lock
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,19 +48,69 @@ 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -51,8 +118,7 @@ async function notifyAdmin(message: string): Promise<void> {
|
||||||
text: message,
|
text: message,
|
||||||
parse_mode: "HTML",
|
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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,9 +48,7 @@ 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -35,8 +56,7 @@ async function notifyAdmin(message: string): Promise<void> {
|
||||||
text: message,
|
text: message,
|
||||||
parse_mode: "HTML",
|
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");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue