mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 10:56:44 +00:00
Desenvolvido auto-provisionamento
X-Lovable-Edit-ID: edt-d65058c7-c314-48aa-a609-48a021b9020f Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
1e72250c73
4 changed files with 246 additions and 26 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowRight, CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
import { ArrowRight, CheckCircle2, Loader2, MessageCircle, Sparkles } from "lucide-react";
|
||||||
import { useSubscription } from "@/hooks/use-profile";
|
import { useSubscription } from "@/hooks/use-profile";
|
||||||
import { useProfile } from "@/hooks/use-profile";
|
import { useProfile } from "@/hooks/use-profile";
|
||||||
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
import { useAgentInstance } from "@/hooks/use-agent-instance";
|
||||||
|
|
@ -33,6 +33,7 @@ function DashboardPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = Route.useNavigate();
|
const navigate = Route.useNavigate();
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const previousStatusRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Auto-open do wizard ao voltar com ?status=success
|
// Auto-open do wizard ao voltar com ?status=success
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -51,6 +52,18 @@ function DashboardPage() {
|
||||||
}
|
}
|
||||||
}, [search.status, agent, isLoading]);
|
}, [search.status, agent, isLoading]);
|
||||||
|
|
||||||
|
// Toast quando agente sair de provisioning → active (uma única vez)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agent) return;
|
||||||
|
const prev = previousStatusRef.current;
|
||||||
|
if (prev === "provisioning" && agent.status === "active") {
|
||||||
|
toast.success("Sua Mika está pronta! 🎉", {
|
||||||
|
description: "Abra o Telegram e mande uma mensagem para começar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
previousStatusRef.current = agent.status;
|
||||||
|
}, [agent]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -79,10 +92,16 @@ function DashboardPage() {
|
||||||
|
|
||||||
{subscription &&
|
{subscription &&
|
||||||
(subscription.status === "incomplete" || subscription.status === "active") &&
|
(subscription.status === "incomplete" || subscription.status === "active") &&
|
||||||
agent?.status === "provisioning" && <ProvisioningCard />}
|
agent?.status === "provisioning" && (
|
||||||
|
<ProvisioningCard
|
||||||
|
telegramConnected={!!agent.telegram_bot_username}
|
||||||
|
railwayServiceCreated={false /* não temos campo direto; mostramos como "em andamento" */}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{subscription && subscription.status === "active" && agent?.status === "active" && (
|
{subscription && subscription.status === "active" && agent?.status === "active" && (
|
||||||
<>
|
<>
|
||||||
|
{agent.telegram_bot_username && <ActiveSuccessCard botUsername={agent.telegram_bot_username} />}
|
||||||
<AutoPausedBanner />
|
<AutoPausedBanner />
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<SkillsDashboardWidget />
|
<SkillsDashboardWidget />
|
||||||
|
|
@ -121,13 +140,34 @@ function NoSubscriptionCard() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProvisioningCard() {
|
type StepState = "done" | "active" | "pending";
|
||||||
const steps = [
|
|
||||||
{ label: "Provisionar container na VPS", state: "active" as const },
|
function ProvisioningCard({
|
||||||
{ label: "Configurar modelo de IA", state: "pending" as const },
|
telegramConnected,
|
||||||
{ label: "Conectar seu Telegram", state: "pending" as const },
|
railwayServiceCreated: _railwayServiceCreated,
|
||||||
{ label: "Personalizar seu agente", state: "pending" as const },
|
}: {
|
||||||
|
telegramConnected: boolean;
|
||||||
|
railwayServiceCreated?: boolean;
|
||||||
|
}) {
|
||||||
|
// Etapas dinâmicas — sabemos: pagamento confirmado (sempre done aqui),
|
||||||
|
// Telegram conectado (vem do agent.telegram_bot_username), e o resto
|
||||||
|
// está em andamento até o railway-webhook chegar como SUCCESS.
|
||||||
|
const steps: { label: string; state: StepState }[] = [
|
||||||
|
{ label: "Pagamento confirmado", state: "done" },
|
||||||
|
{
|
||||||
|
label: telegramConnected ? "Telegram conectado" : "Conecte seu Telegram",
|
||||||
|
state: telegramConnected ? "done" : "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Provisionando container",
|
||||||
|
state: telegramConnected ? "active" : "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mika quase pronta!",
|
||||||
|
state: "pending",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card p-6 sm:p-8 shadow-soft">
|
<div className="rounded-xl border border-border bg-card p-6 sm:p-8 shadow-soft">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
|
|
@ -135,9 +175,9 @@ function ProvisioningCard() {
|
||||||
<Loader2 className="h-5 w-5 text-primary animate-spin" />
|
<Loader2 className="h-5 w-5 text-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-bold">Seu agente Mika está sendo provisionado</h2>
|
<h2 className="text-xl font-bold">Estamos preparando seu agente Mika</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Você receberá um e-mail quando estiver pronto — geralmente em até 10 minutos.
|
Geralmente leva de 3 a 5 minutos. Esta página atualiza sozinha quando ficar pronta.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,23 +189,25 @@ function ProvisioningCard() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-10 rounded-full flex items-center justify-center font-bold text-sm shrink-0",
|
"h-10 w-10 rounded-full flex items-center justify-center font-bold text-sm shrink-0",
|
||||||
step.state === "active"
|
step.state === "done"
|
||||||
? "bg-primary text-primary-foreground shadow-glow"
|
? "bg-success/15 text-success"
|
||||||
: "bg-muted text-muted-foreground",
|
: step.state === "active"
|
||||||
|
? "bg-primary text-primary-foreground shadow-glow"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{step.state === "active" ? (
|
{step.state === "done" ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : step.state === "pending" ? (
|
|
||||||
i + 1
|
|
||||||
) : (
|
|
||||||
<CheckCircle2 className="h-5 w-5" />
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
|
) : step.state === "active" ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
step.state === "active" ? "text-foreground" : "text-muted-foreground",
|
step.state === "pending" ? "text-muted-foreground" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
|
|
@ -177,3 +219,37 @@ function ProvisioningCard() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActiveSuccessCard({ botUsername }: { botUsername: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-success/30 bg-success/5 p-6 shadow-soft">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-success/15 flex items-center justify-center shrink-0">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">Sua Mika está no ar 🎉</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Abra o Telegram e converse com{" "}
|
||||||
|
<span className="font-mono text-foreground">@{botUsername}</span> para começar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${botUsername}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<MessageCircle className="mr-2 h-4 w-4" />
|
||||||
|
Falar com a Mika agora
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,34 @@ Deno.serve(async (req) => {
|
||||||
return jsonResponse({ error: "Webhook configurado, mas falha ao salvar." }, 500);
|
return jsonResponse({ error: "Webhook configurado, mas falha ao salvar." }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-provisionamento: agora que temos token + webhook, disparamos provision-agent
|
||||||
|
// de forma assíncrona. Não bloqueamos a resposta — falhas são tratadas via retry/admin.
|
||||||
|
if (agent.status === "provisioning") {
|
||||||
|
try {
|
||||||
|
const provisionUrl = `${supabaseUrl}/functions/v1/provision-agent`;
|
||||||
|
console.log(
|
||||||
|
`auto-provision: disparando para agent ${agent.id} (user ${userId})`,
|
||||||
|
);
|
||||||
|
// Fire-and-forget: não fazemos await para não bloquear o cliente
|
||||||
|
fetch(provisionUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_instance_id: agent.id,
|
||||||
|
user_id: userId,
|
||||||
|
}),
|
||||||
|
}).catch((err) =>
|
||||||
|
console.error("auto-provision fetch error:", err)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("auto-provision trigger error:", err);
|
||||||
|
// Não re-throw — wizard do Telegram deve retornar sucesso mesmo se provision falhar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({ success: true });
|
return jsonResponse({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("configure-telegram-webhook fatal", err);
|
console.error("configure-telegram-webhook fatal", err);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,28 @@ 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 OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY") ?? "";
|
const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY") ?? "";
|
||||||
|
const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN");
|
||||||
|
const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("notifyAdmin failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
|
|
@ -53,6 +75,8 @@ Deno.serve(async (req) => {
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[provision-agent] início para agent_instance_id=${body.agent_instance_id}`);
|
||||||
|
|
||||||
// 1) Carregar agent_instance
|
// 1) Carregar agent_instance
|
||||||
const { data: agent, error: agentErr } = await supabase
|
const { data: agent, error: agentErr } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
|
|
@ -63,14 +87,17 @@ Deno.serve(async (req) => {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (agentErr || !agent) {
|
if (agentErr || !agent) {
|
||||||
|
console.error(`[provision-agent] agent_instance não encontrado: ${agentErr?.message}`);
|
||||||
return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message });
|
return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.status !== "provisioning") {
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.railway_service_id) {
|
if (agent.railway_service_id) {
|
||||||
|
console.log(`[provision-agent] já tem railway_service_id=${agent.railway_service_id}, abortando`);
|
||||||
return jsonResponse(409, { error: "agent_instance already has a railway_service_id", railway_service_id: agent.railway_service_id });
|
return jsonResponse(409, { error: "agent_instance already has a railway_service_id", railway_service_id: agent.railway_service_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +111,7 @@ Deno.serve(async (req) => {
|
||||||
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 = body.agent_name?.trim() || `Mika de ${firstName}`;
|
const agentName = body.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)
|
// 1c) Carregar subscription ativa (para definir modelo Pro vs Basic)
|
||||||
const { data: subscription } = await supabase
|
const { data: subscription } = await supabase
|
||||||
|
|
@ -98,6 +126,7 @@ Deno.serve(async (req) => {
|
||||||
// deno-lint-ignore no-explicit-any
|
// deno-lint-ignore no-explicit-any
|
||||||
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
|
const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic";
|
||||||
const isPro = ["professional", "enterprise"].includes(planSlug);
|
const isPro = ["professional", "enterprise"].includes(planSlug);
|
||||||
|
console.log(`[provision-agent] plano=${planSlug} isPro=${isPro}`);
|
||||||
|
|
||||||
// 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade)
|
// 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade)
|
||||||
const { data: pool, error: poolErr } = await supabase
|
const { data: pool, error: poolErr } = await supabase
|
||||||
|
|
@ -111,9 +140,17 @@ Deno.serve(async (req) => {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (poolErr || !pool || !pool.railway_project_id || !pool.railway_environment_id) {
|
if (poolErr || !pool || !pool.railway_project_id || !pool.railway_environment_id) {
|
||||||
|
console.error(`[provision-agent] sem vps_pool disponível: ${poolErr?.message}`);
|
||||||
await failJob(supabase, agent, null, "Nenhum vps_pool com Railway IDs configurados disponível");
|
await failJob(supabase, agent, null, "Nenhum vps_pool com Railway IDs configurados disponível");
|
||||||
|
await notifyAdmin(
|
||||||
|
`❌ <b>Falha no auto-provisionamento</b>\n\n` +
|
||||||
|
`👤 <b>Cliente:</b> ${fullName}\n` +
|
||||||
|
`❗ <b>Erro:</b> Nenhum vps_pool disponível\n\n` +
|
||||||
|
`➡️ <a href="https://mika.domco.ai/admin">Resolver manualmente</a>`,
|
||||||
|
);
|
||||||
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})`);
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -136,12 +173,15 @@ Deno.serve(async (req) => {
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (jobErr || !job) {
|
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)
|
// 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}`);
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
@ -149,15 +189,18 @@ Deno.serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!telegramBotToken) {
|
if (!telegramBotToken) {
|
||||||
|
console.error(`[provision-agent] telegram_bot_token ausente — usuário ainda não conectou bot`);
|
||||||
await failJob(supabase, agent, job.id, "telegram_bot_token ausente no Vault — usuário precisa concluir onboarding antes");
|
await failJob(supabase, agent, job.id, "telegram_bot_token ausente no Vault — usuário precisa concluir onboarding antes");
|
||||||
return jsonResponse(412, { error: "telegram token missing" });
|
return jsonResponse(412, { error: "telegram token missing" });
|
||||||
}
|
}
|
||||||
|
console.log(`[provision-agent] token Telegram OK (len=${telegramBotToken.length})`);
|
||||||
|
|
||||||
// 5) Apagar webhook Telegram (Hermes vai usar polling)
|
// 5) Apagar webhook Telegram (Hermes vai usar polling)
|
||||||
try {
|
try {
|
||||||
await deleteTelegramWebhook(telegramBotToken);
|
await deleteTelegramWebhook(telegramBotToken);
|
||||||
|
console.log(`[provision-agent] deleteTelegramWebhook OK`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("deleteTelegramWebhook failed (continuing):", String(e));
|
console.warn("[provision-agent] deleteTelegramWebhook failed (continuing):", String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Montar variáveis de ambiente do container
|
// 6) Montar variáveis de ambiente do container
|
||||||
|
|
@ -190,6 +233,7 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
// 7) Criar serviço no Railway
|
// 7) Criar serviço no Railway
|
||||||
const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`;
|
const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`;
|
||||||
|
console.log(`[provision-agent] criando serviço Railway: ${serviceName}`);
|
||||||
let railwayServiceId: string;
|
let railwayServiceId: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -198,6 +242,7 @@ Deno.serve(async (req) => {
|
||||||
projectId: pool.railway_project_id,
|
projectId: pool.railway_project_id,
|
||||||
name: serviceName,
|
name: serviceName,
|
||||||
});
|
});
|
||||||
|
console.log(`[provision-agent] serviço criado: ${railwayServiceId}`);
|
||||||
|
|
||||||
await configureRailwayService({
|
await configureRailwayService({
|
||||||
token: RAILWAY_API_TOKEN,
|
token: RAILWAY_API_TOKEN,
|
||||||
|
|
@ -207,16 +252,26 @@ Deno.serve(async (req) => {
|
||||||
startCommand: HERMES_START_COMMAND,
|
startCommand: HERMES_START_COMMAND,
|
||||||
variables: envVars,
|
variables: envVars,
|
||||||
});
|
});
|
||||||
|
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,
|
||||||
serviceId: railwayServiceId,
|
serviceId: railwayServiceId,
|
||||||
environmentId: pool.railway_environment_id,
|
environmentId: pool.railway_environment_id,
|
||||||
});
|
});
|
||||||
|
console.log(`[provision-agent] deploy disparado em Railway`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Railway provisioning failed:", msg);
|
console.error("[provision-agent] Railway provisioning failed:", msg);
|
||||||
await scheduleRetry(supabase, agent, job.id, msg);
|
const reachedMax = await scheduleRetry(supabase, agent, job.id, msg);
|
||||||
|
if (reachedMax) {
|
||||||
|
await notifyAdmin(
|
||||||
|
`❌ <b>Falha no auto-provisionamento</b>\n\n` +
|
||||||
|
`👤 <b>Cliente:</b> ${fullName}\n` +
|
||||||
|
`❗ <b>Erro:</b> ${msg}\n\n` +
|
||||||
|
`➡️ <a href="https://mika.domco.ai/admin">Provisionar manualmente</a>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return jsonResponse(500, { error: "railway provisioning failed", detail: msg });
|
return jsonResponse(500, { error: "railway provisioning failed", detail: msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,6 +295,8 @@ 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)`);
|
||||||
|
|
||||||
// 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, {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -278,7 +335,7 @@ async function scheduleRetry(
|
||||||
agent: { id: string },
|
agent: { id: string },
|
||||||
jobId: string,
|
jobId: string,
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
): Promise<boolean> {
|
||||||
const { data: job } = await supabase
|
const { data: job } = await supabase
|
||||||
.from("provisioning_jobs")
|
.from("provisioning_jobs")
|
||||||
.select("attempt, max_attempts")
|
.select("attempt, max_attempts")
|
||||||
|
|
@ -290,7 +347,7 @@ async function scheduleRetry(
|
||||||
|
|
||||||
if (attempt >= max) {
|
if (attempt >= max) {
|
||||||
await failJob(supabase, agent, jobId, `Max attempts reached. Last error: ${message}`);
|
await failJob(supabase, agent, jobId, `Max attempts reached. Last error: ${message}`);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDelayMs = Math.pow(attempt, 2) * 60_000;
|
const nextDelayMs = Math.pow(attempt, 2) * 60_000;
|
||||||
|
|
@ -305,4 +362,5 @@ async function scheduleRetry(
|
||||||
next_retry_at: nextRetryAt,
|
next_retry_at: nextRetryAt,
|
||||||
})
|
})
|
||||||
.eq("id", jobId);
|
.eq("id", jobId);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,28 @@ import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
|
||||||
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 ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN");
|
||||||
|
const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID");
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("notifyAdmin failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
|
|
@ -59,7 +81,7 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const { data: agent } = await supabase
|
const { data: agent } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.select("id, status")
|
.select("id, status, user_id, telegram_bot_username, railway_service_id")
|
||||||
.eq("railway_service_id", serviceId)
|
.eq("railway_service_id", serviceId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
|
|
@ -70,6 +92,17 @@ Deno.serve(async (req) => {
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const upper = status.toUpperCase();
|
const upper = status.toUpperCase();
|
||||||
|
const wasProvisioning = agent.status === "provisioning";
|
||||||
|
|
||||||
|
// Carrega nome do cliente para a notificação (best-effort)
|
||||||
|
async function loadFullName(): Promise<string> {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("full_name")
|
||||||
|
.eq("id", agent.user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
return (data?.full_name as string | undefined) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
if (upper === "SUCCESS" || upper === "ACTIVE" || upper === "DEPLOYED") {
|
if (upper === "SUCCESS" || upper === "ACTIVE" || upper === "DEPLOYED") {
|
||||||
await supabase
|
await supabase
|
||||||
|
|
@ -88,6 +121,19 @@ Deno.serve(async (req) => {
|
||||||
.in("status", ["running", "retrying", "pending"]);
|
.in("status", ["running", "retrying", "pending"]);
|
||||||
|
|
||||||
console.log(`railway-webhook: agent ${agent.id} marcado como active (status=${upper})`);
|
console.log(`railway-webhook: agent ${agent.id} marcado como active (status=${upper})`);
|
||||||
|
|
||||||
|
// Notifica admin somente se era um auto-provisionamento (status anterior=provisioning)
|
||||||
|
if (wasProvisioning) {
|
||||||
|
const fullName = await loadFullName();
|
||||||
|
await notifyAdmin(
|
||||||
|
`✅ <b>Agente provisionado automaticamente!</b>\n\n` +
|
||||||
|
`👤 <b>Cliente:</b> ${fullName}\n` +
|
||||||
|
`🤖 <b>Bot:</b> @${agent.telegram_bot_username || "—"}\n` +
|
||||||
|
`🚀 <b>Railway:</b> <code>${agent.railway_service_id}</code>\n\n` +
|
||||||
|
`O cliente já pode conversar com a Mika no Telegram.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" });
|
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +154,18 @@ Deno.serve(async (req) => {
|
||||||
.in("status", ["running", "retrying", "pending"]);
|
.in("status", ["running", "retrying", "pending"]);
|
||||||
|
|
||||||
console.log(`railway-webhook: agent ${agent.id} marcado como error (status=${upper})`);
|
console.log(`railway-webhook: agent ${agent.id} marcado como error (status=${upper})`);
|
||||||
|
|
||||||
|
if (wasProvisioning) {
|
||||||
|
const fullName = await loadFullName();
|
||||||
|
await notifyAdmin(
|
||||||
|
`❌ <b>Falha no deploy do agente</b>\n\n` +
|
||||||
|
`👤 <b>Cliente:</b> ${fullName}\n` +
|
||||||
|
`🚀 <b>Railway:</b> <code>${agent.railway_service_id}</code>\n` +
|
||||||
|
`❗ <b>Status:</b> ${upper}\n\n` +
|
||||||
|
`➡️ <a href="https://mika.domco.ai/admin">Investigar</a>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "error" });
|
return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue