Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-05-28 12:46:33 +00:00
parent 6c0ccff305
commit c6b67e98b8
9 changed files with 1033 additions and 141 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,12 +23,30 @@ import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n";
import { corsHeaders } from "../_shared/cors.ts";
import { 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_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 CONTRACT_VERSION = "2026-05-28";
const MODEL = "google/gemini-2.5-flash";
@ -66,6 +84,13 @@ function constantTimeEq(a: string, b: string): boolean {
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 {
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 {
try {
return JSON.parse(text) as ParsedJob;
} catch (_) { /* segue */ }
} catch (_) {
/* segue */
}
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try {
return JSON.parse(match[0]) as ParsedJob;
} catch (_) { /* falhou */ }
} catch (_) {
/* falhou */
}
}
return null;
}
@ -123,16 +152,56 @@ async function parseWithAI(input: string, tz: string): Promise<ParsedJob | null>
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) => {
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)
const received = req.headers.get("x-internal-secret") ?? "";
if (!INTERNAL_FUNCTION_SECRET || !received || !constantTimeEq(INTERNAL_FUNCTION_SECRET, received)) {
if (!isAuthorized(req)) {
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
let body: RequestBody;
try {
@ -144,7 +213,8 @@ Deno.serve(async (req) => {
if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400);
const input = (body.natural_language_input ?? "").trim();
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, {
auth: { persistSession: false, autoRefreshToken: false },
@ -172,7 +242,9 @@ Deno.serve(async (req) => {
let cron = (body.cron_expression ?? "").trim();
let actionPrompt = (body.action_prompt ?? "").trim();
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) {
@ -208,7 +280,9 @@ Deno.serve(async (req) => {
let humanReadable = cron;
try {
humanReadable = cronstrue.toString(cron, { locale: "pt_BR" });
} catch (_) { /* fallback */ }
} catch (_) {
/* fallback */
}
// 7) Nome: usa o fornecido ou deriva do input
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);
}
// 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 syncError: string | null = null;
try {
@ -259,7 +334,23 @@ Deno.serve(async (req) => {
syncOk = true;
} catch (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({
@ -270,6 +361,7 @@ Deno.serve(async (req) => {
human_readable: inserted.human_readable,
next_run_at: inserted.next_run_at,
required_mcp_slugs: inserted.required_mcp_slugs,
status: inserted.status,
runtime_sync_ok: syncOk,
runtime_sync_error: syncError,
});

View file

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

View file

@ -22,6 +22,23 @@ import {
normalizeOllamaModelSelection,
} 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 {
agent_instance_id: string;
agent_name?: string;
@ -31,28 +48,77 @@ interface RequestBody {
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_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
const 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_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> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
try {
await fetch(
`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: ADMIN_TELEGRAM_CHAT_ID,
text: message,
parse_mode: "HTML",
}),
},
);
await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: ADMIN_TELEGRAM_CHAT_ID,
text: message,
parse_mode: "HTML",
}),
});
} catch (e) {
console.error("notifyAdmin failed:", e);
}
@ -93,7 +159,7 @@ Deno.serve(async (req) => {
const { data: agent, error: agentErr } = await supabase
.from("agent_instances")
.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)
.maybeSingle();
@ -105,29 +171,33 @@ Deno.serve(async (req) => {
if (agent.status !== "provisioning") {
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)
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);
}
// 1b) Carregar profile (full_name → nome do agente)
const { data: profile } = await supabase
const { data: profileData } = await supabase
.from("profiles")
.select("full_name")
.eq("id", agent.user_id)
.maybeSingle();
const profile = profileData as { full_name?: string | null } | null;
const fullName = (profile?.full_name?.trim() || "Usuário").toString();
const firstName = fullName.split(" ")[0] || "Usuário";
// Prioridade: body > coluna agent_name no DB > default "Mika de {firstName}"
const agentName =
body.agent_name?.trim() ||
(agent.agent_name?.trim() ?? "") ||
`Mika de ${firstName}`;
body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`;
console.log(`[provision-agent] profile carregado: ${fullName} → agent_name=${agentName}`);
// 1c) Carregar subscription ativa (para definir modelo Pro vs Basic)
@ -140,8 +210,7 @@ Deno.serve(async (req) => {
.limit(1)
.maybeSingle();
// deno-lint-ignore no-explicit-any
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null);
console.log(`[provision-agent] plano=${planSlug}`);
// 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" });
}
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
const { data: job, error: jobErr } = await supabase
@ -190,14 +261,19 @@ Deno.serve(async (req) => {
if (jobErr || !job) {
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}`);
// 4) Decrypt do telegram_bot_token (se existir)
let telegramBotToken = "";
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", {
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 envVars: Record<string, string> = {
...buildRuntimePlatformEnv(agent.id),
HERMES_HOME: "/opt/data/.hermes",
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",
HERMES_MODEL_DEFAULT: modelFinal,
HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
@ -275,7 +352,9 @@ Deno.serve(async (req) => {
const msg = createErr instanceof Error ? createErr.message : String(createErr);
// Recover from "service already exists" — provavelmente sobra de attempt anterior
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({
token: RAILWAY_API_TOKEN,
projectId: pool.railway_project_id,
@ -296,11 +375,13 @@ Deno.serve(async (req) => {
serviceId: railwayServiceId,
environmentId: pool.railway_environment_id,
projectId: pool.railway_project_id,
image: "ghcr.io/domfelipe/hermes-agent-custom:latest",
image: HERMES_RUNTIME_IMAGE,
variables: envVars,
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({
token: RAILWAY_API_TOKEN,
@ -346,7 +427,9 @@ Deno.serve(async (req) => {
.update({ railway_service_id: railwayServiceId, status: "running" })
.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
return jsonResponse(200, {
@ -367,8 +450,7 @@ function jsonResponse(status: number, body: unknown) {
}
async function failJob(
// deno-lint-ignore no-explicit-any
supabase: any,
supabase: SupabaseAdminClient,
agent: { id: string },
jobId: string | null,
message: string,
@ -383,8 +465,7 @@ async function failJob(
}
async function scheduleRetry(
// deno-lint-ignore no-explicit-any
supabase: any,
supabase: SupabaseAdminClient,
agent: { id: string },
jobId: string,
message: string,
@ -424,27 +505,27 @@ async function scheduleRetry(
* faz upsert das variáveis de ambiente com defaults automáticos e dispara redeploy.
*/
async function handleUpdateExistingService(
// deno-lint-ignore no-explicit-any
supabase: any,
// deno-lint-ignore no-explicit-any
agent: any,
supabase: SupabaseAdminClient,
agent: AgentInstanceRow,
body: RequestBody,
): 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
const { data: profile } = await supabase
const { data: profileData } = await supabase
.from("profiles")
.select("full_name")
.eq("id", agent.user_id)
.maybeSingle();
const profile = profileData as { full_name?: string | null } | null;
const fullName = (profile?.full_name?.trim() || "Usuário").toString();
const firstName = fullName.split(" ")[0] || "Usuário";
const agentName =
body.agent_name?.trim() ||
(agent.agent_name?.trim() ?? "") ||
`Mika de ${firstName}`;
body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`;
const { data: subscription } = await supabase
.from("subscriptions")
@ -455,8 +536,7 @@ async function handleUpdateExistingService(
.limit(1)
.maybeSingle();
// deno-lint-ignore no-explicit-any
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null);
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;
@ -464,18 +544,24 @@ async function handleUpdateExistingService(
const sttProvider = body.stt_provider || "local";
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
let projectId: string | null = null;
let environmentId: string | null = null;
if (agent.vps_pool_id) {
const { data: pool } = await supabase
const { data: poolData } = await supabase
.from("vps_pool")
.select("railway_project_id, railway_environment_id")
.eq("id", agent.vps_pool_id)
.maybeSingle();
const pool = poolData as {
railway_project_id?: string | null;
railway_environment_id?: string | null;
} | null;
projectId = pool?.railway_project_id ?? null;
environmentId = pool?.railway_environment_id ?? null;
}
@ -492,6 +578,11 @@ async function handleUpdateExistingService(
}
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_PROVIDER: DEFAULT_OLLAMA_PROVIDER,
HERMES_SOUL_OVERRIDE: soulContent,
@ -517,14 +608,16 @@ async function handleUpdateExistingService(
variables,
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({
token: RAILWAY_API_TOKEN!,
serviceId: railwayServiceId,
environmentId,
projectId,
image: "ghcr.io/domfelipe/hermes-agent-custom:latest",
image: HERMES_RUNTIME_IMAGE,
variables: {},
startCommand: HERMES_START_COMMAND,
});

View file

@ -10,10 +10,33 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { corsHeaders } from "../_shared/cors.ts";
import {
syncAgentRuntimeSnapshot,
syncAgentSkillsSnapshot,
} from "../_shared/runtime-sync.ts";
import { syncAgentRuntimeSnapshot, syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts";
import { ensureDefaultSkillsForAgent } from "../_shared/default-skills.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_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
@ -25,18 +48,15 @@ const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
async function notifyAdmin(message: string): Promise<void> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
try {
await fetch(
`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: ADMIN_TELEGRAM_CHAT_ID,
text: message,
parse_mode: "HTML",
}),
},
);
await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: ADMIN_TELEGRAM_CHAT_ID,
text: message,
parse_mode: "HTML",
}),
});
} catch (e) {
console.error("notifyAdmin failed:", e);
}
@ -64,7 +84,9 @@ Deno.serve(async (req) => {
["sign"],
);
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;
if (expected !== macHex) {
console.warn("railway-webhook: invalid signature");
@ -188,7 +210,10 @@ Deno.serve(async (req) => {
);
} catch (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) {
const fullName = await loadFullName();
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;
try {
const syncResult = await syncAgentSkillsSnapshot({
@ -214,7 +266,10 @@ Deno.serve(async (req) => {
);
} catch (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) {
const fullName = await loadFullName();
await notifyAdmin(
@ -228,7 +283,7 @@ Deno.serve(async (req) => {
}
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
if (wasProvisioning && !skillsSyncError && !runtimeSyncError) {
if (wasProvisioning && !skillsSyncError && !runtimeSyncError && !defaultSkillsError) {
const fullName = await loadFullName();
await notifyAdmin(
`✅ <b>Agente provisionado automaticamente!</b>\n\n` +
@ -243,10 +298,7 @@ Deno.serve(async (req) => {
}
if (upper === "FAILED" || upper === "CRASHED") {
await supabase
.from("agent_instances")
.update({ status: "error" })
.eq("id", agent.id);
await supabase.from("agent_instances").update({ status: "error" }).eq("id", agent.id);
await supabase
.from("provisioning_jobs")
@ -286,12 +338,15 @@ function jsonResponse(status: number, body: unknown) {
});
}
// deno-lint-ignore no-explicit-any
async function sendWelcomeMessage(supabase: any, agent: any): Promise<void> {
async function sendWelcomeMessage(
supabase: SupabaseAdminClient,
agent: AgentWelcomeRow,
): Promise<void> {
// 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,
});
const secret = secretData as { decrypted_secret?: string | null }[] | null;
const token: string = secret?.[0]?.decrypted_secret ?? "";
if (!token) {
console.warn("sendWelcomeMessage: token vazio, abortando");