diff --git a/.github/actions/setup-test-env/action.yml b/.github/actions/setup-test-env/action.yml index acd15856a..60c11167d 100644 --- a/.github/actions/setup-test-env/action.yml +++ b/.github/actions/setup-test-env/action.yml @@ -5,7 +5,7 @@ inputs: bun-version: description: Bun version to install (pinned to avoid GitHub API rate-limit flakes). required: false - default: "1.3.5" + default: "1.3.9" setup-db: description: Start postgres/redis and run migrations. required: false diff --git a/.gitignore b/.gitignore index 013df4449..8625dbcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ modelcontextprotocol/ *storybook.log storybook-static +.next-dev/ diff --git a/app/api/cron/milady-billing/route.ts b/app/api/cron/milady-billing/route.ts new file mode 100644 index 000000000..645ca6dd3 --- /dev/null +++ b/app/api/cron/milady-billing/route.ts @@ -0,0 +1,658 @@ +/** + * Milady Agent Billing Cron Job + * + * Hourly billing processor for Milady cloud agents (Docker-hosted). + * - Charges organizations hourly for running agents ($0.02/hour) + * - Charges for idle/stopped agents with snapshots ($0.0025/hour) + * - Sends 48-hour shutdown warnings when credits are insufficient + * - Shuts down agents that have been in warning state for 48+ hours + * + * Schedule: Runs every hour at minute 0 (0 * * * *) + * Protected by CRON_SECRET. + */ + +import { createHmac, timingSafeEqual } from "crypto"; +import { and, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { dbRead, dbWrite } from "@/db/client"; +import { usersRepository } from "@/db/repositories"; +import { creditTransactions } from "@/db/schemas/credit-transactions"; +import { type MiladyBillingStatus, miladySandboxes } from "@/db/schemas/milady-sandboxes"; +import { organizationBilling } from "@/db/schemas/organization-billing"; +import { organizations } from "@/db/schemas/organizations"; +import { trackServerEvent } from "@/lib/analytics/posthog-server"; +import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; +import { emailService } from "@/lib/services/email"; +import { logger } from "@/lib/utils/logger"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; // 5 minutes timeout +const REBILL_GUARD_MINUTES = 55; + +class AlreadyBilledRecentlyError extends Error { + constructor() { + super("Sandbox was already billed within the guard window"); + this.name = "AlreadyBilledRecentlyError"; + } +} + +class InsufficientCreditsDuringBillingError extends Error { + constructor() { + super("Organization balance was insufficient when the debit was attempted"); + this.name = "InsufficientCreditsDuringBillingError"; + } +} + +// ── Types ───────────────────────────────────────────────────────────── + +interface BillingResult { + sandboxId: string; + agentName: string; + organizationId: string; + action: "billed" | "warning_sent" | "shutdown" | "skipped" | "error"; + amount?: number; + newBalance?: number; + error?: string; +} + +// ── Auth ────────────────────────────────────────────────────────────── + +function verifyCronSecret(request: NextRequest): boolean { + const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + logger.error("[Milady Billing] CRON_SECRET not configured"); + return false; + } + + const providedSecret = authHeader?.replace("Bearer ", "") || ""; + // Use HMAC comparison to avoid leaking secret length via timing side-channel + const hmacKey = Buffer.from("milady-billing-cron"); + const a = createHmac("sha256", hmacKey).update(providedSecret).digest(); + const b = createHmac("sha256", hmacKey).update(cronSecret).digest(); + return timingSafeEqual(a, b); +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +async function getOrgUserEmail(organizationId: string): Promise { + try { + const users = await usersRepository.listByOrganization(organizationId); + return users.length > 0 && users[0].email ? users[0].email : null; + } catch (error) { + logger.error("[Milady Billing] Failed to get org user email", { + organizationId, + error, + }); + return null; + } +} + +async function getOrgBalance(organizationId: string): Promise { + try { + const [org] = await dbRead + .select({ credit_balance: organizations.credit_balance }) + .from(organizations) + .where(eq(organizations.id, organizationId)); + + return org ? Number(org.credit_balance) : null; + } catch (error) { + logger.warn("[Milady Billing] Failed to refresh org balance", { + organizationId, + error, + }); + return null; + } +} + +/** + * Determine hourly rate for a sandbox based on its status. + * Running → RUNNING_HOURLY_RATE, Stopped with backups → IDLE_HOURLY_RATE. + */ +function getHourlyRate(status: string): number { + if (status === "running") return MILADY_PRICING.RUNNING_HOURLY_RATE; + // Stopped agents are only billed if they have snapshots (checked in query). + return MILADY_PRICING.IDLE_HOURLY_RATE; +} + +// ── Per-Agent Billing ───────────────────────────────────────────────── + +async function processSandboxBilling( + sandbox: { + id: string; + agent_name: string | null; + organization_id: string; + user_id: string; + status: string; + billing_status: string; + total_billed: string; + shutdown_warning_sent_at: Date | null; + scheduled_shutdown_at: Date | null; + }, + org: { + id: string; + name: string; + credit_balance: string; + billing_email: string | null; + }, +): Promise { + const sandboxId = sandbox.id; + const agentName = sandbox.agent_name ?? sandboxId.slice(0, 8); + const organizationId = sandbox.organization_id; + const hourlyCost = getHourlyRate(sandbox.status); + const currentBalance = Number(org.credit_balance); + const now = new Date(); + + async function queueShutdownWarning(): Promise { + if (sandbox.billing_status === "shutdown_pending" || sandbox.shutdown_warning_sent_at) { + return { + sandboxId, + agentName, + organizationId, + action: "skipped", + error: "Waiting for scheduled shutdown", + }; + } + + const liveBalance = (await getOrgBalance(organizationId)) ?? currentBalance; + if (liveBalance >= hourlyCost) { + logger.info( + `[Milady Billing] Skipping shutdown warning for ${agentName}; balance recovered before warning`, + { + sandboxId, + hourlyCost, + liveBalance, + }, + ); + return { + sandboxId, + agentName, + organizationId, + action: "skipped", + error: "Balance recovered before warning could be sent", + }; + } + + const shutdownTime = new Date( + now.getTime() + MILADY_PRICING.GRACE_PERIOD_HOURS * 60 * 60 * 1000, + ); + + await dbWrite + .update(miladySandboxes) + .set({ + billing_status: "shutdown_pending" as MiladyBillingStatus, + shutdown_warning_sent_at: now, + scheduled_shutdown_at: shutdownTime, + updated_at: now, + }) + .where(eq(miladySandboxes.id, sandboxId)); + + const recipientEmail = org.billing_email || (await getOrgUserEmail(organizationId)); + if (recipientEmail) { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://www.elizacloud.ai"; + // Reuse the container shutdown warning email template — content is generic enough + await emailService.sendContainerShutdownWarningEmail({ + email: recipientEmail, + organizationName: org.name, + containerName: `Milady Agent: ${agentName}`, + projectName: "Milady Cloud", + dailyCost: hourlyCost * 24, + monthlyCost: hourlyCost * 24 * 30, + currentBalance: liveBalance, + requiredCredits: hourlyCost, + minimumRecommended: hourlyCost * 24 * 7, // 1 week + shutdownTime: shutdownTime.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }), + billingUrl: `${appUrl}/dashboard/billing`, + dashboardUrl: `${appUrl}/dashboard/milady`, + }); + + logger.info(`[Milady Billing] Sent shutdown warning for ${agentName} to ${recipientEmail}`); + } + + trackServerEvent(sandbox.user_id, "milady_agent_shutdown_warning_sent", { + sandbox_id: sandboxId, + agent_name: agentName, + organization_id: organizationId, + hourly_cost: hourlyCost, + current_balance: liveBalance, + scheduled_shutdown: shutdownTime.toISOString(), + }); + + return { + sandboxId, + agentName, + organizationId, + action: "warning_sent", + amount: hourlyCost, + }; + } + + logger.info(`[Milady Billing] Processing ${agentName}`, { + sandboxId, + hourlyCost, + currentBalance, + status: sandbox.status, + billingStatus: sandbox.billing_status, + }); + + // ── Scheduled shutdown check ──────────────────────────────────── + if ( + sandbox.billing_status === "shutdown_pending" && + sandbox.scheduled_shutdown_at && + new Date(sandbox.scheduled_shutdown_at) <= now + ) { + logger.info(`[Milady Billing] Shutting down agent ${agentName} due to insufficient credits`); + + await dbWrite + .update(miladySandboxes) + .set({ + status: "stopped", + billing_status: "suspended" as MiladyBillingStatus, + sandbox_id: null, + bridge_url: null, + health_url: null, + updated_at: now, + }) + .where(eq(miladySandboxes.id, sandboxId)); + + trackServerEvent(sandbox.user_id, "milady_agent_shutdown_insufficient_credits", { + sandbox_id: sandboxId, + agent_name: agentName, + organization_id: organizationId, + balance_at_shutdown: currentBalance, + }); + + return { sandboxId, agentName, organizationId, action: "shutdown" }; + } + + // ── Sufficient credits — bill the hour ────────────────────────── + const billingDescription = + sandbox.status === "running" + ? `Milady agent hosting (running): ${agentName}` + : `Milady agent storage (idle): ${agentName}`; + let billingResult: { newBalance: number; transactionId: string }; + try { + billingResult = await dbWrite.transaction(async (tx) => { + const rebillCutoff = new Date(now.getTime() - REBILL_GUARD_MINUTES * 60_000); + // Claim the sandbox row up front so overlapping cron runs serialize on the same record. + const [claimedSandbox] = await tx + .update(miladySandboxes) + .set({ updated_at: now }) + .where( + and( + eq(miladySandboxes.id, sandboxId), + or( + isNull(miladySandboxes.last_billed_at), + lt(miladySandboxes.last_billed_at, rebillCutoff), + ), + ), + ) + .returning({ id: miladySandboxes.id }); + + if (!claimedSandbox) { + throw new AlreadyBilledRecentlyError(); + } + + // Atomic credit deduction — the balance floor lives in SQL, not the stale org snapshot. + const [updatedOrg] = await tx + .update(organizations) + .set({ + credit_balance: sql`${organizations.credit_balance} - ${String(hourlyCost)}`, + updated_at: now, + }) + .where( + and( + eq(organizations.id, organizationId), + gte(organizations.credit_balance, String(hourlyCost)), + ), + ) + .returning({ credit_balance: organizations.credit_balance }); + + if (!updatedOrg) { + throw new InsufficientCreditsDuringBillingError(); + } + + const newBalance = Number(updatedOrg.credit_balance); + + // Create credit transaction + const [creditTx] = await tx + .insert(creditTransactions) + .values({ + organization_id: organizationId, + user_id: sandbox.user_id, + amount: String(-hourlyCost), + type: "debit", + description: billingDescription, + metadata: { + sandbox_id: sandboxId, + agent_name: agentName, + billing_type: sandbox.status === "running" ? "milady_running" : "milady_idle", + hourly_rate: hourlyCost, + billing_hour: now.toISOString(), + }, + created_at: now, + }) + .returning(); + + const nextBillingStatus: MiladyBillingStatus = + newBalance < MILADY_PRICING.LOW_CREDIT_WARNING ? "warning" : "active"; + + // Update sandbox billing fields — use SQL increment for total_billed to avoid races + await tx + .update(miladySandboxes) + .set({ + last_billed_at: now, + billing_status: nextBillingStatus, + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, + hourly_rate: String(hourlyCost), + total_billed: sql`${miladySandboxes.total_billed} + ${String(hourlyCost)}`, + updated_at: now, + }) + .where(eq(miladySandboxes.id, sandboxId)); + + return { newBalance, transactionId: creditTx.id }; + }); + } catch (error) { + if (error instanceof AlreadyBilledRecentlyError) { + logger.info( + `[Milady Billing] Skipping ${agentName}; already billed within ${REBILL_GUARD_MINUTES} minutes`, + { + sandboxId, + }, + ); + return { + sandboxId, + agentName, + organizationId, + action: "skipped", + error: "Already billed recently", + }; + } + + if (error instanceof InsufficientCreditsDuringBillingError) { + return queueShutdownWarning(); + } + + throw error; + } + + logger.info(`[Milady Billing] Billed ${agentName}: $${hourlyCost.toFixed(4)}`, { + sandboxId, + newBalance: billingResult.newBalance, + transactionId: billingResult.transactionId, + }); + + trackServerEvent(sandbox.user_id, "milady_agent_hourly_billed", { + sandbox_id: sandboxId, + agent_name: agentName, + organization_id: organizationId, + amount: hourlyCost, + new_balance: billingResult.newBalance, + }); + + return { + sandboxId, + agentName, + organizationId, + action: "billed", + amount: hourlyCost, + newBalance: billingResult.newBalance, + }; +} + +// ── Main Handler ────────────────────────────────────────────────────── + +async function handleMiladyBilling(request: NextRequest): Promise { + const startTime = Date.now(); + const now = new Date(); + const rebillCutoff = new Date(now.getTime() - REBILL_GUARD_MINUTES * 60_000); + + if (!verifyCronSecret(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + logger.info("[Milady Billing] Starting hourly billing run"); + + try { + // ── 1. Running agents (always billed) ─────────────────────────── + const runningSandboxes = await dbRead + .select({ + id: miladySandboxes.id, + agent_name: miladySandboxes.agent_name, + organization_id: miladySandboxes.organization_id, + user_id: miladySandboxes.user_id, + status: miladySandboxes.status, + billing_status: miladySandboxes.billing_status, + last_billed_at: miladySandboxes.last_billed_at, + total_billed: miladySandboxes.total_billed, + shutdown_warning_sent_at: miladySandboxes.shutdown_warning_sent_at, + scheduled_shutdown_at: miladySandboxes.scheduled_shutdown_at, + }) + .from(miladySandboxes) + .where( + and( + eq(miladySandboxes.status, "running"), + inArray(miladySandboxes.billing_status, [ + "active", + "warning", + "shutdown_pending", + ] satisfies MiladyBillingStatus[]), + or( + and( + eq(miladySandboxes.billing_status, "shutdown_pending"), + isNotNull(miladySandboxes.scheduled_shutdown_at), + lte(miladySandboxes.scheduled_shutdown_at, now), + ), + isNull(miladySandboxes.last_billed_at), + lt(miladySandboxes.last_billed_at, rebillCutoff), + ), + ), + ); + + // ── 2. Stopped agents with at least one backup (idle storage) ─── + // Sub-select sandbox IDs that have backups + const stoppedWithBackups = await dbRead + .select({ + id: miladySandboxes.id, + agent_name: miladySandboxes.agent_name, + organization_id: miladySandboxes.organization_id, + user_id: miladySandboxes.user_id, + status: miladySandboxes.status, + billing_status: miladySandboxes.billing_status, + last_billed_at: miladySandboxes.last_billed_at, + total_billed: miladySandboxes.total_billed, + shutdown_warning_sent_at: miladySandboxes.shutdown_warning_sent_at, + scheduled_shutdown_at: miladySandboxes.scheduled_shutdown_at, + }) + .from(miladySandboxes) + .where( + and( + eq(miladySandboxes.status, "stopped"), + inArray(miladySandboxes.billing_status, [ + "active", + "warning", + "shutdown_pending", + ] satisfies MiladyBillingStatus[]), + // Only bill stopped agents that have snapshot data + isNotNull(miladySandboxes.last_backup_at), + or( + and( + eq(miladySandboxes.billing_status, "shutdown_pending"), + isNotNull(miladySandboxes.scheduled_shutdown_at), + lte(miladySandboxes.scheduled_shutdown_at, now), + ), + isNull(miladySandboxes.last_billed_at), + lt(miladySandboxes.last_billed_at, rebillCutoff), + ), + ), + ); + + const allBillable = [...runningSandboxes, ...stoppedWithBackups]; + + if (allBillable.length === 0) { + logger.info("[Milady Billing] No billable sandboxes"); + return NextResponse.json({ + success: true, + data: { + sandboxesProcessed: 0, + sandboxesBilled: 0, + warningsSent: 0, + sandboxesShutdown: 0, + totalRevenue: 0, + errors: 0, + duration: Date.now() - startTime, + }, + }); + } + + logger.info( + `[Milady Billing] Processing ${allBillable.length} sandboxes (${runningSandboxes.length} running, ${stoppedWithBackups.length} idle)`, + ); + + // ── Fetch organizations ───────────────────────────────────────── + const orgIds = [...new Set(allBillable.map((s) => s.organization_id))]; + + const orgs = await dbRead + .select({ + id: organizations.id, + name: organizations.name, + credit_balance: organizations.credit_balance, + }) + .from(organizations) + .where(inArray(organizations.id, orgIds)); + + const billingData = await dbRead + .select({ + organization_id: organizationBilling.organization_id, + billing_email: organizationBilling.billing_email, + }) + .from(organizationBilling) + .where(inArray(organizationBilling.organization_id, orgIds)); + + const billingEmailMap = new Map(billingData.map((b) => [b.organization_id, b.billing_email])); + const orgMap = new Map( + orgs.map((o) => [o.id, { ...o, billing_email: billingEmailMap.get(o.id) ?? null }]), + ); + + // ── Process each sandbox ──────────────────────────────────────── + const results: BillingResult[] = []; + let totalRevenue = 0; + let sandboxesBilled = 0; + let warningsSent = 0; + let sandboxesShutdown = 0; + let errors = 0; + + for (const sandbox of allBillable) { + const org = orgMap.get(sandbox.organization_id); + if (!org) { + results.push({ + sandboxId: sandbox.id, + agentName: sandbox.agent_name ?? "unknown", + organizationId: sandbox.organization_id, + action: "error", + error: "Organization not found", + }); + errors++; + continue; + } + + try { + const result = await processSandboxBilling(sandbox, org); + results.push(result); + + if (result.action === "billed" && result.amount) { + totalRevenue += result.amount; + sandboxesBilled++; + // Update org balance in memory for next sandbox in same org + org.credit_balance = String(result.newBalance); + } else if (result.action === "warning_sent") { + warningsSent++; + } else if (result.action === "shutdown") { + sandboxesShutdown++; + } else if (result.action === "error") { + errors++; + } + } catch (error) { + logger.error( + `[Milady Billing] Error processing sandbox ${sandbox.agent_name ?? sandbox.id}`, + { error }, + ); + results.push({ + sandboxId: sandbox.id, + agentName: sandbox.agent_name ?? "unknown", + organizationId: sandbox.organization_id, + action: "error", + error: error instanceof Error ? error.message : "Unknown error", + }); + errors++; + } + } + + const duration = Date.now() - startTime; + + logger.info("[Milady Billing] Completed hourly billing run", { + sandboxesProcessed: results.length, + sandboxesBilled, + warningsSent, + sandboxesShutdown, + totalRevenue: totalRevenue.toFixed(4), + errors, + duration, + }); + + return NextResponse.json({ + success: true, + data: { + sandboxesProcessed: results.length, + sandboxesBilled, + warningsSent, + sandboxesShutdown, + totalRevenue: Math.round(totalRevenue * 10000) / 10000, + errors, + duration, + timestamp: now.toISOString(), + resultsTruncated: results.length > 100, + results: results.slice(0, 100), + }, + }); + } catch (error) { + logger.error("[Milady Billing] Failed", { + error: error instanceof Error ? error.message : String(error), + }); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Milady billing failed", + }, + { status: 500 }, + ); + } +} + +/** + * GET /api/cron/milady-billing + * Hourly milady agent billing cron job. + */ +export async function GET(request: NextRequest) { + return handleMiladyBilling(request); +} + +/** + * POST /api/cron/milady-billing + * POST variant for manual triggering. + */ +export async function POST(request: NextRequest) { + return handleMiladyBilling(request); +} diff --git a/app/api/v1/admin/headscale/route.ts b/app/api/v1/admin/headscale/route.ts index 704c7cab5..0a2379e9e 100644 --- a/app/api/v1/admin/headscale/route.ts +++ b/app/api/v1/admin/headscale/route.ts @@ -8,6 +8,7 @@ */ import { NextRequest, NextResponse } from "next/server"; +import { AuthenticationError, ForbiddenError } from "@/lib/api/errors"; import { requireAdmin } from "@/lib/auth"; import { logger } from "@/lib/utils/logger"; @@ -26,36 +27,44 @@ const HEADSCALE_USER = process.env.HEADSCALE_USER || "milady"; // --------------------------------------------------------------------------- export async function GET(request: NextRequest) { - const { role } = await requireAdmin(request); - if (role !== "super_admin") { - return NextResponse.json( - { success: false, error: "Super admin access required" }, - { status: 403 }, - ); - } + try { + const { role } = await requireAdmin(request); + if (role !== "super_admin") { + return NextResponse.json( + { success: false, error: "Super admin access required" }, + { status: 403 }, + ); + } - if (!HEADSCALE_API_KEY) { - return NextResponse.json( - { - success: false, - error: "Headscale not configured: HEADSCALE_API_KEY environment variable is missing", - }, - { status: 503 }, - ); - } + if (!HEADSCALE_API_KEY) { + return NextResponse.json( + { + success: false, + error: "Headscale not configured: HEADSCALE_API_KEY environment variable is missing", + }, + { status: 503 }, + ); + } - const headers: Record = { - Authorization: `Bearer ${HEADSCALE_API_KEY}`, - Accept: "application/json", - }; + const headers: Record = { + Authorization: `Bearer ${HEADSCALE_API_KEY}`, + Accept: "application/json", + }; - try { - // Fetch VPN nodes (machines) from headscale - const nodesResponse = await fetch(`${HEADSCALE_API_URL}/api/v1/machine`, { + // Fetch VPN nodes from headscale — try /api/v1/node first (v0.23+), fall back to /api/v1/machine (legacy) + let nodesResponse = await fetch(`${HEADSCALE_API_URL}/api/v1/node`, { headers, signal: AbortSignal.timeout(10_000), }); + // Fall back to legacy /api/v1/machine endpoint for older headscale versions + if (!nodesResponse.ok && nodesResponse.status === 404) { + nodesResponse = await fetch(`${HEADSCALE_API_URL}/api/v1/machine`, { + headers, + signal: AbortSignal.timeout(10_000), + }); + } + if (!nodesResponse.ok) { const errText = await nodesResponse.text().catch(() => ""); logger.error("[Admin Headscale] API request failed", { @@ -72,6 +81,20 @@ export async function GET(request: NextRequest) { } const nodesData = (await nodesResponse.json()) as { + nodes?: Array<{ + id: string; + machineKey?: string; + nodeKey?: string; + name: string; + givenName: string; + user: { id: string; name: string }; + ipAddresses: string[]; + online: boolean; + lastSeen: string; + expiry: string; + createdAt: string; + forcedTags?: string[]; + }>; machines?: Array<{ id: string; machineKey: string; @@ -88,7 +111,8 @@ export async function GET(request: NextRequest) { }>; }; - const machines = nodesData.machines || []; + // Support both v0.23+ (nodes) and legacy (machines) response shapes + const machines = nodesData.nodes || nodesData.machines || []; // Optionally filter to the configured user const filteredMachines = HEADSCALE_USER @@ -129,6 +153,16 @@ export async function GET(request: NextRequest) { error: error instanceof Error ? error.message : String(error), }); + if (error instanceof AuthenticationError) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 }, + ); + } + if (error instanceof ForbiddenError) { + return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 }); + } + // Distinguish network errors from other failures if (error instanceof TypeError && error.message.includes("fetch")) { return NextResponse.json( diff --git a/app/api/v1/admin/infrastructure/route.ts b/app/api/v1/admin/infrastructure/route.ts new file mode 100644 index 000000000..834eaa723 --- /dev/null +++ b/app/api/v1/admin/infrastructure/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { AuthenticationError, ForbiddenError } from "@/lib/api/errors"; +import { requireAdmin } from "@/lib/auth"; +import { getAdminInfrastructureSnapshot } from "@/lib/services/admin-infrastructure"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const { role } = await requireAdmin(request); + if (role !== "super_admin") { + return NextResponse.json( + { success: false, error: "Super admin access required" }, + { status: 403 }, + ); + } + + const snapshot = await getAdminInfrastructureSnapshot(); + + return NextResponse.json({ + success: true, + data: snapshot, + }); + } catch (error) { + if (error instanceof AuthenticationError) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 }, + ); + } + if (error instanceof ForbiddenError) { + return NextResponse.json({ success: false, error: "Forbidden" }, { status: 403 }); + } + + logger.error("[Admin Infrastructure] Failed to build infrastructure snapshot", { + error: error instanceof Error ? error.message : String(error), + }); + + return NextResponse.json( + { success: false, error: "Failed to load infrastructure snapshot" }, + { status: 500 }, + ); + } +} diff --git a/app/api/v1/milady/agents/[agentId]/provision/route.ts b/app/api/v1/milady/agents/[agentId]/provision/route.ts index f9aff3fb6..6c39600ae 100644 --- a/app/api/v1/milady/agents/[agentId]/provision/route.ts +++ b/app/api/v1/milady/agents/[agentId]/provision/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; import { assertSafeOutboundUrl } from "@/lib/security/outbound-url"; +import { checkMiladyCreditGate } from "@/lib/services/milady-billing-gate"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { provisioningJobService } from "@/lib/services/provisioning-jobs"; import { logger } from "@/lib/utils/logger"; @@ -82,6 +84,26 @@ export async function POST( }); } + // ── Credit gate: require minimum deposit before provisioning ────── + const creditCheck = await checkMiladyCreditGate(user.organization_id); + if (!creditCheck.allowed) { + logger.warn("[milady-api] Provision blocked: insufficient credits", { + agentId, + orgId: user.organization_id, + balance: creditCheck.balance, + required: MILADY_PRICING.MINIMUM_DEPOSIT, + }); + return NextResponse.json( + { + success: false, + error: creditCheck.error, + requiredBalance: MILADY_PRICING.MINIMUM_DEPOSIT, + currentBalance: creditCheck.balance, + }, + { status: 402 }, + ); + } + // ── Sync fallback (legacy) ──────────────────────────────────────── if (sync) { const result = await miladySandboxService.provision(agentId, user.organization_id!); diff --git a/app/api/v1/milady/agents/[agentId]/resume/route.ts b/app/api/v1/milady/agents/[agentId]/resume/route.ts index c7aa0cf70..399c8c765 100644 --- a/app/api/v1/milady/agents/[agentId]/resume/route.ts +++ b/app/api/v1/milady/agents/[agentId]/resume/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; import { assertSafeOutboundUrl } from "@/lib/security/outbound-url"; +import { checkMiladyCreditGate } from "@/lib/services/milady-billing-gate"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { provisioningJobService } from "@/lib/services/provisioning-jobs"; import { logger } from "@/lib/utils/logger"; @@ -53,6 +55,26 @@ export async function POST( }); } + // ── Credit gate: require minimum deposit before resuming ────────── + const creditCheck = await checkMiladyCreditGate(user.organization_id); + if (!creditCheck.allowed) { + logger.warn("[milady-api] Resume blocked: insufficient credits", { + agentId, + orgId: user.organization_id, + balance: creditCheck.balance, + required: MILADY_PRICING.MINIMUM_DEPOSIT, + }); + return NextResponse.json( + { + success: false, + error: creditCheck.error, + requiredBalance: MILADY_PRICING.MINIMUM_DEPOSIT, + currentBalance: creditCheck.balance, + }, + { status: 402 }, + ); + } + if (sync) { const result = await miladySandboxService.provision(agentId, user.organization_id); diff --git a/app/api/v1/milady/agents/route.ts b/app/api/v1/milady/agents/route.ts index 9fbe6b5e5..c19e7ea5d 100644 --- a/app/api/v1/milady/agents/route.ts +++ b/app/api/v1/milady/agents/route.ts @@ -2,10 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { userCharactersRepository } from "@/db/repositories/characters"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; import { stripReservedMiladyConfigKeys, withReusedMiladyCharacterOwnership, } from "@/lib/services/milady-agent-config"; +import { checkMiladyCreditGate } from "@/lib/services/milady-billing-gate"; import { prepareManagedMiladyEnvironment } from "@/lib/services/milady-managed-launch"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { logger } from "@/lib/utils/logger"; @@ -83,6 +85,25 @@ export async function POST(request: NextRequest) { ); } + // ── Credit gate: require minimum deposit before creating an agent ── + const creditCheck = await checkMiladyCreditGate(user.organization_id); + if (!creditCheck.allowed) { + logger.warn("[milady-api] Agent creation blocked: insufficient credits", { + orgId: user.organization_id, + balance: creditCheck.balance, + required: MILADY_PRICING.MINIMUM_DEPOSIT, + }); + return NextResponse.json( + { + success: false, + error: creditCheck.error, + requiredBalance: MILADY_PRICING.MINIMUM_DEPOSIT, + currentBalance: creditCheck.balance, + }, + { status: 402 }, + ); + } + if (parsed.data.characterId) { const character = await userCharactersRepository.findByIdInOrganizationForWrite( parsed.data.characterId, diff --git a/app/dashboard/containers/agents/[id]/page.tsx b/app/dashboard/containers/agents/[id]/page.tsx index 13423089a..725afabe4 100644 --- a/app/dashboard/containers/agents/[id]/page.tsx +++ b/app/dashboard/containers/agents/[id]/page.tsx @@ -7,24 +7,13 @@ * - Admin-only infrastructure details, SSH access, and Docker logs */ -import { Badge, BrandCard } from "@elizaos/cloud-ui"; -import { - Activity, - AlertCircle, - ArrowLeft, - Clock, - Cloud, - Cpu, - Database, - ExternalLink, - Network, - Server, - Terminal, -} from "lucide-react"; +import { Badge } from "@elizaos/cloud-ui"; +import { AlertCircle, ArrowLeft, Cloud, ExternalLink, Server, Terminal } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { redirect } from "next/navigation"; import { requireAuthWithOrg } from "@/lib/auth"; +import { statusBadgeColor, statusDotColor } from "@/lib/constants/sandbox-status"; import { getPreferredMiladyAgentWebUiUrl } from "@/lib/milady-web-ui"; import { adminService } from "@/lib/services/admin"; import { miladySandboxService } from "@/lib/services/milaidy-sandbox"; @@ -48,17 +37,27 @@ export async function generateMetadata({ params }: PageProps): Promise }; } -const STATUS_COLORS: Record = { - running: "bg-green-500", - provisioning: "bg-blue-500", - pending: "bg-yellow-500", - stopped: "bg-gray-500", - disconnected: "bg-orange-500", - error: "bg-red-500", -}; +function formatDate(date: Date | string | null): string { + if (!date) return "—"; + const d = new Date(date); + return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +function formatTime(date: Date | string | null): string { + if (!date) return ""; + return new Date(date).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); +} -function getStatusColor(status: string) { - return STATUS_COLORS[status] ?? "bg-gray-500"; +function formatRelativeShort(date: Date | string | null): string { + if (!date) return "Never"; + const d = new Date(date); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "Just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH}h ago`; + return formatDate(date); } export default async function MiladyAgentDetailPage({ params }: PageProps) { @@ -66,7 +65,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { const { id } = await params; // Milady sandboxes table may not exist in all environments — redirect gracefully - let agent; + let agent: Awaited>; try { agent = await miladySandboxService.getAgent(id, user.organization_id); } catch { @@ -81,335 +80,185 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { const isDockerBacked = !!agent.node_id; const webUiUrl = getPreferredMiladyAgentWebUiUrl(agent); - const sshCommand = agent.headscale_ip ? `ssh root@${agent.headscale_ip}` : null; + const badgeColor = statusBadgeColor(agent.status); + const dotColor = statusDotColor(agent.status); + return ( -
+
{/* ── Back nav ── */} -
+
-
- +
+
- Back to Containers + Containers {webUiUrl && agent.status === "running" && }
- {/* ── Header card ── */} - -
-
-
- {isDockerBacked ? ( - - ) : ( - - )} -
-
-
- -

- {agent.agent_name ?? "Unnamed Agent"} -

- {isDockerBacked ? ( - - - Docker - - ) : ( - - - Sandbox - - )} -
-

+

+
+ {isDockerBacked ? ( + + ) : ( + + )} +
+
+
+

- {agent.id} -

-

-
-
- - - {/* ── Stats grid ── */} -
- {/* Status */} - -
-
-
- -
- + {agent.agent_name ?? "Unnamed Agent"} + + + {agent.status}
-

- Status -

-

- {agent.status} -

-
-
- - {/* Database */} - -
-
-
- -
- - {agent.database_status} - +
+ {agent.id} + + {isDockerBacked ? : } + {isDockerBacked ? "Docker" : "Sandbox"} +
-

- Database -

-

- {agent.database_status === "ready" - ? "Connected" - : agent.database_status === "provisioning" - ? "Setting up" - : agent.database_status === "none" - ? "Not configured" - : "Error"} -

-
- - - {/* Created */} - -
-
-
- -
-
-

- Created -

-

- {new Date(agent.created_at).toLocaleDateString()} -

-

- {new Date(agent.created_at).toLocaleTimeString()} -

-
+
+
- {/* Last Heartbeat */} - -
-
-
- -
-
-

- Last Heartbeat -

-

- {agent.last_heartbeat_at - ? new Date(agent.last_heartbeat_at).toLocaleTimeString() - : "Never"} + {/* ── Key info strip ── */} +

+
+

Status

+

+ {agent.status} +

+
+
+

Database

+

+ {agent.database_status === "ready" + ? "Connected" + : agent.database_status === "provisioning" + ? "Setting up" + : agent.database_status === "none" + ? "None" + : "Error"} +

+
+
+

Created

+

+ {formatDate(agent.created_at)} +

+

{formatTime(agent.created_at)}

+
+
+

Last Heartbeat

+

+ {formatRelativeShort(agent.last_heartbeat_at)} +

+ {agent.last_heartbeat_at && ( +

+ {formatDate(agent.last_heartbeat_at)}

- {agent.last_heartbeat_at && ( -

- {new Date(agent.last_heartbeat_at).toLocaleDateString()} -

- )} -
- + )} +
{/* ── Error message ── */} {agent.error_message && ( -
-
-
- -
-
-

- Error ({agent.error_count} occurrence - {agent.error_count !== 1 ? "s" : ""}) -

-

{agent.error_message}

-
+
+ +
+

+ Error ({agent.error_count} occurrence{agent.error_count !== 1 ? "s" : ""}) +

+

{agent.error_message}

)} - {/* ── Docker infrastructure ── */} + {/* ── Docker infrastructure (admin) ── */} {isAdmin && isDockerBacked && ( - -
-
- -

- Docker Infrastructure -

-
- -
- {/* Node */} - } - label="Node" - value={agent.node_id ?? "—"} - mono - /> - - {/* Container Name */} - } - label="Container Name" - value={agent.container_name ?? "—"} - mono - /> - - {/* Docker Image */} - } - label="Docker Image" - value={agent.docker_image ?? "—"} - mono - /> - - {/* VPN IP */} - {agent.headscale_ip && ( - } - label="VPN IP (Headscale)" - value={agent.headscale_ip} - mono - highlight="green" - /> - )} - - {/* Bridge Port */} - {agent.bridge_port && ( - } - label="Bridge Port" - value={String(agent.bridge_port)} - mono - /> - )} - - {/* Web UI Port */} - {agent.web_ui_port && ( - } - label="Web UI Port" - value={String(agent.web_ui_port)} - mono - /> - )} -
+
+
+ +

+ Infrastructure +

+
- {/* Connect URL */} - {webUiUrl && ( -
-

- Web UI URL -

- - {webUiUrl} - -
+
+ + + + {agent.headscale_ip && ( + + )} + {agent.bridge_port && ( + + )} + {agent.web_ui_port && ( + )}
- + + {webUiUrl && ( +
+ + Web UI + + {webUiUrl} +
+ )} +
)} - {/* ── SSH connection info ── */} + {/* ── SSH access (admin) ── */} {isAdmin && sshCommand && ( - -
-
- -

- SSH Access -

-
-

- Connect to this container via the Headscale VPN: +

+
+ +

+ SSH Access

-
- +
+ +
+
+ {sshCommand}
{agent.bridge_port && ( -
+
)}
- +
)} - {/* ── Vercel sandbox info ── */} + {/* ── Vercel sandbox info (admin) ── */} {isAdmin && !isDockerBacked && agent.bridge_url && ( - -
-
- -

- Sandbox Connection -

-
-
-

- Bridge URL -

- - {agent.bridge_url} - - -
+
+
+ +

+ Sandbox Connection +

- + +
+ + Bridge URL + + + {agent.bridge_url} + + +
+
)} {/* ── Actions card ── */} @@ -492,45 +333,35 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { // Sub-components // ---------------------------------------------------------------- -function InfoBlock({ - icon, +function InfoCell({ label, value, mono = false, - highlight, + accent, }: { - icon: React.ReactNode; label: string; value: string; mono?: boolean; - highlight?: "green" | "blue" | "orange"; + accent?: "emerald" | "blue" | "orange"; }) { const valueColor = - highlight === "green" - ? "text-green-400" - : highlight === "blue" + accent === "emerald" + ? "text-emerald-400" + : accent === "blue" ? "text-blue-400" - : highlight === "orange" + : accent === "orange" ? "text-orange-400" - : "text-white"; + : "text-white/80"; return ( -
-
{icon}
-
-

- {label} -

-

- {value} -

-
+
+

{label}

+

+ {value} +

); } diff --git a/app/dashboard/containers/page.tsx b/app/dashboard/containers/page.tsx index 8ce921c9e..a48155b6b 100644 --- a/app/dashboard/containers/page.tsx +++ b/app/dashboard/containers/page.tsx @@ -1,20 +1,12 @@ -import { - BrandCard, - ContainersEmptyState, - ContainersSkeleton, - DashboardStatCard, -} from "@elizaos/cloud-ui"; -import { Activity, AlertCircle, Box, Server, TrendingUp } from "lucide-react"; +import { ContainersEmptyState, ContainersSkeleton, DashboardStatCard } from "@elizaos/cloud-ui"; +import { Activity, AlertCircle, Server, TrendingUp } from "lucide-react"; import type { Metadata } from "next"; import { Suspense } from "react"; import { requireAuthWithOrg } from "@/lib/auth"; -import { getMiladyAgentPublicWebUiUrl } from "@/lib/milady-web-ui"; import { listContainers } from "@/lib/services/containers"; -import { miladySandboxService } from "@/lib/services/milaidy-sandbox"; import { ContainersPageWrapper } from "@/packages/ui/src/components/containers/containers-page-wrapper"; import { ContainersTable } from "@/packages/ui/src/components/containers/containers-table"; import { DeployFromCLI } from "@/packages/ui/src/components/containers/deploy-from-cli"; -import { MiladySandboxesTable } from "@/packages/ui/src/components/containers/milady-sandboxes-table"; export const metadata: Metadata = { title: "Containers", @@ -32,21 +24,6 @@ export default async function ContainersPage() { const user = await requireAuthWithOrg(); const containers = await listContainers(user.organization_id); - // Milady sandboxes table may not exist in all environments — degrade gracefully - let sandboxes: Awaited> = []; - try { - sandboxes = await miladySandboxService.listAgents(user.organization_id); - } catch { - // Table likely missing — show empty list - } - - // Read base domain once rather than per-sandbox in the map - const baseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; - const miladySandboxes = sandboxes.map((sandbox) => ({ - ...sandbox, - canonical_web_ui_url: getMiladyAgentPublicWebUiUrl(sandbox, { baseDomain }), - })); - const stats = { total: containers.length, running: containers.filter((c) => c.status === "running").length, @@ -59,7 +36,7 @@ export default async function ContainersPage() { return ( -
+
{/* Stats Grid - only show when containers exist */} {containers.length > 0 && (
@@ -73,19 +50,19 @@ export default async function ContainersPage() { label="Running" value={stats.running} accent="emerald" - icon={} + icon={} /> } + icon={} /> } + icon={} />
)} @@ -94,29 +71,13 @@ export default async function ContainersPage() { {containers.length === 0 ? ( ) : ( - <> - {/* Deploy from CLI helper */} +
- - {/* Table */} - - }> - - - - - )} - - {/* Milady Sandboxes (Docker-provisioned agents) */} - -
- -

Milady Sandboxes

+ }> + +
- }> - - -
+ )}
); diff --git a/app/dashboard/milady/page.tsx b/app/dashboard/milady/page.tsx index 57a20d3bf..9838cb3fa 100644 --- a/app/dashboard/milady/page.tsx +++ b/app/dashboard/milady/page.tsx @@ -1,9 +1,10 @@ -import { BrandCard, ContainersSkeleton } from "@elizaos/cloud-ui"; -import { Box } from "lucide-react"; +import { ContainersSkeleton } from "@elizaos/cloud-ui"; import type { Metadata } from "next"; import { Suspense } from "react"; import { requireAuthWithOrg } from "@/lib/auth"; +import { getMiladyAgentPublicWebUiUrl } from "@/lib/milady-web-ui"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; +import { MiladyPageWrapper } from "@/packages/ui/src/components/containers/milady-page-wrapper"; import { MiladySandboxesTable } from "@/packages/ui/src/components/containers/milady-sandboxes-table"; export const metadata: Metadata = { @@ -15,25 +16,42 @@ export const dynamic = "force-dynamic"; export default async function MiladyDashboardPage() { const user = await requireAuthWithOrg(); - const sandboxes = await miladySandboxService.listAgents(user.organization_id); + + // Milady sandboxes table may not exist in all environments — degrade gracefully + let sandboxes: Awaited> = []; + try { + sandboxes = await miladySandboxService.listAgents(user.organization_id); + } catch { + // Table likely missing — show empty list + } + + // Compute canonical Web UI URLs server-side so the client table can link them + const baseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + const sandboxesWithUrls = sandboxes.map((sandbox) => ({ + ...sandbox, + canonical_web_ui_url: getMiladyAgentPublicWebUiUrl(sandbox, { baseDomain }), + })); return ( -
- -
- -
-

Milady Instances

-

- Launch an existing Milady agent into the web app or create a new managed instance. + +

+
+
+ +

+ Instances

+

Milady Instances

+

+ Launch an existing agent into the web app or create a new managed instance. +

}> - + - -
+
+ ); } diff --git a/app/globals.css b/app/globals.css index 4fe9657e9..3fa50c8c1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1654,3 +1654,18 @@ button:not(:disabled), .animate-celebrate-bounce { animation: celebrateBounce 0.6s ease-out; } + +/* Provisioning UX — status transition animations */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(2px); } +} + +@keyframes scaleIn { + 0% { transform: scale(0.85); opacity: 0.5; } + 60% { transform: scale(1.08); } + 100% { transform: scale(1); opacity: 1; } +} diff --git a/bun.lock b/bun.lock index 41ae77d85..ae45e0c6b 100644 --- a/bun.lock +++ b/bun.lock @@ -113,7 +113,7 @@ "gray-matter": "^4.0.3", "highlight.js": "^11.11.1", "isomorphic-dompurify": "^2.35.0", - "jose": "^6.1.3", + "jose": "^4.15.0", "json5": "^2.2.3", "libphonenumber-js": "^1.12.35", "lucide-react": "^0.562.0", @@ -2865,7 +2865,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], @@ -4195,6 +4195,8 @@ "@coinbase/cdp-sdk/abitype": ["abitype@1.0.6", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3 >=3.22.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A=="], + "@coinbase/cdp-sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@coinbase/cdp-sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@coinbase/wallet-sdk/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], @@ -4223,6 +4225,8 @@ "@elizaos/server/@elizaos/plugin-sql": ["@elizaos/plugin-sql@1.7.2", "", { "dependencies": { "@electric-sql/pglite": "^0.3.3", "@elizaos/core": "1.7.2", "@neondatabase/serverless": "^1.0.2", "dotenv": "^17.2.3", "drizzle-kit": "^0.31.1", "drizzle-orm": "^0.45.0", "pg": "^8.13.3", "uuid": "^13.0.0", "ws": "^8.19.0" } }, "sha512-LYDcSm9nzdx8wpdcsI/74Yn5ZSeV0vneT151pyMHHDNF1erhslnj/ZLPqBmKPpSk24dHXitZsPI1Jp/5OJcKCw=="], + "@elizaos/server/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], @@ -4279,6 +4283,8 @@ "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@neondatabase/serverless/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.211.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg=="], @@ -4323,24 +4329,18 @@ "@privy-io/api-base/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@privy-io/js-sdk-core/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], - "@privy-io/js-sdk-core/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@privy-io/public-api/@privy-io/api-base": ["@privy-io/api-base@1.7.0", "", { "dependencies": { "zod": "^3.24.3" } }, "sha512-ji6ARQAAuW/FzRTgft9NCjRuouWX9t+J7JMmvLPsnXQJDFEV9mqh4sWXZhQ8ddTq/iDZ4z/yz1ORJqN8bYAi4Q=="], "@privy-io/public-api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@privy-io/react-auth/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], - "@privy-io/react-auth/lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="], "@privy-io/react-auth/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@privy-io/server-auth/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "@privy-io/server-auth/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], - "@privy-io/server-auth/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/docs/BILLING_AUDIT.md b/docs/BILLING_AUDIT.md new file mode 100644 index 000000000..36162465d --- /dev/null +++ b/docs/BILLING_AUDIT.md @@ -0,0 +1,478 @@ +# Billing Infrastructure Audit — Milady Cloud Agents + +> **Date:** 2026-03-16 +> **Auditor:** Sol (subagent lane2-billing-audit) +> **Scope:** Map existing billing infrastructure, define pricing, identify gaps for Milady agent billing + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Existing Billing Infrastructure](#existing-billing-infrastructure) +3. [Current Billing Flow](#current-billing-flow) +4. [Milady Agent Billing Gap Analysis](#milady-agent-billing-gap-analysis) +5. [Pricing Proposal](#pricing-proposal) +6. [Implementation TODOs](#implementation-todos) +7. [Architecture Diagrams](#architecture-diagrams) + +--- + +## Executive Summary + +Eliza Cloud has a **mature, production-ready billing system** with Stripe integration, crypto payments (OxaPay), credit packs, auto-top-up, and a daily container billing cron. However, **Milady sandbox agents are NOT connected to this billing system at all**. Agents can be created, provisioned, and run indefinitely without any credit checks or charges. This is the critical gap. + +### What Exists ✅ +- Stripe checkout + webhook for credit purchases +- Crypto payments via OxaPay (USDC, BTC, ETH, etc.) +- Credit balance per organization (`organizations.credit_balance`) +- Daily container billing cron (`$0.67/day per AWS container`) +- Auto-top-up (threshold-based, Stripe auto-charge) +- Low-credit email warnings + 48h shutdown grace period +- Credit transaction audit trail +- Affiliate/referral revenue splits +- Invoice generation (Stripe + crypto) + +### What's Missing ❌ +- **No credit check before Milady agent provisioning** — anyone can spin up agents for free +- **Container billing cron only queries `containers` table** — Milady agents live in `milady_sandboxes` (separate table) +- **No billing fields on `milady_sandboxes` schema** — no `billing_status`, `last_billed_at`, `total_billed`, etc. +- **No per-agent cost calculation for Docker-hosted agents** — pricing constants assume AWS ECS +- **No usage dashboard for Milady agents** — billing UI only shows credit packs + balance + +--- + +## Existing Billing Infrastructure + +### 1. Credit System + +**Location:** `packages/lib/services/credits.ts` + +Credits are USD-denominated, stored as `organizations.credit_balance` (numeric string). The system supports: + +| Operation | Method | Details | +|-----------|--------|---------| +| Add credits | `creditsService.addCredits()` | Atomic, idempotent via `stripe_payment_intent_id` | +| Deduct credits | `creditsService.deductCredits()` | Row-level locking (`FOR UPDATE`), prevents negative balance | +| Reserve + deduct | `creditsService.reserveAndDeductCredits()` | Atomic check-and-deduct, prevents TOCTOU race | +| Refund | `creditsService.refundCredits()` | Restores balance + creates refund transaction | +| Reserve (high-level) | `creditsService.reserve()` | Estimates cost, deducts with buffer, returns `reconcile()` callback | + +**Key constants:** +- `COST_BUFFER = 1.5` (50% buffer on AI cost estimates) +- `PLATFORM_MARKUP_MULTIPLIER = 1.2` (20% markup on all provider costs) + +### 2. Credit Packs + +**Schema:** `packages/db/schemas/credit-packs.ts` + +Pre-defined purchase amounts with Stripe price IDs. Stored in `credit_packs` table. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | text | Pack name | +| `credits` | numeric(10,2) | Credit amount in USD | +| `price_cents` | integer | Stripe price in cents | +| `stripe_price_id` | text | Stripe Price ID | +| `stripe_product_id` | text | Stripe Product ID | +| `is_active` | boolean | Whether pack is available | + +Custom amounts also supported ($1-$1000 range). + +### 3. Stripe Integration + +**Key files:** +- `app/api/stripe/create-checkout-session/route.ts` — Creates Stripe Checkout sessions +- `app/api/stripe/webhook/route.ts` — Handles `checkout.session.completed` + `payment_intent.succeeded` +- `packages/lib/services/payment-methods.ts` — Payment method management +- `packages/lib/services/auto-top-up.ts` — Automatic balance replenishment + +**Flow:** User → Checkout Session → Stripe Payment → Webhook → `creditsService.addCredits()` → Balance updated + +### 4. Crypto Payments (OxaPay) + +**File:** `packages/lib/services/crypto-payments.ts` + +Full crypto payment flow via OxaPay redirect. Supports USDC, BTC, ETH, and other currencies. Creates invoice → user pays → webhook confirms → credits added. Same `creditsService.addCredits()` endpoint. + +### 5. Auto-Top-Up + +**File:** `packages/lib/services/auto-top-up.ts` + +When balance drops below threshold, automatically charges the default Stripe payment method: +- Min amount: $1, Max: $1000 +- Min threshold: $0, Max: $1000 +- Uses Stripe `off_session` payment with idempotency key +- Disables itself on payment failure (sends email notification) + +### 6. Container Billing Cron + +**File:** `app/api/cron/container-billing/route.ts` + +**Schedule:** Daily at midnight UTC + +**What it does:** +1. Queries `containers` table for `status = 'running'` and `billing_status IN ('active', 'warning', 'shutdown_pending')` +2. Calculates daily cost per container: **$0.67/day** base (with 20% markup over $0.56 AWS cost) +3. Deducts from `organizations.credit_balance` +4. Creates `credit_transactions` record (type: `debit`) +5. Creates `container_billing_records` entry +6. If insufficient credits: sends 48h shutdown warning email +7. After 48h warning period: stops container, marks `billing_status = 'suspended'` + +**Pricing formula:** +``` +baseCost = $0.67/day per container ++ CPU premium if > 1 vCPU (linear multiplier) ++ Memory premium if > 2GB (sub-linear sqrt multiplier) +× number of instances +``` + +**Monthly equivalent:** ~$20/month per standard container (1 vCPU, 2GB RAM) + +### 7. Old Credits Bridge (milady-cloud legacy) + +**File:** `/home/shad0w/projects/milady-cloud/backend/services/eliza-credits-client.ts` + +The old milady-cloud Express backend had a client that called Eliza Cloud's API to check credit balances: +- `GET /api/v1/credits/balance` with `Bearer ` +- `checkSufficientCredits(apiKey, estimatedCost)` — checks balance >= cost + +This was the **intended integration point** but was never wired into the provisioning flow. The old backend is being superseded by Eliza Cloud's native Milady API routes. + +--- + +## Current Billing Flow + +### How Credits Are Purchased + +``` +User clicks "Add Funds" in /dashboard/billing + ├── Credit Pack → Stripe Checkout (pre-defined price) + ├── Custom Amount → Stripe Checkout ($1-$1000, dynamic line item) + └── Crypto → OxaPay redirect → webhook confirms payment + +Stripe/OxaPay Webhook fires + → creditsService.addCredits(orgId, amount, paymentIntentId) + → organizations.credit_balance += amount (atomic, row-locked) + → credit_transactions record created (type: 'credit') + → invoice record created + → Discord payment notification (fire-and-forget) +``` + +### How Credits Are Consumed + +| Consumer | Billing Method | Connected? | +|----------|---------------|------------| +| AI inference (text gen) | Per-request, reserve + reconcile | ✅ Yes | +| Image generation | Per-image ($0.01) | ✅ Yes | +| Video generation | Per-video ($0.05) | ✅ Yes | +| TTS / STT | Per-1K chars / per-minute | ✅ Yes | +| AWS ECS Containers | Daily cron ($0.67/day) | ✅ Yes | +| Blockchain API proxy | Per-call ($0.0003-$0.001) | ✅ Yes | +| **Milady sandbox agents** | **NOT BILLED** | ❌ **NO** | + +### Container Billing Cron vs Milady Sandboxes + +The container billing cron queries the **`containers`** table — this is for AWS ECS deployments. +Milady agents live in the **`milady_sandboxes`** table — a completely separate schema. + +**`milady_sandboxes` has NO billing columns:** +- No `billing_status` +- No `last_billed_at` / `next_billing_at` +- No `total_billed` +- No `shutdown_warning_sent_at` / `scheduled_shutdown_at` + +The cron will never find Milady agents. They run for free. + +--- + +## Milady Agent Billing Gap Analysis + +### 1. No Pre-Provisioning Credit Check + +**Where provisioning happens:** `packages/lib/services/milady-sandbox.ts` → `provision()` + +**What happens:** +1. Finds sandbox record in DB +2. Sets status to `provisioning` +3. Creates Neon DB +4. Creates Docker container on remote node +5. Health check +6. Marks `running` + +**What should happen:** +1. **CHECK: Does org have sufficient credits for at least 1 day of agent hosting?** +2. **DEDUCT: Charge deployment fee** +3. Proceed with provisioning + +**Also missing from agent creation:** `app/api/v1/milady/agents/route.ts` POST handler creates agents with no credit check. + +### 2. No Ongoing Billing + +No cron or mechanism charges for running Milady agents. They run indefinitely without consuming credits. + +### 3. No Billing-Driven Lifecycle + +No mechanism to: +- Warn users when credits are low (specific to their running agents) +- Suspend agents when credits run out +- Resume agents when credits are added + +### 4. Schema Gap + +`milady_sandboxes` table needs billing columns to match `containers` table capabilities. + +### 5. Pricing Model Mismatch + +Container billing assumes AWS ECS costs ($0.56/day base → $0.67/day with markup). +Milady agents run on **dedicated Hetzner servers** with very different cost structure. + +--- + +## Pricing Proposal + +### Infrastructure Costs + +| Item | Monthly Cost | Notes | +|------|-------------|-------| +| Hetzner AX42 (dedicated) | ~€40-50/mo (~$44-55) | 32GB RAM, 8-core Ryzen, 2×512GB NVMe | +| Neon DB per agent | ~$0/mo (free tier) | Each agent gets a free-tier Neon project | +| Cloudflare tunnel | $0 | Already configured | +| Headscale/Tailscale | $0 | Self-hosted coordination | + +### Capacity per Server + +From handoff context: **~8 agents per 32GB RAM server** at comfortable capacity. + +``` +Server cost: $50/month +Per-agent infra cost: $50 / 8 = $6.25/month +Per-agent-day infra cost: $6.25 / 30 = ~$0.21/day +``` + +### Suggested Pricing Tiers + +| Tier | Daily Cost | Monthly Cost | Margin | Target User | +|------|-----------|-------------|--------|-------------| +| **Basic Agent** | $0.50/day | $15/mo | 140% | Personal projects, testing | +| **Standard Agent** | $0.67/day | $20/mo | 220% | Production agents, businesses | +| **Power Agent** | $1.00/day | $30/mo | 380% | High-memory, priority support | + +**Recommendation:** Start with **Standard Agent at $0.67/day ($20/mo)** to match existing container pricing. This: +- Provides familiar pricing (same as AWS containers already in the system) +- Gives ~220% margin over infra costs at capacity (good for covering overhead, support, underutilization) +- Can adjust later as we understand real utilization patterns + +### Deployment Fee + +| Action | Cost | Rationale | +|--------|------|-----------| +| Agent deployment | $0.50 (one-time) | Covers Neon DB provisioning + Docker setup | +| Re-deployment | $0.25 | Lighter operation (reuse existing DB) | +| Image upload | $0.00 | N/A for Milady (pre-built images) | + +### Free Trial Proposal + +**Option A (Recommended):** First agent free for 24 hours +- Low friction for new users +- Enough time to evaluate the platform +- Auto-stop at 24h mark if no credits added +- Cost: ~$0.21 infra cost per trial + +**Option B:** $5 credit bonus on first signup +- User gets to experience billing flow +- ~7 days of a basic agent +- More expensive but stickier + +### AI Inference Costs (Pass-Through) + +When agents use AI features (via `cloudProvider: "elizacloud"` + `ELIZAOS_API_KEY`), those costs are already billed through the existing AI billing system: +- Text gen: per-token with 20% markup +- Image/video gen: flat rate per generation +- Blockchain API proxy: per-call + +These are **separate from hosting costs** and already working. The agent's `ELIZAOS_API_KEY` ties inference to the org's credit balance. + +--- + +## Implementation TODOs + +### Phase 1: Schema + Basic Enforcement (P0 — Do First) + +- [ ] **2.A** Add billing columns to `milady_sandboxes`: + ```sql + ALTER TABLE milady_sandboxes ADD COLUMN billing_status TEXT DEFAULT 'active'; + ALTER TABLE milady_sandboxes ADD COLUMN last_billed_at TIMESTAMPTZ; + ALTER TABLE milady_sandboxes ADD COLUMN next_billing_at TIMESTAMPTZ; + ALTER TABLE milady_sandboxes ADD COLUMN total_billed NUMERIC(10,2) DEFAULT '0'; + ALTER TABLE milady_sandboxes ADD COLUMN shutdown_warning_sent_at TIMESTAMPTZ; + ALTER TABLE milady_sandboxes ADD COLUMN scheduled_shutdown_at TIMESTAMPTZ; + ``` + +- [ ] **2.B** Add Milady pricing constants to `packages/lib/constants/pricing.ts`: + ```typescript + export const MILADY_PRICING = { + DAILY_RUNNING_COST: 0.67, // $0.67/day per agent + DEPLOYMENT_COST: 0.50, // $0.50 per deployment + REDEPLOYMENT_COST: 0.25, // $0.25 per re-deployment + MIN_BALANCE_FOR_PROVISION: 1.00, // Minimum $1 to start an agent + SHUTDOWN_WARNING_HOURS: 48, + LOW_CREDITS_WARNING_DAYS: 3, + }; + ``` + +- [ ] **2.C** Add credit check before provisioning in `milady-sandbox.ts` → `provision()`: + ```typescript + // Before provisioning, check org has sufficient credits + const org = await organizationsRepository.findById(orgId); + const balance = Number(org.credit_balance); + if (balance < MILADY_PRICING.MIN_BALANCE_FOR_PROVISION) { + return { success: false, error: 'Insufficient credits' }; + } + // Deduct deployment fee + await creditsService.deductCredits({ + organizationId: orgId, + amount: MILADY_PRICING.DEPLOYMENT_COST, + description: `Agent deployment: ${agentName}`, + }); + ``` + +- [ ] **2.D** Add credit check in API route `app/api/v1/milady/agents/[agentId]/provision/route.ts` + +### Phase 2: Billing Cron (P0 — Do With Phase 1) + +- [ ] **2.E** Create `app/api/cron/milady-billing/route.ts`: + - Query `milady_sandboxes WHERE status = 'running'` + - Calculate daily cost per agent ($0.67/day) + - Deduct from org balance (atomic transaction) + - Create credit transaction record + - Handle insufficient credits: send warning → 48h → shutdown + - Mirror the pattern from `container-billing/route.ts` exactly + +- [ ] **2.F** Add Vercel cron config for milady-billing (daily at midnight UTC) + +### Phase 3: Billing UI (P1 — After Core Billing Works) + +- [ ] **2.G** Show per-agent costs in the Milady Instances dashboard: + - Daily cost column + - Total billed column + - Billing status badge (active / warning / suspended) + +- [ ] **2.H** Add "Milady Agent Hosting" line items to credit transaction history + +- [ ] **2.I** Billing notification emails: + - Low credits warning (template exists: `email/templates/low-credits.html`) + - Shutdown warning (48h notice) + - Agent suspended notification + +### Phase 4: Free Trial (P2 — Nice to Have) + +- [ ] **2.J** Implement free trial logic: + - Track `has_used_trial` flag on organization + - First agent gets 24h free hosting + - Auto-stop cron after trial expires + - Prompt to add credits for continued service + +### Phase 5: Crypto Billing Path (P2 — Already Working) + +The crypto payment path via OxaPay is **already functional** for buying credits. No Milady-specific work needed here — once credits are in the org balance, they work identically whether purchased via Stripe or crypto. The billing cron doesn't care how credits were obtained. + +### Future Considerations + +- [ ] **Usage-based pricing** — charge per AI inference token consumed by the agent (already happening via `ELIZAOS_API_KEY`) +- [ ] **Tiered pricing** — different daily rates for different resource allocations +- [ ] **Volume discounts** — reduced rate for 3+ agents in same org +- [ ] **Pre-paid plans** — monthly subscription at discount ($15/mo instead of $20 pay-as-you-go) +- [ ] **USDC on-chain billing** — direct smart contract payments (longer term, not in scope now) + +--- + +## Architecture Diagrams + +### Current State (No Billing) + +``` +User → Create Agent → POST /api/v1/milady/agents (no credit check) + ↓ +User → Provision Agent → POST /api/v1/milady/agents/:id/provision (no credit check) + ↓ + Docker container starts on Hetzner node + ↓ + Agent runs indefinitely... (no billing) +``` + +### Target State (With Billing) + +``` +User → Create Agent → POST /api/v1/milady/agents + ↓ +User → Provision Agent → POST /api/v1/milady/agents/:id/provision + ↓ + ┌── Credit Check ──┐ + │ balance >= $1.00?│ + └──┬──────────┬───┘ + │ YES │ NO → 402 "Insufficient credits" + ↓ + Deduct $0.50 deployment fee + ↓ + Docker container starts + ↓ + Agent runs... + ↓ + ┌── Daily Billing Cron (midnight UTC) ──┐ + │ For each running milady_sandbox: │ + │ balance >= $0.67? │ + │ YES → deduct $0.67, log transaction │ + │ NO → send 48h warning email │ + │ 48h expired → stop agent, suspend │ + └────────────────────────────────────────┘ +``` + +### Billing System Integration Map + +``` + ┌─────────────────────────┐ + │ Credit Balance │ + │ organizations.credit_ │ + │ balance │ + └──────────┬──────────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌────────▼──────┐ ┌──────▼──────┐ ┌───────▼──────┐ + │ Stripe │ │ OxaPay │ │ Auto-Top-Up │ + │ credit packs │ │ crypto │ │ threshold │ + │ custom amount │ │ payments │ │ charge │ + └───────────────┘ └─────────────┘ └──────────────┘ + ↑ ADD ↑ ADD + + │ DEDUCT │ DEDUCT + ┌────────▼──────┐ ┌──────▼──────┐ ┌───────▼──────┐ + │ AI Inference │ │ Container │ │ Milady │ + │ (per-token) │ │ Billing │ │ Agent │ + │ ✅ WORKING │ │ (daily AWS) │ │ Billing │ + │ │ │ ✅ WORKING │ │ ❌ MISSING │ + └───────────────┘ └─────────────┘ └──────────────┘ +``` + +--- + +## Files Referenced + +| File | Purpose | +|------|---------| +| `app/api/cron/container-billing/route.ts` | Daily AWS container billing cron | +| `packages/lib/constants/pricing.ts` | Container pricing constants ($0.67/day) | +| `packages/lib/services/credits.ts` | Core credit management service | +| `packages/lib/services/auto-top-up.ts` | Automatic balance replenishment | +| `packages/lib/services/crypto-payments.ts` | OxaPay crypto payment flow | +| `app/api/stripe/create-checkout-session/route.ts` | Stripe checkout creation | +| `app/api/stripe/webhook/route.ts` | Stripe webhook handler | +| `app/api/v1/milady/agents/route.ts` | Milady agent CRUD (no credit check) | +| `app/api/v1/milady/agents/[agentId]/provision/route.ts` | Provisioning endpoint (no credit check) | +| `packages/lib/services/milady-sandbox.ts` | Sandbox lifecycle management | +| `packages/lib/services/milady-managed-launch.ts` | Managed agent launch + onboarding | +| `packages/db/schemas/milady-sandboxes.ts` | Schema (missing billing columns) | +| `packages/db/schemas/containers.ts` | Container schema (has billing columns) | +| `milady-cloud/backend/services/eliza-credits-client.ts` | Legacy credit bridge (unused) | diff --git a/docs/ONBOARDING_INTEGRATION_PLAN.md b/docs/ONBOARDING_INTEGRATION_PLAN.md new file mode 100644 index 000000000..3648f9391 --- /dev/null +++ b/docs/ONBOARDING_INTEGRATION_PLAN.md @@ -0,0 +1,962 @@ +# Milady ↔ Eliza Cloud: Onboarding Integration Plan + +> **Status**: Planning — ready for implementation +> **Date**: 2026-03-16 +> **Author**: Sol (automated analysis) +> **Repos**: `milady-ai/milady` (develop), `elizaOS/cloud` (eliza-cloud-v2) + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Current Onboarding Flow (As-Is)](#2-current-onboarding-flow-as-is) +3. [Eliza Cloud Compat API Map](#3-eliza-cloud-compat-api-map) +4. [Proposed Onboarding Flow (To-Be)](#4-proposed-onboarding-flow-to-be) +5. [Auth Flow Design](#5-auth-flow-design) +6. [Provisioning Flow Design](#6-provisioning-flow-design) +7. [Thin Client Mode](#7-thin-client-mode) +8. [Desktop App Integration](#8-desktop-app-integration) +9. [Fallback to Local Mode](#9-fallback-to-local-mode) +10. [Code Changes Required](#10-code-changes-required) +11. [New Compat API Endpoints Needed](#11-new-compat-api-endpoints-needed) +12. [Sequence Diagrams](#12-sequence-diagrams) +13. [Open Questions](#13-open-questions) + +--- + +## 1. Executive Summary + +**Goal**: Make "Host on Eliza Cloud" the default/recommended runtime option during milady's first-run onboarding, while keeping local mode as a fully-supported fallback. + +**Current state**: The milady CLI (`milady start`) runs `runFirstTimeSetup()` in `src/runtime/eliza.ts` which walks the user through: name → personality → AI provider → wallets → GitHub. Cloud integration exists (`src/cloud/`) but is a separate, opt-in path — never surfaced during onboarding. + +**Target state**: After the name + personality steps, the user sees a new "How should I run?" prompt where "☁️ Eliza Cloud (recommended)" is the default. Choosing it triggers browser-based auth → agent provisioning → thin client connection — all within the same `milady start` flow. + +--- + +## 2. Current Onboarding Flow (As-Is) + +### Entry Points + +| Surface | Entry | Code | +|---------|-------|------| +| CLI | `milady start` (first run, no agent name in config) | `src/runtime/eliza.ts` → `runFirstTimeSetup()` (line ~3055) | +| CLI | `milady setup` (explicit) | `src/cli/program/register.setup.ts` → `registerSetupCommand()` | +| Desktop | Electron/Electrobun app → headless boot | `bootElizaRuntime({ requireConfig: true })` — skips CLI onboarding, GUI handles it | +| API | Web UI onboarding | `src/api/server.ts` — uses same `STYLE_PRESETS` from `src/onboarding-presets.ts` | + +### `runFirstTimeSetup()` Steps (CLI path) + +``` +Step 1: Welcome banner + └─ clack.intro("WELCOME TO MILADY!") + +Step 2: Name selection + └─ 4 random names from onboarding-names.ts + "Custom..." + └─ Stored in config.agents.list[0].name + +Step 3: Personality/style selection + └─ 7 presets from STYLE_PRESETS: uwu~, hell yeah, lol k, Noted., hehe~, ..., locked in + └─ Each has bio[], system prompt, style rules, adjectives, examples + └─ composeCharacter() mixes preset with random BIO_POOL + SYSTEM_POOL samples + └─ Stored in config.agents.list[0].{bio, system, style, adjectives, ...} + +Step 4: Model provider selection ◀── THIS IS WHERE CLOUD SHOULD GO + └─ Detects existing env keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) + └─ If none found: menu of 11 providers + "Skip for now" + └─ Chosen key saved to config.env[PROVIDER_KEY] + +Step 5: Wallet setup + └─ Generate new / Import existing / Skip + └─ EVM + Solana keypairs saved to config.env + +Step 6: Skills registry (silent) + └─ Sets SKILLS_REGISTRY=https://clawhub.ai + +Step 7: GitHub access + └─ PAT / OAuth / Skip + +Step 8: Persist config + └─ saveMiladyConfig(updated) + └─ Saves to ~/.milady/milady.json +``` + +### `milady setup` Command + +Separate from `runFirstTimeSetup()`. Runs `runProviderWizard()` which only handles the model provider key — a simpler version of Step 4. Also bootstraps the agent workspace directory. + +### Config Structure (`~/.milady/milady.json`) + +```jsonc +{ + "agents": { + "list": [{ + "id": "main", + "default": true, + "name": "Mochi", + "bio": ["..."], + "system": "...", + "style": { "all": [], "chat": [], "post": [] }, + "adjectives": ["..."], + "postExamples": ["..."], + "messageExamples": [...] + }] + }, + "env": { + "ANTHROPIC_API_KEY": "sk-ant-...", + "EVM_PRIVATE_KEY": "0x...", + "SOLANA_PRIVATE_KEY": "..." + }, + "cloud": { + "enabled": false, // ← currently off by default + "apiKey": null, + "baseUrl": "https://www.elizacloud.ai" + } +} +``` + +### Cloud Config Type (`src/config/types.milady.ts`) + +```typescript +type CloudConfig = { + enabled?: boolean; + provider?: string; + baseUrl?: string; + apiKey?: string; + inferenceMode?: "cloud" | "byok" | "local"; + services?: CloudServiceToggles; + autoProvision?: boolean; + bridge?: CloudBridgeConfig; + backup?: CloudBackupConfig; + container?: CloudContainerDefaults; +}; +``` + +### Existing Cloud Modules (`src/cloud/`) + +| File | Purpose | Status | +|------|---------|--------| +| `auth.ts` | `cloudLogin()` — creates CLI session, opens browser, polls for API key | ✅ Complete | +| `bridge-client.ts` | `ElizaCloudClient` — full CRUD + messaging + streaming + snapshots | ✅ Complete | +| `cloud-manager.ts` | `CloudManager` — orchestrates client + proxy + backup + reconnect | ✅ Complete | +| `cloud-proxy.ts` | `CloudRuntimeProxy` — drop-in replacement for local AgentRuntime | ✅ Complete | +| `base-url.ts` | URL normalization, defaults to `https://www.elizacloud.ai` | ✅ Complete | +| `index.ts` | Re-exports | ✅ Complete | + +**Key insight**: The cloud infrastructure in the milady repo is already built. The gap is purely in the **onboarding flow** — cloud is never presented as an option during setup. + +--- + +## 3. Eliza Cloud Compat API Map + +### Authentication Flow + +``` +POST /api/auth/cli-session + Body: { sessionId: "" } + Returns: { sessionId, status: "pending", expiresAt } + +Browser: /auth/cli-login?session= + User authenticates via Privy (wallet, email, social) + +POST /api/auth/cli-session/[sessionId]/complete (called by web UI after Privy auth) + Requires: Privy session auth + Action: Generates API key, stores encrypted, marks session "authenticated" + Returns: { success, apiKey, keyPrefix, expiresAt } + +GET /api/auth/cli-session/[sessionId] (polled by CLI) + Public: no auth required + If pending: { status: "pending" } + If authenticated: { status: "authenticated", apiKey, keyPrefix, expiresAt } + NOTE: apiKey is one-time retrieval (cleared after first GET) + If expired/not found: 404 +``` + +### Agent Management (Compat Layer) + +All require auth via: `X-Service-Key` OR service JWT OR Privy/API-key (`X-Api-Key` header). + +``` +GET /api/compat/agents + Returns: { success, data: CompatAgentShape[] } + +POST /api/compat/agents + Body: { agentName, agentConfig?, environmentVars? } + Returns: { success, data: { agentId, agentName, jobId, status, nodeId, message } } + Note: if WAIFU_AUTO_PROVISION=true, auto-provisions on create + +GET /api/compat/agents/[id] + Returns: { success, data: CompatAgentShape } + +DELETE /api/compat/agents/[id] + Returns: { success, data: { jobId, status, message } } + +GET /api/compat/agents/[id]/status + Returns: { success, data: { status, lastHeartbeat, bridgeUrl, webUiUrl, ... } } + +POST /api/compat/agents/[id]/launch + Provisions if needed, returns launch URL + connection details + Returns: { success, data: { agentId, agentName, appUrl, launchSessionId, connection } } + Note: launchManagedMiladyAgent not yet implemented (placeholder) +``` + +### Availability + +``` +GET /api/compat/availability (public for aggregate, auth for node topology) + Returns: { success, data: { totalSlots, usedSlots, availableSlots, acceptingNewAgents } } +``` + +### Status Mapping + +| Eliza Cloud Internal | Compat/Thin Client | +|---------------------|--------------------| +| pending | queued | +| provisioning | provisioning | +| running | running | +| stopped | stopped | +| disconnected | stopped | +| error | failed | + +### Bridge Client API (milady → cloud agent) + +The `ElizaCloudClient` in milady's `src/cloud/bridge-client.ts` calls these internal V1 endpoints: + +``` +GET /api/v1/milady/agents — list agents +POST /api/v1/milady/agents — create agent +GET /api/v1/milady/agents/:id — get agent +DELETE /api/v1/milady/agents/:id — delete agent +POST /api/v1/milady/agents/:id/provision — provision sandbox +POST /api/v1/milady/agents/:id/bridge — JSON-RPC message relay +POST /api/v1/milady/agents/:id/stream — SSE streaming message relay +POST /api/v1/milady/agents/:id/snapshot — create backup +GET /api/v1/milady/agents/:id/backups — list backups +POST /api/v1/milady/agents/:id/restore — restore from backup +``` + +**Note**: The bridge-client uses `/api/v1/milady/agents/` paths, but the compat API is at `/api/compat/agents/`. Either the bridge-client needs to switch to compat routes, or the V1 routes need to be verified as equivalent. **Recommendation**: Use the compat routes for the thin client since they're the official external API. + +--- + +## 4. Proposed Onboarding Flow (To-Be) + +### Modified `runFirstTimeSetup()` Steps + +``` +Step 1: Welcome banner (unchanged) + └─ clack.intro("WELCOME TO MILADY!") + +Step 2: Name selection (unchanged) + └─ Same 4 random + Custom + +Step 3: Personality/style selection (unchanged) + └─ Same 7 STYLE_PRESETS + +Step 3.5: ★ NEW — Runtime selection ◀── THE KEY CHANGE + └─ "Where should I live?" + │ + ├─ ☁️ Eliza Cloud (recommended) ← DEFAULT, pre-selected + │ └─ "Zero setup — runs in the cloud, always online" + │ + ├─ 💻 Run locally + │ └─ "Full control — runs on this machine" + │ + └─ ⏭️ Decide later + └─ "Start local, switch to cloud anytime" + + IF "Eliza Cloud": + └─ Step 3.5a: Check availability (GET /api/compat/availability) + │ └─ If !acceptingNewAgents → warn, offer local fallback + │ + └─ Step 3.5b: Cloud authentication + │ └─ cloudLogin() → opens browser → polls for API key + │ └─ Stores apiKey in config.cloud.apiKey + │ └─ Sets config.cloud.enabled = true + │ + └─ Step 3.5c: Create cloud agent + │ └─ POST /api/compat/agents { agentName, agentConfig: { preset, style... } } + │ └─ Wait for status == "running" (poll GET /api/compat/agents/:id/status) + │ └─ Store agentId in config.cloud.agentId (new field) + │ + └─ Step 3.5d: Skip Steps 4-7 (provider, wallets, GitHub) + └─ Cloud handles inference, no local API key needed + └─ Wallets can be configured later via cloud dashboard + + IF "Run locally": + └─ Continue to Step 4 (model provider) as today + + IF "Decide later": + └─ Continue to Step 4 as today (local default) + +Step 4: Model provider selection (only if local) +Step 5: Wallet setup (only if local or always?) +Step 6: Skills registry (unchanged) +Step 7: GitHub access (unchanged) +Step 8: Persist config (unchanged + cloud fields) +``` + +### Post-Onboarding Start Behavior + +After `runFirstTimeSetup()` returns, `startEliza()` needs to check if cloud mode was chosen: + +``` +if config.cloud.enabled && config.cloud.apiKey && config.cloud.agentId: + → Initialize CloudManager + → Connect to cloud agent via bridge + → Start thin client mode (TUI talks to cloud proxy) + → Skip local runtime initialization +else: + → Start local elizaOS runtime (current behavior) +``` + +--- + +## 5. Auth Flow Design + +### CLI Auth Sequence + +``` +User milady CLI Eliza Cloud Browser + │ │ │ │ + │ picks "Eliza Cloud" │ │ │ + │───────────────────────>│ │ │ + │ │ POST /api/auth/ │ │ + │ │ cli-session │ │ + │ │ {sessionId: uuid} │ │ + │ │───────────────────────>│ │ + │ │ 201 {sessionId, │ │ + │ │ status: "pending"} │ │ + │ │<───────────────────────│ │ + │ │ │ │ + │ │ open(browserUrl) │ │ + │ │────────────────────────│────────────────────>│ + │ │ │ │ + │ │ │ /auth/cli-login? │ + │ │ │ session= │ + │ │ │<────────────────────│ + │ │ │ │ + │ │ │ User logs in │ + │ │ │ (Privy: wallet/ │ + │ │ │ email/social) │ + │ │ │ │ + │ │ │ POST /api/auth/ │ + │ │ │ cli-session/:id/ │ + │ │ │ complete │ + │ │ │<────────────────────│ + │ │ │ │ + │ │ poll GET /api/auth/ │ │ + │ │ cli-session/:id │ │ + │ │───────────────────────>│ │ + │ │ {status: "auth'd", │ │ + │ │ apiKey: "...", │ │ + │ │ keyPrefix, expiresAt}│ │ + │ │<───────────────────────│ │ + │ │ │ │ + │ "✓ Logged in!" │ │ │ + │<───────────────────────│ │ │ +``` + +### Implementation Notes + +- `cloudLogin()` in `src/cloud/auth.ts` already implements this exact flow +- It accepts `onBrowserUrl` callback — the onboarding wizard can use this to show the URL in the terminal +- Default timeout: 5 minutes (300s) — configurable +- Poll interval: 2s +- API key is one-time retrieval (security: cleared after first GET) +- `normalizeCloudSiteUrl()` defaults to `https://www.elizacloud.ai` + +### What to Store After Auth + +```jsonc +// ~/.milady/milady.json +{ + "cloud": { + "enabled": true, + "provider": "elizacloud", + "apiKey": "ec_...", // from cloudLogin() + "baseUrl": "https://www.elizacloud.ai", + "inferenceMode": "cloud", // cloud handles model calls + "autoProvision": true, + "services": { + "inference": true, + "tts": true, + "media": true + } + } +} +``` + +--- + +## 6. Provisioning Flow Design + +### After Auth, During Onboarding + +```typescript +// Pseudocode for onboarding provisioning +async function provisionCloudAgent( + config: MiladyConfig, + agentName: string, + preset: StylePreset, +): Promise<{ agentId: string; bridgeUrl: string }> { + const client = new ElizaCloudClient( + normalizeCloudSiteUrl(config.cloud.baseUrl), + config.cloud.apiKey, + ); + + // 1. Create agent with character config + const { bio, system } = composeCharacter(preset); + const agent = await client.createAgent({ + agentName, + agentConfig: { + preset: preset.catchphrase, + bio, + system, + style: preset.style, + adjectives: preset.adjectives, + postExamples: preset.postExamples, + messageExamples: preset.messageExamples, + }, + }); + + // 2. Wait for provisioning to complete + const spinner = clack.spinner(); + spinner.start("Setting up your cloud agent..."); + + let status = agent.status; + while (status !== "running" && status !== "completed") { + await sleep(3000); + const statusRes = await client.getAgent(agent.agentId); + status = statusRes.status; + + if (status === "failed" || status === "error") { + spinner.stop("Provisioning failed"); + throw new Error(`Cloud agent provisioning failed: ${statusRes.errorMessage}`); + } + + spinner.message(`Status: ${status}...`); + } + + spinner.stop("Cloud agent is running! ☁️"); + + return { + agentId: agent.agentId, + bridgeUrl: agent.bridgeUrl, + }; +} +``` + +### Status Polling Strategy + +1. Create agent → immediately get `jobId` (same as `agentId`) +2. Poll `GET /api/compat/agents/:id/status` every 3s +3. Status transitions: `queued` → `provisioning` → `running` +4. Timeout after 120s (containers take ~30-60s to provision) +5. On failure: offer retry or fallback to local + +### What to Store After Provisioning + +```jsonc +// Added to ~/.milady/milady.json → cloud section +{ + "cloud": { + // ...existing auth fields... + "agentId": "uuid-of-cloud-agent", // NEW FIELD + "bridgeUrl": "https://...", // NEW FIELD (cached for fast reconnect) + } +} +``` + +--- + +## 7. Thin Client Mode + +### How It Works (Already Built) + +Once connected, the milady CLI/TUI acts as a thin client: + +1. **CloudManager** (`src/cloud/cloud-manager.ts`) initializes `ElizaCloudClient` with the stored API key +2. **CloudManager.connect(agentId)** provisions if needed, creates a `CloudRuntimeProxy` +3. **CloudRuntimeProxy** (`src/cloud/cloud-proxy.ts`) is a drop-in for `AgentRuntime`: + - `handleChatMessage(text)` → calls `ElizaCloudClient.sendMessage()` (JSON-RPC bridge) + - `handleChatMessageStream(text)` → calls `ElizaCloudClient.sendMessageStream()` (SSE) + - `getStatus()` → calls `ElizaCloudClient.getAgent()` + - `isAlive()` → calls `ElizaCloudClient.heartbeat()` +4. **BackupScheduler** auto-snapshots every 60s +5. **ConnectionMonitor** heartbeats every 30s, auto-reconnects on disconnect + +### Changes Needed in `startEliza()` + +After `runFirstTimeSetup()` completes, `startEliza()` needs a cloud-mode branch: + +```typescript +// In startEliza(), after config = await runFirstTimeSetup(config) + +if (config.cloud?.enabled && config.cloud?.apiKey && config.cloud?.agentId) { + // Cloud mode — start thin client + const cloudManager = new CloudManager(config.cloud); + await cloudManager.init(); + const proxy = await cloudManager.connect(config.cloud.agentId); + + if (opts?.headless) { + // For API server mode, return proxy as runtime-like object + return proxy as unknown as AgentRuntime; // needs interface alignment + } + + // Interactive mode — start readline loop with cloud proxy + await startCloudChatLoop(proxy, cloudManager); + return undefined; +} + +// Otherwise: continue with local runtime (existing code) +``` + +### Chat Loop Integration + +The existing interactive chat loop in `startEliza()` uses `AgentRuntime` directly. For cloud mode, we need either: + +**Option A**: Make `CloudRuntimeProxy` implement the same interface as `AgentRuntime` (duck typing) +**Option B**: Create a new `startCloudChatLoop()` that uses the proxy directly + +**Recommendation**: Option B — cleaner separation, can show cloud-specific status (connection state, latency, agent status). + +--- + +## 8. Desktop App Integration + +### Current Desktop Onboarding + +The desktop app (Electron/Electrobun) boots with `bootElizaRuntime({ requireConfig: true })` which calls `startEliza({ headless: true })`. Interactive onboarding is skipped — the GUI web UI handles it via the API server. + +### Changes for Cloud Integration + +1. **Web UI onboarding** (in the desktop app's renderer) should present the same runtime choice: + - Cloud (recommended) vs Local + - Uses the same API endpoints + +2. **Web UI auth flow**: Instead of opening an external browser: + - Open the Eliza Cloud login page in an embedded webview or in-app browser window + - Or use the same open-external-browser pattern (simpler, works today) + +3. **API server endpoints** (`src/api/server.ts`) need a new route: + ``` + POST /api/onboarding/cloud-auth → initiates cloudLogin() + GET /api/onboarding/cloud-status → returns auth + provisioning status + POST /api/onboarding/cloud-provision → creates cloud agent + ``` + +4. **Config persistence**: Same `saveMiladyConfig()` path — GUI writes to `~/.milady/milady.json` + +5. **Runtime switch**: After provisioning, GUI triggers a runtime restart (`bootElizaRuntime()` re-reads config, sees cloud.enabled → initializes CloudManager) + +--- + +## 9. Fallback to Local Mode + +### When Cloud Is Unavailable + +``` +Scenario 1: No internet / cloud unreachable + → Pre-flight check: try GET /api/compat/availability + → If fails: "Cloud is currently unavailable. Run locally instead?" + → Falls through to Step 4 (provider selection) + +Scenario 2: No capacity + → availability.acceptingNewAgents === false + → "Cloud is at capacity. Run locally for now?" + → Can switch to cloud later via `milady cloud connect` + +Scenario 3: Auth timeout + → cloudLogin() times out after 5 minutes + → "Login wasn't completed. Try again or run locally?" + +Scenario 4: Provisioning failure + → Agent creation/provisioning fails + → "Cloud setup failed. Run locally instead?" + → Error details logged for debugging + +Scenario 5: User explicitly wants local + → Picks "Run locally" in Step 3.5 + → Normal flow continues +``` + +### Switching Between Modes Later + +```bash +# Switch from local to cloud +milady cloud login # auth with Eliza Cloud +milady cloud connect # provision + connect agent + +# Switch from cloud to local +milady config set cloud.enabled false +milady start # starts in local mode + +# Check current mode +milady cloud status +``` + +--- + +## 10. Code Changes Required + +### In `milady-ai/milady` (the milady repo) + +#### 10.1 Modified: `src/runtime/eliza.ts` — `runFirstTimeSetup()` + +**What**: Insert Step 3.5 (runtime selection) between personality choice and provider selection. + +**Changes**: +- After `styleChoice` (line ~3110), add runtime selection prompt +- If cloud chosen: call new `runCloudOnboarding()` helper +- If cloud chosen: skip Steps 4-7 (wrap them in `if (!isCloudMode)` guard) +- After setup: if cloud mode, store cloud config fields + +**New function** `runCloudOnboarding()`: +```typescript +async function runCloudOnboarding( + clack: ClackModule, + name: string, + chosenTemplate: StylePreset | undefined, +): Promise<{ + apiKey: string; + agentId: string; + bridgeUrl?: string; +} | null> { + // 1. Check availability + // 2. Run cloudLogin() + // 3. Create agent with POST /api/compat/agents + // 4. Poll for running status + // 5. Return { apiKey, agentId } + // Returns null if user cancels or error occurs +} +``` + +**Lines affected**: ~3055–3470 (the entire `runFirstTimeSetup` function) + +#### 10.2 Modified: `src/runtime/eliza.ts` — `startEliza()` + +**What**: After `runFirstTimeSetup()`, check for cloud mode and branch. + +**Changes** (around line ~3610): +```typescript +// After: config = await runFirstTimeSetup(config); +// Before: existing local runtime setup + +if (config.cloud?.enabled && config.cloud?.apiKey) { + const cloudAgentId = (config.cloud as any).agentId; + if (cloudAgentId) { + return startInCloudMode(config, cloudAgentId, opts); + } +} +``` + +**New function** `startInCloudMode()`: +```typescript +async function startInCloudMode( + config: MiladyConfig, + agentId: string, + opts?: StartElizaOptions, +): Promise { + const cloudManager = new CloudManager(config.cloud!); + await cloudManager.init(); + const proxy = await cloudManager.connect(agentId); + + if (opts?.headless || opts?.serverOnly) { + // API server mode: register cloud proxy as the runtime + // Start the HTTP server that the GUI connects to + return startCloudApiServer(proxy, cloudManager, config); + } + + // Interactive CLI mode + return startCloudChatLoop(proxy, cloudManager, config); +} +``` + +#### 10.3 New: `src/runtime/cloud-onboarding.ts` + +**What**: Extracted cloud onboarding logic (keeps `eliza.ts` from growing further). + +**Contains**: +- `checkCloudAvailability(baseUrl)` — pre-flight availability check +- `runCloudAuth(clack, baseUrl)` — wraps `cloudLogin()` with clack spinners/messages +- `provisionCloudAgent(client, agentName, preset)` — creates + waits for running +- `runCloudOnboarding(clack, name, preset)` — orchestrates the above + +**Dependencies**: `src/cloud/auth.ts`, `src/cloud/bridge-client.ts`, `@clack/prompts` + +#### 10.4 New: `src/runtime/cloud-chat-loop.ts` + +**What**: Interactive readline loop for cloud mode (replaces local chat loop). + +**Contains**: +- `startCloudChatLoop(proxy, cloudManager, config)` — readline loop using `CloudRuntimeProxy` +- Shows connection status, latency, cloud agent info in prompt +- Handles reconnection gracefully + +#### 10.5 Modified: `src/config/types.milady.ts` — `CloudConfig` + +**What**: Add `agentId` field. + +```typescript +type CloudConfig = { + // ...existing fields... + /** ID of the cloud agent created during onboarding. */ + agentId?: string; +}; +``` + +#### 10.6 Modified: `src/cli/program/register.setup.ts` + +**What**: Add cloud option to the `milady setup` non-interactive path. + +**Changes**: +- Add `--cloud` flag to setup command +- If `--cloud`: run cloud auth + provisioning flow +- Update `runProviderWizard()` to show cloud as first option + +#### 10.7 New: `src/cli/program/register.cloud.ts` + +**What**: New `milady cloud` subcommand group. + +**Commands**: +``` +milady cloud login — authenticate with Eliza Cloud (runs cloudLogin()) +milady cloud status — show connection status, agent info, capacity +milady cloud connect — provision a new cloud agent or reconnect to existing +milady cloud logout — clear stored API key, disable cloud mode +``` + +#### 10.8 Modified: `src/cloud/bridge-client.ts` + +**What**: Update API paths to use compat routes (or verify V1 routes work). + +The `ElizaCloudClient` currently uses `/api/v1/milady/agents/` paths. For the thin client use case via compat API, either: +- Switch to `/api/compat/agents/` paths, OR +- Verify both route sets map to the same backend service + +**Recommendation**: Keep V1 paths for now (they work with the provisioning infrastructure), but add a `useCompatApi` option for the onboarding flow. + +#### 10.9 Modified: `src/api/server.ts` (Desktop App API) + +**What**: Add onboarding API routes for the GUI to call. + +**New routes**: +``` +POST /api/onboarding/cloud/check-availability +POST /api/onboarding/cloud/start-auth +GET /api/onboarding/cloud/auth-status +POST /api/onboarding/cloud/provision +GET /api/onboarding/cloud/provision-status +``` + +### Summary Table + +| # | File | Change Type | Priority | +|---|------|------------|----------| +| 10.1 | `src/runtime/eliza.ts` (runFirstTimeSetup) | Modify | P0 — core | +| 10.2 | `src/runtime/eliza.ts` (startEliza) | Modify | P0 — core | +| 10.3 | `src/runtime/cloud-onboarding.ts` | New file | P0 — core | +| 10.4 | `src/runtime/cloud-chat-loop.ts` | New file | P1 — UX | +| 10.5 | `src/config/types.milady.ts` | Modify (minor) | P0 — core | +| 10.6 | `src/cli/program/register.setup.ts` | Modify | P1 | +| 10.7 | `src/cli/program/register.cloud.ts` | New file | P1 | +| 10.8 | `src/cloud/bridge-client.ts` | Modify (optional) | P2 | +| 10.9 | `src/api/server.ts` | Modify | P1 — desktop | + +--- + +## 11. New Compat API Endpoints Needed + +### In Eliza Cloud (`elizaOS/cloud`) + +#### 11.1 `POST /api/compat/agents/[id]/launch` — Implementation + +**Status**: Route exists but `launchManagedMiladyAgent` is not implemented. + +**What it should do**: +1. Verify agent belongs to authenticated user +2. If agent status is "pending" → provision it +3. If agent status is "stopped" → restart it +4. Return `{ agentId, agentName, appUrl, connection: { bridgeUrl, apiKey } }` + +**Priority**: P0 — needed for onboarding flow + +#### 11.2 Compat API Route Aliases + +The milady `ElizaCloudClient` uses `/api/v1/milady/agents/` paths. Ensure these are either: +- Aliased to compat routes, OR +- Independently served + +**Current state**: V1 routes exist separately from compat routes. Both work but may have slight differences in auth and response shapes. + +**Recommendation**: Add V1 → compat passthrough or update `ElizaCloudClient` to use compat paths. + +#### 11.3 Character Config Pass-Through + +When milady creates a cloud agent via `POST /api/compat/agents`, the `agentConfig` should include the full character definition (bio, system, style, etc.) so the cloud agent runs with the chosen personality. + +**Current state**: `agentConfig` is stored as JSON in `milady_sandboxes.agent_config`. The cloud provisioning worker passes it to the container. + +**Verify**: That the character config in `agentConfig` is correctly applied when the container starts. The cloud agent's `milady.json` should include the personality data. + +--- + +## 12. Sequence Diagrams + +### Full Cloud Onboarding (Happy Path) + +``` +User milady CLI Eliza Cloud Browser Docker Node + │ │ │ │ │ + │ milady start │ │ │ │ + │──────────────────>│ │ │ │ + │ │ │ │ │ + │ "Name?" │ │ │ │ + │<──────────────────│ │ │ │ + │ "Mochi" │ │ │ │ + │──────────────────>│ │ │ │ + │ │ │ │ │ + │ "Personality?" │ │ │ │ + │<──────────────────│ │ │ │ + │ "uwu~" │ │ │ │ + │──────────────────>│ │ │ │ + │ │ │ │ │ + │ "Where to run?" │ │ │ │ + │<──────────────────│ │ │ │ + │ "☁️ Eliza Cloud" │ │ │ │ + │──────────────────>│ │ │ │ + │ │ │ │ │ + │ │ GET /compat/ │ │ │ + │ │ availability │ │ │ + │ │───────────────────>│ │ │ + │ │ {accepting: true} │ │ │ + │ │<───────────────────│ │ │ + │ │ │ │ │ + │ │ POST /auth/ │ │ │ + │ │ cli-session │ │ │ + │ │───────────────────>│ │ │ + │ │ {sessionId} │ │ │ + │ │<───────────────────│ │ │ + │ │ │ │ │ + │ "Open browser │ │ │ │ + │ to log in" │ │ │ │ + │<──────────────────│ open browser ────────────────────>│ │ + │ │ │ │ │ + │ │ poll... │ User logs in │ │ + │ │ poll... │ (Privy auth) │ │ + │ │───────────────────>│<─────────────────│ │ + │ │ {apiKey: "ec_."} │ │ │ + │ │<───────────────────│ │ │ + │ │ │ │ │ + │ "✓ Logged in!" │ │ │ │ + │<──────────────────│ │ │ │ + │ │ │ │ │ + │ │ POST /compat/ │ │ │ + │ │ agents │ │ │ + │ │ {name, config} │ │ │ + │ │───────────────────>│ │ │ + │ │ {agentId, status} │ provision job──────────────────>│ + │ │<───────────────────│ │ Docker pull │ + │ │ │ │ Container │ + │ "Setting up..." │ poll status... │ │ start... │ + │<──────────────────│───────────────────>│ │ │ + │ │ {status: running} │ │ │ + │ │<───────────────────│<─────────────────────────────────│ + │ │ │ │ │ + │ "☁️ Ready! │ │ │ │ + │ Talk to Mochi" │ │ │ │ + │<──────────────────│ │ │ │ + │ │ │ │ │ + │ "hi mochi~" │ bridge/message │ │ │ + │──────────────────>│───────────────────>│ relay──────────────────────────>│ + │ │<───────────────────│<─────────────────────────────────│ + │ "hi~ :3" │ │ │ │ + │<──────────────────│ │ │ │ +``` + +### Fallback to Local + +``` +User milady CLI Eliza Cloud + │ │ │ + │ picks Cloud │ │ + │──────────────────>│ │ + │ │ GET /availability │ + │ │───────────────────>│ + │ │ {accepting: false}│ + │ │<───────────────────│ + │ │ │ + │ "Cloud is full. │ │ + │ Run locally?" │ │ + │<──────────────────│ │ + │ "Yes" │ │ + │──────────────────>│ │ + │ │ │ + │ "AI provider?" │ (Step 4 - local) │ + │<──────────────────│ │ +``` + +--- + +## 13. Open Questions + +### Must Resolve Before Implementation + +1. **V1 vs Compat routes**: Should `ElizaCloudClient` switch to `/api/compat/agents/` or keep `/api/v1/milady/agents/`? Need to verify both are equivalent or if compat has features V1 lacks. + +2. **Character config propagation**: When `agentConfig` is passed to `POST /api/compat/agents`, does the provisioning worker correctly inject it into the container's `milady.json`? Need to trace through `miladySandboxService.createAgent()` → provisioning worker → container startup. + +3. **`launchManagedMiladyAgent`**: This is referenced in the launch route but not implemented. Is it needed for onboarding, or can we use create + poll + connect? + +4. **Wallet setup for cloud agents**: Should the onboarding skip wallets entirely in cloud mode, or should the user still be able to set up wallets that get passed to the cloud agent via `environmentVars`? + +5. **Desktop app onboarding**: The GUI web UI handles onboarding separately. Who implements the cloud option in the GUI — the milady team or the Eliza Cloud team? Need coordination. + +### Nice to Have (Can Defer) + +6. **Agent migration**: Can a local agent be "uploaded" to cloud later? (snapshot → restore) + +7. **Multi-agent**: What if the user wants multiple cloud agents? The current onboarding assumes one. + +8. **Billing**: Should onboarding show pricing/tier info before the user commits to cloud? + +9. **Offline detection**: If the user starts `milady start` without internet and cloud is configured, should it fail gracefully or fall back to local? + +--- + +## Appendix: File Reference + +### Milady Repo (`milady-ai/milady`) + +| Path | Purpose | +|------|---------| +| `src/runtime/eliza.ts` | Main runtime entry, `runFirstTimeSetup()`, `startEliza()` | +| `src/cli/program/register.setup.ts` | `milady setup` command | +| `src/cli/program/register.start.ts` | `milady start` command (calls `startEliza()`) | +| `src/cli/program/register.config.ts` | `milady config` command | +| `src/onboarding-presets.ts` | `STYLE_PRESETS`, `BIO_POOL`, `SYSTEM_POOL`, `composeCharacter()` | +| `src/runtime/onboarding-names.ts` | `pickRandomNames()` | +| `src/cloud/auth.ts` | `cloudLogin()` — CLI auth session flow | +| `src/cloud/bridge-client.ts` | `ElizaCloudClient` — API client for cloud agents | +| `src/cloud/cloud-manager.ts` | `CloudManager` — orchestrator | +| `src/cloud/cloud-proxy.ts` | `CloudRuntimeProxy` — drop-in runtime replacement | +| `src/cloud/base-url.ts` | URL normalization | +| `src/config/types.milady.ts` | `CloudConfig` type | +| `src/config/config.ts` | `loadMiladyConfig()`, `saveMiladyConfig()` | + +### Eliza Cloud (`elizaOS/cloud` / eliza-cloud-v2) + +| Path | Purpose | +|------|---------| +| `app/api/auth/cli-session/route.ts` | `POST` — create CLI auth session | +| `app/api/auth/cli-session/[sessionId]/route.ts` | `GET` — poll for auth completion | +| `app/api/auth/cli-session/[sessionId]/complete/route.ts` | `POST` — complete auth (web UI) | +| `app/api/compat/agents/route.ts` | `GET`/`POST` — list/create agents | +| `app/api/compat/agents/[id]/route.ts` | `GET`/`DELETE` — get/delete agent | +| `app/api/compat/agents/[id]/status/route.ts` | `GET` — agent status | +| `app/api/compat/agents/[id]/launch/route.ts` | `POST` — launch/provision agent | +| `app/api/compat/availability/route.ts` | `GET` — capacity check | +| `packages/lib/api/compat-envelope.ts` | Response shape utilities | +| `app/api/compat/_lib/auth.ts` | Auth helper (service key / JWT / Privy) | diff --git a/package.json b/package.json index a4ea5043f..fb72e5ba3 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,8 @@ "admin:list": "bun run packages/scripts/promote-admin.ts --list", "admin:revoke": "bun run packages/scripts/promote-admin.ts --revoke", "test:unit": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit", - "test:repo-unit:bulk": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts $(find packages/tests/unit -name '*.test.ts' ! -path 'packages/tests/unit/credits.test.ts' ! -path 'packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts' ! -path 'packages/tests/unit/eliza-app/discord-auth.test.ts' ! -path 'packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts' | sort) packages/lib/services/gateway-discord/__tests__ packages/services/gateway-discord/tests/gateway-manager.test.ts packages/services/gateway-discord/tests/leader-election.test.ts packages/services/gateway-discord/tests/logger.test.ts packages/services/gateway-discord/tests/voice-message-handler.test.ts", - "test:repo-unit:special": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit/credits.test.ts packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts packages/tests/unit/eliza-app/discord-auth.test.ts packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts", + "test:repo-unit:bulk": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts $(find packages/tests/unit -name '*.test.ts' ! -path 'packages/tests/unit/credits.test.ts' ! -path 'packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts' ! -path 'packages/tests/unit/eliza-app/discord-auth.test.ts' ! -path 'packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts' ! -path 'packages/tests/unit/milady-web-ui.test.ts' ! -path 'packages/tests/unit/mcp-twitter-tools.test.ts' ! -path 'packages/tests/unit/affiliates-service.test.ts' ! -path 'packages/tests/unit/proxy-pricing.test.ts' ! -path 'packages/tests/unit/milady-billing-route.test.ts' | sort) packages/lib/services/gateway-discord/__tests__ packages/services/gateway-discord/tests/gateway-manager.test.ts packages/services/gateway-discord/tests/leader-election.test.ts packages/services/gateway-discord/tests/logger.test.ts packages/services/gateway-discord/tests/voice-message-handler.test.ts", + "test:repo-unit:special": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit/credits.test.ts packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts packages/tests/unit/eliza-app/discord-auth.test.ts packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts packages/tests/unit/milady-web-ui.test.ts packages/tests/unit/mcp-twitter-tools.test.ts packages/tests/unit/affiliates-service.test.ts packages/tests/unit/proxy-pricing.test.ts packages/tests/unit/milady-billing-route.test.ts", "test:integration": "bun test --max-concurrency=1 --preload ./packages/tests/e2e/preload.ts packages/tests/integration --timeout 120000", "test:services": "bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts packages/tests/integration/services --timeout 120000", "test:properties": "bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts packages/tests/properties --timeout 300000", @@ -172,7 +172,7 @@ "gray-matter": "^4.0.3", "highlight.js": "^11.11.1", "isomorphic-dompurify": "^2.35.0", - "jose": "^6.1.3", + "jose": "^4.15.0", "json5": "^2.2.3", "libphonenumber-js": "^1.12.35", "lucide-react": "^0.562.0", diff --git a/packages/db/migrations/0052_add_milady_billing_columns.sql b/packages/db/migrations/0052_add_milady_billing_columns.sql new file mode 100644 index 000000000..76685b86a --- /dev/null +++ b/packages/db/migrations/0052_add_milady_billing_columns.sql @@ -0,0 +1,14 @@ +-- Add billing tracking columns to milady_sandboxes table. +-- Mirrors the billing fields on the `containers` table so the new +-- milady-billing cron can track per-agent charges, warnings, and shutdowns. + +ALTER TABLE "milady_sandboxes" + ADD COLUMN IF NOT EXISTS "billing_status" text NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS "last_billed_at" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "hourly_rate" numeric(10,4) DEFAULT '0.0200', + ADD COLUMN IF NOT EXISTS "total_billed" numeric(10,2) NOT NULL DEFAULT '0.00', + ADD COLUMN IF NOT EXISTS "shutdown_warning_sent_at" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "scheduled_shutdown_at" timestamp with time zone; + +CREATE INDEX IF NOT EXISTS "milady_sandboxes_billing_status_idx" + ON "milady_sandboxes" ("billing_status"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index d165bbb09..7f95ffc82 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1773570000000, "tag": "0051_add_milady_pairing_tokens", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1773868800000, + "tag": "0052_add_milady_billing_columns", + "breakpoints": true } ] } diff --git a/packages/db/schemas/milady-sandboxes.ts b/packages/db/schemas/milady-sandboxes.ts index 23810ca0f..27857246e 100644 --- a/packages/db/schemas/milady-sandboxes.ts +++ b/packages/db/schemas/milady-sandboxes.ts @@ -1,5 +1,15 @@ import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; -import { bigint, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + bigint, + index, + integer, + jsonb, + numeric, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; import { organizations } from "./organizations"; import { userCharacters } from "./user-characters"; import { users } from "./users"; @@ -12,6 +22,13 @@ export type MiladySandboxStatus = | "disconnected" | "error"; +export type MiladyBillingStatus = + | "active" + | "warning" + | "suspended" + | "shutdown_pending" + | "exempt"; + export const miladySandboxes = pgTable( "milady_sandboxes", { @@ -55,6 +72,13 @@ export const miladySandboxes = pgTable( web_ui_port: integer("web_ui_port"), headscale_ip: text("headscale_ip"), docker_image: text("docker_image"), + // Billing tracking fields (mirrors containers table pattern) + billing_status: text("billing_status").$type().notNull().default("active"), + last_billed_at: timestamp("last_billed_at", { withTimezone: true }), + hourly_rate: numeric("hourly_rate", { precision: 10, scale: 4 }).default("0.0200"), + total_billed: numeric("total_billed", { precision: 10, scale: 2 }).default("0.00").notNull(), + shutdown_warning_sent_at: timestamp("shutdown_warning_sent_at", { withTimezone: true }), + scheduled_shutdown_at: timestamp("scheduled_shutdown_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, @@ -64,6 +88,7 @@ export const miladySandboxes = pgTable( status_idx: index("milady_sandboxes_status_idx").on(table.status), character_idx: index("milady_sandboxes_character_idx").on(table.character_id), sandbox_id_idx: index("milady_sandboxes_sandbox_id_idx").on(table.sandbox_id), + billing_status_idx: index("milady_sandboxes_billing_status_idx").on(table.billing_status), }), ); diff --git a/packages/lib/analytics/posthog-server.ts b/packages/lib/analytics/posthog-server.ts index 8073ad7e9..0287233ff 100644 --- a/packages/lib/analytics/posthog-server.ts +++ b/packages/lib/analytics/posthog-server.ts @@ -7,7 +7,7 @@ import { PostHog } from "posthog-node"; import { logger } from "@/lib/utils/logger"; -import type { EventProperties, PostHogEvent } from "./posthog"; +import type { AdHocEventProperties, EventProperties, PostHogEvent } from "./posthog"; let posthogClient: PostHog | null = null; @@ -50,6 +50,29 @@ export function trackServerEvent( } } +export function trackCustomServerEvent( + distinctId: string, + event: string, + properties?: AdHocEventProperties, +): void { + const client = getPostHogClient(); + if (!client) return; + + try { + client.capture({ + distinctId, + event, + properties: { + ...properties, + $lib: "posthog-node", + source: "server", + }, + }); + } catch (error) { + logger.error("[PostHog] Failed to track ad-hoc event", { error }); + } +} + export interface ServerUserProperties { email?: string; name?: string; diff --git a/packages/lib/analytics/posthog.ts b/packages/lib/analytics/posthog.ts index bb7372d61..58a20bf93 100644 --- a/packages/lib/analytics/posthog.ts +++ b/packages/lib/analytics/posthog.ts @@ -36,6 +36,10 @@ export type PostHogEvent = | "container_shutdown_insufficient_credits" | "container_shutdown_warning_sent" | "container_daily_billed" + // Milady Agent Billing + | "milady_agent_shutdown_insufficient_credits" + | "milady_agent_shutdown_warning_sent" + | "milady_agent_hourly_billed" // Billing & Credits (Legacy - maintained for backwards compatibility) // Use these for basic credit tracking without payment method details | "credits_purchased" // Simple credit purchase event (use checkout_completed for detailed tracking) @@ -161,6 +165,31 @@ export interface ContainerDailyBilledProps { new_balance: number; } +// Milady Agent Billing +export interface MiladyAgentShutdownInsufficientCreditsProps { + sandbox_id: string; + agent_name: string; + organization_id: string; + balance_at_shutdown: number; +} + +export interface MiladyAgentShutdownWarningSentProps { + sandbox_id: string; + agent_name: string; + organization_id: string; + hourly_cost: number; + current_balance: number; + scheduled_shutdown: string; +} + +export interface MiladyAgentHourlyBilledProps { + sandbox_id: string; + agent_name: string; + organization_id: string; + amount: number; + new_balance: number; +} + export interface PageViewedProps { page_name: string; page_path: string; @@ -373,6 +402,8 @@ export interface LoginCompletedProps { method: AuthMethod; } +export type AdHocEventProperties = Record; + export type EventProperties = | SignupCompletedProps | LoginCompletedProps @@ -386,6 +417,10 @@ export type EventProperties = | ContainerShutdownInsufficientCreditsProps | ContainerShutdownWarningSentProps | ContainerDailyBilledProps + // Milady Agent Billing + | MiladyAgentShutdownInsufficientCreditsProps + | MiladyAgentShutdownWarningSentProps + | MiladyAgentHourlyBilledProps | PageViewedProps | BillingPageViewedProps | CreditsPurchaseStartedProps @@ -454,6 +489,11 @@ export function trackEvent(event: PostHogEvent, properties?: EventProperties): v posthog.capture(event, properties); } +export function trackCustomEvent(event: string, properties?: AdHocEventProperties): void { + if (!isBrowser()) return; + posthog.capture(event, properties); +} + /** * Sanitize error messages before sending to analytics. * Removes stack traces, truncates to 200 chars, and takes only the first line. diff --git a/packages/lib/auth/jwks.ts b/packages/lib/auth/jwks.ts index 144fdf2b7..1fd7dae75 100644 --- a/packages/lib/auth/jwks.ts +++ b/packages/lib/auth/jwks.ts @@ -5,13 +5,7 @@ * Supports key rotation by allowing multiple active keys identified by "kid". */ -import { - exportJWK, - importPKCS8, - importSPKI, - type CryptoKey as JoseCryptoKey, - type JWK, -} from "jose"; +import { exportJWK, importPKCS8, importSPKI, type KeyLike as JoseCryptoKey, type JWK } from "jose"; /** * Environment variables for JWT signing keys. @@ -84,7 +78,7 @@ export async function getPublicKey(): Promise { } const pem = decodePemKey(JWT_SIGNING_PUBLIC_KEY, "PUBLIC"); - cachedPublicKey = await importSPKI(pem, ALGORITHM); + cachedPublicKey = await importSPKI(pem, ALGORITHM, { extractable: true }); return cachedPublicKey; } diff --git a/packages/lib/constants/milady-pricing.ts b/packages/lib/constants/milady-pricing.ts new file mode 100644 index 000000000..dc1fd2fcd --- /dev/null +++ b/packages/lib/constants/milady-pricing.ts @@ -0,0 +1,37 @@ +/** + * Pricing constants for Milady Cloud hosted agents (Docker-based). + * + * These agents run on dedicated Hetzner servers, not AWS ECS. + * Pricing is hourly-based and billed by an hourly cron. + * + * Running agents: $0.02/hour (~$14.40/month) + * Idle/stopped: $0.0025/hour (~$1.80/month — snapshot storage) + * + * All amounts in USD. + */ + +export const MILADY_PRICING = { + // ── Hourly rates ────────────────────────────────────────────────── + /** Cost per hour for a running agent. */ + RUNNING_HOURLY_RATE: 0.02, + /** Cost per hour for an idle/stopped agent (snapshot storage). */ + IDLE_HOURLY_RATE: 0.0025, + + // ── Derived daily rates (for display / logging) ─────────────────── + /** Daily cost for a running agent ($0.48/day). */ + get DAILY_RUNNING_COST(): number { + return Math.round(this.RUNNING_HOURLY_RATE * 24 * 100) / 100; + }, + /** Daily cost for an idle agent ($0.06/day). */ + get DAILY_IDLE_COST(): number { + return Math.round(this.IDLE_HOURLY_RATE * 24 * 100) / 100; + }, + + // ── Thresholds ──────────────────────────────────────────────────── + /** Minimum credit balance required before provisioning an agent. */ + MINIMUM_DEPOSIT: 5.0, + /** Warn user when balance drops below this. */ + LOW_CREDIT_WARNING: 2.0, + /** Hours between warning and forced shutdown. */ + GRACE_PERIOD_HOURS: 48, +} as const; diff --git a/packages/lib/constants/sandbox-status.ts b/packages/lib/constants/sandbox-status.ts new file mode 100644 index 000000000..b319a06d2 --- /dev/null +++ b/packages/lib/constants/sandbox-status.ts @@ -0,0 +1,57 @@ +/** Shared status dot and badge color maps for sandbox/agent status display. */ + +export const STATUS_DOT_COLORS: Record = { + running: "bg-emerald-400", + provisioning: "bg-blue-400 animate-pulse", + pending: "bg-amber-400 animate-pulse", + stopped: "bg-white/30", + disconnected: "bg-orange-400", + error: "bg-red-400", +}; + +export const STATUS_BADGE_COLORS: Record = { + running: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", + provisioning: "bg-blue-500/15 text-blue-400 border-blue-500/25", + pending: "bg-amber-500/15 text-amber-400 border-amber-500/25", + stopped: "bg-white/5 text-white/40 border-white/10", + disconnected: "bg-orange-500/15 text-orange-400 border-orange-500/25", + error: "bg-red-500/15 text-red-400 border-red-500/25", +}; + +export function statusDotColor(status: string): string { + return STATUS_DOT_COLORS[status] ?? "bg-white/30"; +} + +export function statusBadgeColor(status: string): string { + return STATUS_BADGE_COLORS[status] ?? "bg-white/5 text-white/40 border-white/10"; +} + +/** Format a date into a human-readable relative time string. */ +export function formatRelative(date: Date | string | null): string { + if (!date) return "Never"; + const d = new Date(date); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "Just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + if (diffD < 7) return `${diffD}d ago`; + return d.toLocaleDateString(); +} + +/** Shorter format used in compact views (omits "ago" for brevity). */ +export function formatRelativeShort(date: Date | string | null): string { + if (!date) return "—"; + const d = new Date(date); + const diffMs = Date.now() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "Just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + if (diffD < 7) return `${diffD}d ago`; + return d.toLocaleDateString(); +} diff --git a/packages/lib/email/utils/template-renderer.ts b/packages/lib/email/utils/template-renderer.ts index df17082e5..05a58a393 100644 --- a/packages/lib/email/utils/template-renderer.ts +++ b/packages/lib/email/utils/template-renderer.ts @@ -4,6 +4,7 @@ import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import type { AutoTopUpDisabledEmailData, AutoTopUpSuccessEmailData, @@ -21,7 +22,10 @@ import type { * @returns Template content as string. */ function loadTemplate(filename: string): string { - const templatePath = path.join(process.cwd(), "lib", "email", "templates", filename); + // Use path relative to this file (utils/ → ../templates/) for reliable resolution + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const templatePath = path.resolve(__dirname, "..", "templates", filename); return fs.readFileSync(templatePath, "utf-8"); } diff --git a/packages/lib/hooks/use-sandbox-status-poll.ts b/packages/lib/hooks/use-sandbox-status-poll.ts new file mode 100644 index 000000000..65c7fdac8 --- /dev/null +++ b/packages/lib/hooks/use-sandbox-status-poll.ts @@ -0,0 +1,259 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export type SandboxStatus = + | "pending" + | "provisioning" + | "running" + | "stopped" + | "disconnected" + | "error"; + +export interface SandboxStatusResult { + status: SandboxStatus; + lastHeartbeat: string | null; + error: string | null; + isLoading: boolean; +} + +const TERMINAL_STATES = new Set(["running", "stopped", "error"]); +const ACTIVE_STATES = new Set(["pending", "provisioning"]); +const MAX_CONSECUTIVE_ERRORS = 5; + +/** + * Polls a single agent's status while it's in a non-terminal state. + * Stops automatically when the agent reaches "running", "stopped", or "error". + */ +export function useSandboxStatusPoll( + agentId: string | null, + options: { + intervalMs?: number; + enabled?: boolean; + } = {}, +) { + const { intervalMs = 5_000, enabled = true } = options; + const [result, setResult] = useState({ + status: "pending", + lastHeartbeat: null, + error: null, + isLoading: false, + }); + + const cancelledRef = useRef(false); + const intervalRef = useRef | null>(null); + const statusRef = useRef("pending"); + const consecutiveErrorsRef = useRef(0); + + const cleanup = useCallback(() => { + cancelledRef.current = true; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + if (!agentId || !enabled) { + cleanup(); + return; + } + + cancelledRef.current = false; + consecutiveErrorsRef.current = 0; + + const poll = async () => { + if (cancelledRef.current) return; + if (TERMINAL_STATES.has(statusRef.current)) { + cleanup(); + return; + } + + setResult((prev) => ({ ...prev, isLoading: true })); + + try { + const res = await fetch(`/api/v1/milady/agents/${agentId}`); + if (cancelledRef.current) return; + + if (!res.ok) { + consecutiveErrorsRef.current++; + setResult((prev) => ({ + ...prev, + isLoading: false, + error: `HTTP ${res.status}`, + })); + // Stop polling on persistent client errors (4xx) or too many consecutive failures + if ( + (res.status >= 400 && res.status < 500) || + consecutiveErrorsRef.current >= MAX_CONSECUTIVE_ERRORS + ) { + cleanup(); + } + return; + } + + // Reset error counter on success + consecutiveErrorsRef.current = 0; + + const json = await res.json(); + const data = json?.data; + if (!data) return; + + const newStatus = (data.status as SandboxStatus) ?? "pending"; + statusRef.current = newStatus; + + setResult({ + status: newStatus, + lastHeartbeat: data.lastHeartbeatAt ?? null, + error: data.errorMessage ?? null, + isLoading: false, + }); + + // Stop polling once we've reached a terminal state + if (TERMINAL_STATES.has(newStatus)) { + cleanup(); + } + } catch { + if (!cancelledRef.current) { + consecutiveErrorsRef.current++; + setResult((prev) => ({ ...prev, isLoading: false })); + if (consecutiveErrorsRef.current >= MAX_CONSECUTIVE_ERRORS) { + cleanup(); + } + } + } + }; + + // Initial poll + void poll(); + + // Set up interval + intervalRef.current = setInterval(() => void poll(), intervalMs); + + return cleanup; + }, [agentId, enabled, intervalMs, cleanup]); + + return result; +} + +/** Raw agent shape returned by the list endpoint (camelCase). */ +export interface SandboxListAgent { + id: string; + status: string; + agentName?: string; + agent_name?: string; + databaseStatus?: string; + errorMessage?: string; + lastHeartbeatAt?: string | null; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + +/** + * Polls the agent list endpoint while any sandbox is in an active state. + * Fires `onTransitionToRunning` on status transitions and pushes the full + * agent list via `onDataRefresh` so the parent can update local state without + * a full page reload. + */ +export function useSandboxListPoll( + sandboxes: Array<{ id: string; status: string }>, + options: { + intervalMs?: number; + onTransitionToRunning?: (agentId: string, agentName?: string) => void; + /** Called on every successful poll with the full agent list from the API. */ + onDataRefresh?: (agents: SandboxListAgent[]) => void; + } = {}, +) { + const { intervalMs = 10_000, onTransitionToRunning, onDataRefresh } = options; + const [isPolling, setIsPolling] = useState(false); + const previousStatusesRef = useRef>(new Map()); + const callbackRef = useRef(onTransitionToRunning); + const dataRefreshRef = useRef(onDataRefresh); + const intervalRef = useRef | null>(null); + + useEffect(() => { + callbackRef.current = onTransitionToRunning; + }, [onTransitionToRunning]); + + useEffect(() => { + dataRefreshRef.current = onDataRefresh; + }, [onDataRefresh]); + + // Initialize previousStatusesRef from props — only seed new IDs, preserve poll-derived statuses + useEffect(() => { + const statusMap = new Map(); + for (const sb of sandboxes) { + // Only set if not already tracked (preserve poll-derived statuses) + if (!previousStatusesRef.current.has(sb.id)) { + statusMap.set(sb.id, sb.status); + } else { + statusMap.set(sb.id, previousStatusesRef.current.get(sb.id)!); + } + } + previousStatusesRef.current = statusMap; + }, [sandboxes]); + + const hasActiveAgents = sandboxes.some((sb) => ACTIVE_STATES.has(sb.status as SandboxStatus)); + + useEffect(() => { + if (!hasActiveAgents) { + setIsPolling(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + setIsPolling(true); + let cancelled = false; + + const poll = async () => { + if (cancelled) return; + + try { + const res = await fetch("/api/v1/milady/agents"); + if (cancelled || !res.ok) return; + + const json = await res.json(); + const agents: SandboxListAgent[] = json?.data ?? []; + + // Push full list to parent for local state merge + dataRefreshRef.current?.(agents); + + for (const agent of agents) { + const prevStatus = previousStatusesRef.current.get(agent.id); + const newStatus = agent.status; + + if ( + prevStatus && + ACTIVE_STATES.has(prevStatus as SandboxStatus) && + newStatus === "running" + ) { + callbackRef.current?.(agent.id, agent.agentName ?? agent.agent_name); + } + + previousStatusesRef.current.set(agent.id, newStatus); + } + } catch { + // Silently retry on next interval + } + }; + + // Fire initial poll immediately (don't wait for first interval) + void poll(); + + intervalRef.current = setInterval(() => void poll(), intervalMs); + + return () => { + cancelled = true; + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [hasActiveAgents, intervalMs]); + + return { isPolling }; +} diff --git a/packages/lib/services/admin-infrastructure.ts b/packages/lib/services/admin-infrastructure.ts new file mode 100644 index 000000000..94929b252 --- /dev/null +++ b/packages/lib/services/admin-infrastructure.ts @@ -0,0 +1,962 @@ +import { asc } from "drizzle-orm"; +import { dbRead } from "@/db/helpers"; +import { dockerNodesRepository } from "@/db/repositories/docker-nodes"; +import type { DockerNodeStatus } from "@/db/schemas/docker-nodes"; +import { type MiladySandboxStatus, miladySandboxes } from "@/db/schemas/milady-sandboxes"; +import { DockerSSHClient } from "@/lib/services/docker-ssh"; +import { logger } from "@/lib/utils/logger"; + +const HEARTBEAT_WARNING_MINUTES = 5; +const HEARTBEAT_STALE_MINUTES = 15; +const NODE_SATURATION_WARNING_PCT = 85; +const NODE_SATURATION_CRITICAL_PCT = 100; +const NODE_RESOURCE_WARNING_PCT = 85; +const NODE_RESOURCE_CRITICAL_PCT = 95; +const SSH_CONNECT_TIMEOUT_MS = 10_000; +const SSH_COMMAND_TIMEOUT_MS = 15_000; +const NODE_INSPECTION_TIMEOUT_MS = 25_000; +const MAX_CONCURRENT_SSH_SESSIONS = 5; +const SNAPSHOT_CACHE_TTL_MS = 30_000; + +/** Simple concurrency limiter — runs at most `limit` tasks in parallel. */ +async function pLimit(tasks: Array<() => Promise>, limit: number): Promise { + const results: T[] = new Array(tasks.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < tasks.length) { + const i = nextIndex++; + results[i] = await tasks[i](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +/** Wraps a promise with a timeout — rejects with an error if the deadline expires. */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +/** + * Best-effort in-process cache for repeated admin polling on the same warm instance. + * Serverless cold starts will begin with an empty cache. + */ +let snapshotCache: { data: AdminInfrastructureSnapshot; expiresAt: number } | null = null; + +type IncidentSeverity = "critical" | "warning" | "info"; +type IncidentScope = "cluster" | "node" | "container"; + +export type ContainerLiveHealthStatus = + | "healthy" + | "warming" + | "degraded" + | "stale" + | "missing" + | "failed" + | "stopped"; + +export interface ContainerHealthAssessment { + status: ContainerLiveHealthStatus; + severity: IncidentSeverity; + reason: string; +} + +interface RuntimeContainerRecord { + name: string; + id: string; + image: string | null; + state: string; + status: string; + runningFor: string | null; + health: "healthy" | "unhealthy" | "starting" | null; +} + +interface NodeRuntimeSnapshot { + reachable: boolean; + checkedAt: string; + sshLatencyMs: number | null; + dockerVersion: string | null; + diskUsedPercent: number | null; + memoryUsedPercent: number | null; + loadAverage: string | null; + actualContainerCount: number; + runningContainerCount: number; + containers: RuntimeContainerRecord[]; + error: string | null; +} + +export interface AdminInfrastructureContainer { + id: string; + sandboxId: string | null; + agentName: string | null; + organizationId: string | null; + userId: string | null; + nodeId: string | null; + containerName: string | null; + dbStatus: MiladySandboxStatus; + liveHealth: ContainerLiveHealthStatus; + liveHealthSeverity: IncidentSeverity; + liveHealthReason: string; + runtimeState: string | null; + runtimeStatus: string | null; + runtimePresent: boolean; + dockerImage: string | null; + bridgePort: number | null; + webUiPort: number | null; + headscaleIp: string | null; + bridgeUrl: string | null; + healthUrl: string | null; + lastHeartbeatAt: string | null; + heartbeatAgeMinutes: number | null; + errorMessage: string | null; + errorCount: number; + createdAt: string; + updatedAt: string; +} + +export interface AdminInfrastructureNode { + id: string; + nodeId: string; + hostname: string; + sshPort: number; + sshUser: string; + capacity: number; + allocatedCount: number; + availableSlots: number; + enabled: boolean; + status: DockerNodeStatus; + lastHealthCheck: string | null; + utilizationPct: number; + runtime: NodeRuntimeSnapshot; + allocationDrift: number; + alerts: string[]; + containers: AdminInfrastructureContainer[]; + ghostContainers: Array<{ + name: string; + state: string; + status: string; + }>; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +export interface AdminInfrastructureIncident { + severity: IncidentSeverity; + scope: IncidentScope; + title: string; + detail: string; + nodeId?: string; + containerId?: string; +} + +export interface AdminInfrastructureSummary { + totalNodes: number; + enabledNodes: number; + healthyNodes: number; + degradedNodes: number; + offlineNodes: number; + unknownNodes: number; + totalCapacity: number; + allocatedSlots: number; + availableSlots: number; + utilizationPct: number; + saturatedNodes: number; + nodesWithDrift: number; + totalContainers: number; + runningContainers: number; + pendingContainers: number; + provisioningContainers: number; + stoppedContainers: number; + errorContainers: number; + disconnectedContainers: number; + healthyContainers: number; + attentionContainers: number; + staleContainers: number; + missingContainers: number; + failedContainers: number; + backlogCount: number; +} + +export interface AdminInfrastructureSnapshot { + refreshedAt: string; + summary: AdminInfrastructureSummary; + incidents: AdminInfrastructureIncident[]; + nodes: AdminInfrastructureNode[]; + containers: AdminInfrastructureContainer[]; +} + +function toIso(value: Date | string | null | undefined): string | null { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function parsePercent(value: string): number | null { + const parsed = Number.parseInt(value.replace(/%/g, "").trim(), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseMemoryPercent(value: string): number | null { + const [usedRaw, totalRaw] = value.split("|"); + const used = Number.parseInt(usedRaw ?? "", 10); + const total = Number.parseInt(totalRaw ?? "", 10); + + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) { + return null; + } + + return Math.round((used / total) * 100); +} + +function parseRuntimeContainers(output: string): RuntimeContainerRecord[] { + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [name = "", id = "", image = "", state = "", status = "", runningFor = ""] = + line.split("|"); + return { + name, + id, + image: image || null, + state: state.toLowerCase(), + status, + runningFor: runningFor || null, + health: parseDockerHealth(status), + } satisfies RuntimeContainerRecord; + }); +} + +function parseDockerHealth(status: string): RuntimeContainerRecord["health"] { + // Strip the common "health: " prefix from Docker status strings + const normalized = status + .toLowerCase() + .replace(/^.*health:\s*/, "") + .trim(); + if (normalized === "unhealthy" || normalized.startsWith("unhealthy")) return "unhealthy"; + if (normalized === "healthy" || normalized.startsWith("healthy")) return "healthy"; + if (normalized === "starting" || normalized.startsWith("starting")) return "starting"; + // Fallback: check the full string for keywords (handles non-standard formats) + const full = status.toLowerCase(); + if (full.includes("unhealthy")) return "unhealthy"; + if (full.includes("healthy")) return "healthy"; + if (full.includes("starting")) return "starting"; + return null; +} + +function getHeartbeatAgeMinutes(lastHeartbeatAt: string | null): number | null { + if (!lastHeartbeatAt) return null; + const parsed = new Date(lastHeartbeatAt).getTime(); + if (Number.isNaN(parsed)) return null; + return Math.max(0, Math.round((Date.now() - parsed) / 60_000)); +} + +function buildResourceAlert(label: string, percent: number | null): string | null { + if (percent === null) return null; + if (percent >= NODE_RESOURCE_CRITICAL_PCT) return `${label} critical: ${percent}% used`; + if (percent >= NODE_RESOURCE_WARNING_PCT) return `${label} warning: ${percent}% used`; + return null; +} + +function sortIncidents(a: AdminInfrastructureIncident, b: AdminInfrastructureIncident): number { + const severityWeight: Record = { + critical: 0, + warning: 1, + info: 2, + }; + + return severityWeight[a.severity] - severityWeight[b.severity] || a.title.localeCompare(b.title); +} + +export function classifyContainerHealth(params: { + dbStatus: MiladySandboxStatus; + runtime: RuntimeContainerRecord | null; + lastHeartbeatAt: string | null; + errorMessage: string | null; +}): ContainerHealthAssessment { + const heartbeatAgeMinutes = getHeartbeatAgeMinutes(params.lastHeartbeatAt); + const runtime = params.runtime; + + if (params.dbStatus === "error") { + return { + status: "failed", + severity: "critical", + reason: params.errorMessage || "Provisioning or runtime error recorded in control plane", + }; + } + + if (params.dbStatus === "stopped" && !runtime) { + return { + status: "stopped", + severity: "info", + reason: "Container is intentionally stopped", + }; + } + + if (!runtime) { + if (params.dbStatus === "pending" || params.dbStatus === "provisioning") { + return { + status: "warming", + severity: "info", + reason: "Container is not on a node yet", + }; + } + + return { + status: "missing", + severity: "critical", + reason: "Database record exists but container is missing from the node", + }; + } + + if (params.dbStatus === "stopped") { + return { + status: "degraded", + severity: "warning", + reason: "Control plane says stopped but container still exists on the node", + }; + } + + if (runtime.state === "dead" || runtime.state === "exited") { + return { + status: "failed", + severity: "critical", + reason: runtime.status || "Container exited unexpectedly", + }; + } + + if (runtime.state === "restarting") { + return { + status: "degraded", + severity: "warning", + reason: runtime.status || "Container is restarting", + }; + } + + if (runtime.state === "created") { + return { + status: "warming", + severity: "info", + reason: "Container exists but has not started yet", + }; + } + + if (runtime.health === "unhealthy") { + return { + status: "failed", + severity: "critical", + reason: runtime.status || "Docker health check reports unhealthy", + }; + } + + if (runtime.health === "starting") { + return { + status: "warming", + severity: "info", + reason: runtime.status || "Docker health check is still warming up", + }; + } + + if (params.dbStatus === "pending" || params.dbStatus === "provisioning") { + return { + status: "warming", + severity: "info", + reason: "Provisioning is still in progress", + }; + } + + if (params.dbStatus === "disconnected") { + return { + status: "degraded", + severity: "warning", + reason: "Container is running but marked disconnected", + }; + } + + if (heartbeatAgeMinutes === null) { + return { + status: "degraded", + severity: "warning", + reason: "No heartbeat has been recorded yet", + }; + } + + if (heartbeatAgeMinutes >= HEARTBEAT_STALE_MINUTES) { + return { + status: "stale", + severity: "critical", + reason: `Heartbeat is ${heartbeatAgeMinutes}m old`, + }; + } + + if (heartbeatAgeMinutes >= HEARTBEAT_WARNING_MINUTES) { + return { + status: "degraded", + severity: "warning", + reason: `Heartbeat is delayed (${heartbeatAgeMinutes}m old)`, + }; + } + + return { + status: "healthy", + severity: "info", + reason: runtime.status || "Container is running normally", + }; +} + +async function inspectNodeRuntime(node: { + node_id: string; + hostname: string; + ssh_port: number; + ssh_user: string; + host_key_fingerprint: string | null; +}): Promise { + const checkedAt = new Date().toISOString(); + const ssh = new DockerSSHClient({ + hostname: node.hostname, + port: node.ssh_port, + username: node.ssh_user, + hostKeyFingerprint: node.host_key_fingerprint ?? undefined, + }); + + try { + const sshStart = Date.now(); + await ssh.exec("echo ok", SSH_CONNECT_TIMEOUT_MS); + const sshLatencyMs = Date.now() - sshStart; + + const [dockerVersionRaw, diskRaw, memoryRaw, loadAverageRaw, containersRaw] = await Promise.all( + [ + ssh.exec("docker version --format '{{.Server.Version}}'", SSH_COMMAND_TIMEOUT_MS), + ssh.exec("df -P / | tail -1 | awk '{print $5}'", SSH_COMMAND_TIMEOUT_MS), + ssh.exec("free -b | awk '/Mem:/ {print $3\"|\"$2}'", SSH_COMMAND_TIMEOUT_MS), + ssh.exec("cut -d' ' -f1-3 /proc/loadavg", SSH_COMMAND_TIMEOUT_MS), + ssh.exec( + "docker ps -a --filter name=milady- --format '{{.Names}}|{{.ID}}|{{.Image}}|{{.State}}|{{.Status}}|{{.RunningFor}}' 2>/dev/null || true", + SSH_COMMAND_TIMEOUT_MS, + ), + ], + ); + + const containers = parseRuntimeContainers(containersRaw); + + return { + reachable: true, + checkedAt, + sshLatencyMs, + dockerVersion: dockerVersionRaw.trim() || null, + diskUsedPercent: parsePercent(diskRaw), + memoryUsedPercent: parseMemoryPercent(memoryRaw.trim()), + loadAverage: loadAverageRaw.trim() || null, + actualContainerCount: containers.length, + runningContainerCount: containers.filter((container) => container.state === "running").length, + containers, + error: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn("[admin-infrastructure] Failed to inspect node runtime", { + nodeId: node.node_id, + error: message, + }); + + return { + reachable: false, + checkedAt, + sshLatencyMs: null, + dockerVersion: null, + diskUsedPercent: null, + memoryUsedPercent: null, + loadAverage: null, + actualContainerCount: 0, + runningContainerCount: 0, + containers: [], + error: message, + }; + } finally { + try { + await ssh.disconnect(); + } catch { + // ignore cleanup failures + } + } +} + +function buildNodeAlerts(params: { + node: Awaited>[number]; + runtime: NodeRuntimeSnapshot; + allocationDrift: number; + unhealthyContainerCount: number; +}): string[] { + const alerts: string[] = []; + const { node, runtime, allocationDrift, unhealthyContainerCount } = params; + + if (!node.enabled) { + alerts.push("Node is disabled for new allocations"); + } + + if (!runtime.reachable) { + alerts.push("Live SSH inspection failed"); + return alerts; + } + + const saturation = + node.capacity > 0 ? Math.round((node.allocated_count / node.capacity) * 100) : 0; + if (saturation >= NODE_SATURATION_CRITICAL_PCT) { + alerts.push(`Capacity exhausted (${saturation}% allocated)`); + } else if (saturation >= NODE_SATURATION_WARNING_PCT) { + alerts.push(`Capacity nearly full (${saturation}% allocated)`); + } + + const diskAlert = buildResourceAlert("Disk", runtime.diskUsedPercent); + if (diskAlert) alerts.push(diskAlert); + + const memoryAlert = buildResourceAlert("Memory", runtime.memoryUsedPercent); + if (memoryAlert) alerts.push(memoryAlert); + + if (allocationDrift !== 0) { + const driftDirection = allocationDrift > 0 ? `+${allocationDrift}` : `${allocationDrift}`; + alerts.push(`Allocation drift ${driftDirection} vs control plane`); + } + + if (unhealthyContainerCount > 0) { + alerts.push( + `${unhealthyContainerCount} container${unhealthyContainerCount === 1 ? "" : "s"} need attention`, + ); + } + + return alerts; +} + +export async function getAdminInfrastructureSnapshot(): Promise { + if (snapshotCache && Date.now() < snapshotCache.expiresAt) { + return snapshotCache.data; + } + + const refreshedAt = new Date().toISOString(); + + const [nodes, sandboxRows] = await Promise.all([ + dockerNodesRepository.findAll(), + dbRead + .select({ + id: miladySandboxes.id, + sandboxId: miladySandboxes.sandbox_id, + organizationId: miladySandboxes.organization_id, + userId: miladySandboxes.user_id, + agentName: miladySandboxes.agent_name, + status: miladySandboxes.status, + nodeId: miladySandboxes.node_id, + containerName: miladySandboxes.container_name, + bridgePort: miladySandboxes.bridge_port, + webUiPort: miladySandboxes.web_ui_port, + headscaleIp: miladySandboxes.headscale_ip, + dockerImage: miladySandboxes.docker_image, + bridgeUrl: miladySandboxes.bridge_url, + healthUrl: miladySandboxes.health_url, + lastHeartbeatAt: miladySandboxes.last_heartbeat_at, + errorMessage: miladySandboxes.error_message, + errorCount: miladySandboxes.error_count, + createdAt: miladySandboxes.created_at, + updatedAt: miladySandboxes.updated_at, + }) + .from(miladySandboxes) + .orderBy(asc(miladySandboxes.created_at)), + ]); + + const sandboxesByNode = new Map(); + const unassignedSandboxRows = [] as typeof sandboxRows; + + for (const row of sandboxRows) { + if (!row.nodeId) { + unassignedSandboxRows.push(row); + continue; + } + + const existing = sandboxesByNode.get(row.nodeId) ?? []; + existing.push(row); + sandboxesByNode.set(row.nodeId, existing); + } + + const inspectedNodes = await pLimit( + nodes.map((node) => async () => { + const dbContainers = sandboxesByNode.get(node.node_id) ?? []; + const runtime = await withTimeout( + inspectNodeRuntime(node), + NODE_INSPECTION_TIMEOUT_MS, + `inspectNodeRuntime(${node.node_id})`, + ).catch((error): NodeRuntimeSnapshot => { + const message = error instanceof Error ? error.message : String(error); + logger.warn("[admin-infrastructure] Node inspection timed out", { + nodeId: node.node_id, + error: message, + }); + return { + reachable: false, + checkedAt: new Date().toISOString(), + sshLatencyMs: null, + dockerVersion: null, + diskUsedPercent: null, + memoryUsedPercent: null, + loadAverage: null, + actualContainerCount: 0, + runningContainerCount: 0, + containers: [], + error: message, + }; + }); + const runtimeByName = new Map( + runtime.containers.map((container) => [container.name, container]), + ); + + const containers: AdminInfrastructureContainer[] = dbContainers.map((container) => { + const runtimeMatch = container.containerName + ? (runtimeByName.get(container.containerName) ?? null) + : null; + const health = classifyContainerHealth({ + dbStatus: container.status, + runtime: runtimeMatch, + lastHeartbeatAt: toIso(container.lastHeartbeatAt), + errorMessage: container.errorMessage, + }); + + return { + id: container.id, + sandboxId: container.sandboxId, + agentName: container.agentName, + organizationId: container.organizationId, + userId: container.userId, + nodeId: container.nodeId, + containerName: container.containerName, + dbStatus: container.status, + liveHealth: health.status, + liveHealthSeverity: health.severity, + liveHealthReason: health.reason, + runtimeState: runtimeMatch?.state ?? null, + runtimeStatus: runtimeMatch?.status ?? null, + runtimePresent: !!runtimeMatch, + dockerImage: container.dockerImage ?? runtimeMatch?.image ?? null, + bridgePort: container.bridgePort, + webUiPort: container.webUiPort, + headscaleIp: container.headscaleIp, + bridgeUrl: container.bridgeUrl, + healthUrl: container.healthUrl, + lastHeartbeatAt: toIso(container.lastHeartbeatAt), + heartbeatAgeMinutes: getHeartbeatAgeMinutes(toIso(container.lastHeartbeatAt)), + errorMessage: container.errorMessage, + errorCount: container.errorCount ?? 0, + createdAt: toIso(container.createdAt) ?? refreshedAt, + updatedAt: toIso(container.updatedAt) ?? refreshedAt, + }; + }); + + const trackedContainerNames = new Set( + containers + .map((container) => container.containerName) + .filter((value): value is string => Boolean(value)), + ); + + const ghostContainers = runtime.containers + .filter((container) => !trackedContainerNames.has(container.name)) + .map((container) => ({ + name: container.name, + state: container.state, + status: container.status, + })); + + const unhealthyContainerCount = containers.filter( + (container) => + container.liveHealth !== "healthy" && + container.liveHealth !== "warming" && + container.liveHealth !== "stopped", + ).length; + + const allocationDrift = runtime.reachable + ? runtime.actualContainerCount - node.allocated_count + : 0; + + return { + id: node.id, + nodeId: node.node_id, + hostname: node.hostname, + sshPort: node.ssh_port, + sshUser: node.ssh_user, + capacity: node.capacity, + allocatedCount: node.allocated_count, + availableSlots: Math.max(0, node.capacity - node.allocated_count), + enabled: node.enabled, + status: node.status, + lastHealthCheck: toIso(node.last_health_check), + utilizationPct: + node.capacity > 0 ? Math.round((node.allocated_count / node.capacity) * 100) : 0, + runtime, + allocationDrift, + alerts: buildNodeAlerts({ node, runtime, allocationDrift, unhealthyContainerCount }), + containers, + ghostContainers, + metadata: node.metadata, + createdAt: toIso(node.created_at) ?? refreshedAt, + updatedAt: toIso(node.updated_at) ?? refreshedAt, + } satisfies AdminInfrastructureNode; + }), + MAX_CONCURRENT_SSH_SESSIONS, + ); + + const unassignedContainers: AdminInfrastructureContainer[] = unassignedSandboxRows.map( + (container) => { + const health = classifyContainerHealth({ + dbStatus: container.status, + runtime: null, + lastHeartbeatAt: toIso(container.lastHeartbeatAt), + errorMessage: container.errorMessage, + }); + + return { + id: container.id, + sandboxId: container.sandboxId, + agentName: container.agentName, + organizationId: container.organizationId, + userId: container.userId, + nodeId: null, + containerName: container.containerName, + dbStatus: container.status, + liveHealth: health.status, + liveHealthSeverity: health.severity, + liveHealthReason: health.reason, + runtimeState: null, + runtimeStatus: null, + runtimePresent: false, + dockerImage: container.dockerImage, + bridgePort: container.bridgePort, + webUiPort: container.webUiPort, + headscaleIp: container.headscaleIp, + bridgeUrl: container.bridgeUrl, + healthUrl: container.healthUrl, + lastHeartbeatAt: toIso(container.lastHeartbeatAt), + heartbeatAgeMinutes: getHeartbeatAgeMinutes(toIso(container.lastHeartbeatAt)), + errorMessage: container.errorMessage, + errorCount: container.errorCount ?? 0, + createdAt: toIso(container.createdAt) ?? refreshedAt, + updatedAt: toIso(container.updatedAt) ?? refreshedAt, + }; + }, + ); + + const containers = [ + ...inspectedNodes.flatMap((node) => node.containers), + ...unassignedContainers, + ]; + const incidents: AdminInfrastructureIncident[] = []; + + for (const node of inspectedNodes) { + if (!node.enabled) { + incidents.push({ + severity: "info", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} disabled`, + detail: "Node is excluded from new allocations", + }); + } + + if (!node.runtime.reachable) { + incidents.push({ + severity: "critical", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} unreachable`, + detail: node.runtime.error || "Live SSH inspection failed", + }); + continue; + } + + if (node.utilizationPct >= NODE_SATURATION_CRITICAL_PCT) { + incidents.push({ + severity: "critical", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} at capacity`, + detail: `${node.allocatedCount}/${node.capacity} slots allocated`, + }); + } else if (node.utilizationPct >= NODE_SATURATION_WARNING_PCT) { + incidents.push({ + severity: "warning", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} nearing capacity`, + detail: `${node.allocatedCount}/${node.capacity} slots allocated`, + }); + } + + if (node.allocationDrift !== 0) { + incidents.push({ + severity: Math.abs(node.allocationDrift) >= 2 ? "critical" : "warning", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} allocation drift`, + detail: `Control plane differs from runtime by ${node.allocationDrift > 0 ? `+${node.allocationDrift}` : node.allocationDrift} container(s)`, + }); + } + + if ( + node.runtime.diskUsedPercent !== null && + node.runtime.diskUsedPercent >= NODE_RESOURCE_CRITICAL_PCT + ) { + incidents.push({ + severity: "critical", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} disk pressure`, + detail: `Disk usage at ${node.runtime.diskUsedPercent}%`, + }); + } + + if ( + node.runtime.memoryUsedPercent !== null && + node.runtime.memoryUsedPercent >= NODE_RESOURCE_CRITICAL_PCT + ) { + incidents.push({ + severity: "critical", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} memory pressure`, + detail: `Memory usage at ${node.runtime.memoryUsedPercent}%`, + }); + } + + for (const ghost of node.ghostContainers) { + incidents.push({ + severity: "warning", + scope: "node", + nodeId: node.nodeId, + title: `${node.nodeId} has ghost container`, + detail: `${ghost.name} is running on the node but not tracked in the control plane`, + }); + } + } + + for (const container of containers) { + if ( + container.liveHealth === "healthy" || + container.liveHealth === "warming" || + container.liveHealth === "stopped" + ) { + continue; + } + + incidents.push({ + severity: container.liveHealthSeverity, + scope: "container", + nodeId: container.nodeId ?? undefined, + containerId: container.id, + title: `${container.agentName || container.containerName || container.id.slice(0, 8)} ${container.liveHealth}`, + detail: container.liveHealthReason, + }); + } + + const enabledNodes = inspectedNodes.filter((node) => node.enabled); + const totalCapacity = enabledNodes.reduce((sum, node) => sum + node.capacity, 0); + const allocatedSlots = enabledNodes.reduce((sum, node) => sum + node.allocatedCount, 0); + const availableSlots = enabledNodes.reduce((sum, node) => sum + node.availableSlots, 0); + + const summary: AdminInfrastructureSummary = { + totalNodes: inspectedNodes.length, + enabledNodes: enabledNodes.length, + healthyNodes: enabledNodes.filter((node) => node.status === "healthy").length, + degradedNodes: enabledNodes.filter((node) => node.status === "degraded").length, + offlineNodes: enabledNodes.filter((node) => node.status === "offline").length, + unknownNodes: enabledNodes.filter((node) => node.status === "unknown").length, + totalCapacity, + allocatedSlots, + availableSlots, + utilizationPct: totalCapacity > 0 ? Math.round((allocatedSlots / totalCapacity) * 100) : 0, + saturatedNodes: enabledNodes.filter( + (node) => node.utilizationPct >= NODE_SATURATION_WARNING_PCT, + ).length, + nodesWithDrift: inspectedNodes.filter((node) => node.allocationDrift !== 0).length, + totalContainers: containers.length, + runningContainers: containers.filter((container) => container.dbStatus === "running").length, + pendingContainers: containers.filter((container) => container.dbStatus === "pending").length, + provisioningContainers: containers.filter((container) => container.dbStatus === "provisioning") + .length, + stoppedContainers: containers.filter((container) => container.dbStatus === "stopped").length, + errorContainers: containers.filter((container) => container.dbStatus === "error").length, + disconnectedContainers: containers.filter((container) => container.dbStatus === "disconnected") + .length, + healthyContainers: containers.filter((container) => container.liveHealth === "healthy").length, + attentionContainers: containers.filter( + (container) => + container.liveHealth !== "healthy" && + container.liveHealth !== "warming" && + container.liveHealth !== "stopped", + ).length, + staleContainers: containers.filter((container) => container.liveHealth === "stale").length, + missingContainers: containers.filter((container) => container.liveHealth === "missing").length, + failedContainers: containers.filter((container) => container.liveHealth === "failed").length, + backlogCount: containers.filter( + (container) => container.dbStatus === "pending" || container.dbStatus === "provisioning", + ).length, + }; + + if (summary.backlogCount > summary.availableSlots && summary.availableSlots >= 0) { + incidents.push({ + severity: "warning", + scope: "cluster", + title: "Provisioning backlog exceeds free capacity", + detail: `${summary.backlogCount} containers are waiting or provisioning with ${summary.availableSlots} slots free`, + }); + } + + if (enabledNodes.length === 0 && summary.totalNodes > 0) { + incidents.push({ + severity: "critical", + scope: "cluster", + title: "No enabled Docker nodes available", + detail: + "Provisioning capacity is unavailable until at least one node is enabled for allocations", + }); + } else if (summary.healthyNodes === 0 && summary.totalNodes > 0) { + incidents.push({ + severity: "critical", + scope: "cluster", + title: "No healthy Docker nodes available", + detail: "Provisioning capacity is effectively unavailable until a node recovers", + }); + } + + const snapshot: AdminInfrastructureSnapshot = { + refreshedAt, + summary, + incidents: incidents.sort(sortIncidents), + nodes: inspectedNodes, + containers: containers.sort((a, b) => { + const severityWeight: Record = { + critical: 0, + warning: 1, + info: 2, + }; + + return ( + severityWeight[a.liveHealthSeverity] - severityWeight[b.liveHealthSeverity] || + a.createdAt.localeCompare(b.createdAt) + ); + }), + }; + + snapshotCache = { data: snapshot, expiresAt: Date.now() + SNAPSHOT_CACHE_TTL_MS }; + return snapshot; +} diff --git a/packages/lib/services/agent-budgets.ts b/packages/lib/services/agent-budgets.ts index f2c5a2320..069c01442 100644 --- a/packages/lib/services/agent-budgets.ts +++ b/packages/lib/services/agent-budgets.ts @@ -371,12 +371,16 @@ class AgentBudgetService { newBalance.lte(budget.auto_refill_threshold) ) { // Trigger auto-refill asynchronously - failure is non-critical - void this.triggerAutoRefill(agentId); + this.triggerAutoRefill(agentId).catch((err) => + logger.error("[AgentBudgets] Auto-refill failed", { agentId, error: String(err) }), + ); } // Check for low budget alert (fire-and-forget, logged on failure) if (newBalance.lte(lowThreshold) && !budget.low_budget_alert_sent) { - void this.sendLowBudgetAlert(agentId, newBalance.toNumber()); + this.sendLowBudgetAlert(agentId, newBalance.toNumber()).catch((err) => + logger.error("[AgentBudgets] Low budget alert failed", { agentId, error: String(err) }), + ); } return { diff --git a/packages/lib/services/eliza-app/session-service.ts b/packages/lib/services/eliza-app/session-service.ts index 044629538..fd0da67e9 100644 --- a/packages/lib/services/eliza-app/session-service.ts +++ b/packages/lib/services/eliza-app/session-service.ts @@ -66,7 +66,7 @@ class ElizaAppSessionService { const token = await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt(now) - .setExpirationTime(expiresAt) + .setExpirationTime(Math.floor(expiresAt.getTime() / 1000)) .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) .setSubject(userId) @@ -83,23 +83,25 @@ class ElizaAppSessionService { async validateSession(token: string): Promise { try { - const { payload } = await jwtVerify(token, this.getSecretKey(), { + const { payload } = await jwtVerify(token, this.getSecretKey(), { issuer: JWT_ISSUER, audience: JWT_AUDIENCE, }); - if (!payload.userId || !payload.organizationId) { + const sessionPayload = payload as unknown as ElizaAppSessionPayload; + + if (!sessionPayload.userId || !sessionPayload.organizationId) { logger.warn("[ElizaAppSession] Token missing required fields"); return null; } return { - userId: payload.userId, - organizationId: payload.organizationId, - telegramId: payload.telegramId, - discordId: payload.discordId, - whatsappId: payload.whatsappId, - phoneNumber: payload.phoneNumber, + userId: sessionPayload.userId, + organizationId: sessionPayload.organizationId, + telegramId: sessionPayload.telegramId, + discordId: sessionPayload.discordId, + whatsappId: sessionPayload.whatsappId, + phoneNumber: sessionPayload.phoneNumber, }; } catch (error) { logger.debug("[ElizaAppSession] Token validation failed", { error }); diff --git a/packages/lib/services/milady-billing-gate.ts b/packages/lib/services/milady-billing-gate.ts new file mode 100644 index 000000000..4a99d25d4 --- /dev/null +++ b/packages/lib/services/milady-billing-gate.ts @@ -0,0 +1,59 @@ +/** + * Milady billing gate — pre-provisioning credit check. + * + * Ensures an organization has the minimum deposit ($5) before + * allowing agent creation, provisioning, or resume. + */ + +import { organizationsRepository } from "@/db/repositories"; +import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; +import { logger } from "@/lib/utils/logger"; + +export interface CreditGateResult { + allowed: boolean; + balance: number; + error?: string; +} + +/** + * Check whether an organization has sufficient credits for Milady agent operations. + * + * Returns `{ allowed: true }` if `credit_balance >= MINIMUM_DEPOSIT`, + * otherwise returns a user-facing error message directing them to add funds. + */ +export async function checkMiladyCreditGate(organizationId: string): Promise { + try { + const org = await organizationsRepository.findById(organizationId); + if (!org) { + return { + allowed: false, + balance: 0, + error: "Organization not found", + }; + } + + const balance = Number(org.credit_balance); + + if (balance < MILADY_PRICING.MINIMUM_DEPOSIT) { + const deficit = MILADY_PRICING.MINIMUM_DEPOSIT - balance; + return { + allowed: false, + balance, + error: `Insufficient credits. A minimum balance of $${MILADY_PRICING.MINIMUM_DEPOSIT.toFixed(2)} is required to create or run Milady agents. Please add at least $${deficit.toFixed(2)} to your account at /dashboard/billing.`, + }; + } + + return { allowed: true, balance }; + } catch (error) { + logger.error("[milady-billing-gate] Failed to check credits", { + organizationId, + error: error instanceof Error ? error.message : String(error), + }); + // Fail closed — don't allow provisioning if we can't verify credits + return { + allowed: false, + balance: 0, + error: "Unable to verify credit balance. Please try again.", + }; + } +} diff --git a/packages/lib/utils/audio.ts b/packages/lib/utils/audio.ts index 5e8cdc3ea..da3064c93 100644 --- a/packages/lib/utils/audio.ts +++ b/packages/lib/utils/audio.ts @@ -82,7 +82,9 @@ export function validateAudioFile( */ export function createAudioContext(sampleRate?: number): AudioContext { const contextOptions = sampleRate ? { sampleRate } : undefined; - const AudioContextClass = window.AudioContext || window.webkitAudioContext; + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; return new AudioContextClass(contextOptions); } diff --git a/packages/scripts/check-types-split.ts b/packages/scripts/check-types-split.ts index 6666ef4af..8624869e7 100644 --- a/packages/scripts/check-types-split.ts +++ b/packages/scripts/check-types-split.ts @@ -69,7 +69,13 @@ async function createTempTsconfig(directory: string, baseTsconfig: object): Prom skipLibCheck: true, skipDefaultLibCheck: true, }, - include: ["next-env.d.ts", "types/**/*.d.ts", `${directory}/**/*.ts`, `${directory}/**/*.tsx`], + include: [ + "next-env.d.ts", + "types/**/*.d.ts", + "./packages/types/**/*.d.ts", + `${directory}/**/*.ts`, + `${directory}/**/*.tsx`, + ], // Keep the same excludes (include __tests__ so bun:test files are not type-checked with node types) exclude: [ "node_modules", @@ -78,6 +84,8 @@ async function createTempTsconfig(directory: string, baseTsconfig: object): Prom "scripts", "tests", "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx", ".next", "out", "build", diff --git a/packages/tests/e2e/preload.ts b/packages/tests/e2e/preload.ts index f5301f6b9..9a9d4e810 100644 --- a/packages/tests/e2e/preload.ts +++ b/packages/tests/e2e/preload.ts @@ -1,2 +1,31 @@ import "../load-env"; -import "./setup-server"; + +const DEFAULT_TEST_SECRETS_MASTER_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const OPTIONAL_OAUTH_ENV_VARS = [ + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "LINEAR_CLIENT_ID", + "LINEAR_CLIENT_SECRET", + "NOTION_CLIENT_ID", + "NOTION_CLIENT_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "SLACK_CLIENT_ID", + "SLACK_CLIENT_SECRET", +] as const; + +// Keep DB-backed integration/e2e suites deterministic on developer machines even +// when local .env files contain optional OAuth provider credentials. +if (!process.env.SECRETS_MASTER_KEY) { + process.env.SECRETS_MASTER_KEY = DEFAULT_TEST_SECRETS_MASTER_KEY; +} + +if (process.env.PRESERVE_LOCAL_OAUTH_PROVIDER_ENV !== "1") { + for (const envVar of OPTIONAL_OAUTH_ENV_VARS) { + process.env[envVar] = ""; + } +} + +await import("./setup-server"); diff --git a/packages/tests/unit/admin-infrastructure.test.ts b/packages/tests/unit/admin-infrastructure.test.ts new file mode 100644 index 000000000..a61881c2f --- /dev/null +++ b/packages/tests/unit/admin-infrastructure.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { classifyContainerHealth } from "../../lib/services/admin-infrastructure"; + +describe("classifyContainerHealth", () => { + test("marks runtime-unhealthy containers as failed", () => { + const result = classifyContainerHealth({ + dbStatus: "running", + runtime: { + name: "milady-test", + id: "abc123", + image: "milady/agent:cloud-full-ui", + state: "running", + status: "Up 3m (unhealthy)", + runningFor: "3 minutes", + health: "unhealthy", + }, + lastHeartbeatAt: new Date().toISOString(), + errorMessage: null, + }); + + expect(result.status).toBe("failed"); + expect(result.severity).toBe("critical"); + expect(result.reason).toContain("unhealthy"); + }); + + test("marks missing runtime containers as missing when control plane expects them", () => { + const result = classifyContainerHealth({ + dbStatus: "running", + runtime: null, + lastHeartbeatAt: new Date().toISOString(), + errorMessage: null, + }); + + expect(result.status).toBe("missing"); + expect(result.severity).toBe("critical"); + }); + + test("treats provisioning records without runtime as warming", () => { + const result = classifyContainerHealth({ + dbStatus: "provisioning", + runtime: null, + lastHeartbeatAt: null, + errorMessage: null, + }); + + expect(result.status).toBe("warming"); + expect(result.severity).toBe("info"); + }); + + test("marks old heartbeats as stale even if runtime is running", () => { + const result = classifyContainerHealth({ + dbStatus: "running", + runtime: { + name: "milady-test", + id: "abc123", + image: "milady/agent:cloud-full-ui", + state: "running", + status: "Up 2h", + runningFor: "2 hours", + health: "healthy", + }, + lastHeartbeatAt: new Date(Date.now() - 20 * 60_000).toISOString(), + errorMessage: null, + }); + + expect(result.status).toBe("stale"); + expect(result.severity).toBe("critical"); + expect(result.reason).toContain("Heartbeat"); + }); + + test("accepts healthy running containers with fresh heartbeat", () => { + const result = classifyContainerHealth({ + dbStatus: "running", + runtime: { + name: "milady-test", + id: "abc123", + image: "milady/agent:cloud-full-ui", + state: "running", + status: "Up 10m (healthy)", + runningFor: "10 minutes", + health: "healthy", + }, + lastHeartbeatAt: new Date(Date.now() - 2 * 60_000).toISOString(), + errorMessage: null, + }); + + expect(result.status).toBe("healthy"); + expect(result.severity).toBe("info"); + }); +}); diff --git a/packages/tests/unit/admin-service-pricing-route.test.ts b/packages/tests/unit/admin-service-pricing-route.test.ts index 847c7a26e..fca78aee9 100644 --- a/packages/tests/unit/admin-service-pricing-route.test.ts +++ b/packages/tests/unit/admin-service-pricing-route.test.ts @@ -34,8 +34,22 @@ mock.module("@/db/repositories", () => ({ }, })); +// Include all public exports so the mock doesn't break other modules that +// import PricingNotFoundError (e.g. proxy/engine loaded by proxy-engine tests). +class _MockPricingNotFoundError extends Error { + constructor( + public readonly serviceId: string, + public readonly method: string, + ) { + super(`Pricing not found for service ${serviceId}, method ${method}`); + this.name = "PricingNotFoundError"; + } +} + mock.module("@/lib/services/proxy/pricing", () => ({ invalidateServicePricingCache: mockInvalidateCache, + PricingNotFoundError: _MockPricingNotFoundError, + getServiceMethodCost: mock(async () => 1.0), })); mock.module("@/lib/utils/logger", () => ({ diff --git a/packages/tests/unit/affiliates-service.test.ts b/packages/tests/unit/affiliates-service.test.ts index 848902788..783e89d67 100644 --- a/packages/tests/unit/affiliates-service.test.ts +++ b/packages/tests/unit/affiliates-service.test.ts @@ -20,6 +20,15 @@ mock.module("@/db/repositories/affiliates", () => ({ }, })); +mock.module("@/lib/cache/client", () => ({ + cache: { + get: mock(async () => null), + set: mock(async () => {}), + del: mock(async () => {}), + delPattern: mock(async () => {}), + }, +})); + mock.module("@/lib/utils/logger", () => ({ logger: { info: mock(), diff --git a/packages/tests/unit/milady-billing-route.test.ts b/packages/tests/unit/milady-billing-route.test.ts new file mode 100644 index 000000000..b82854f08 --- /dev/null +++ b/packages/tests/unit/milady-billing-route.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { NextRequest } from "next/server"; + +const TEST_SECRET = "milady-cron-secret"; + +const readResultsQueue: unknown[][] = []; +const txUpdateResultsQueue: unknown[][] = []; +const txInsertResultsQueue: unknown[][] = []; +const writeUpdateResultsQueue: unknown[][] = []; +const txUpdateSetCalls: Array> = []; +const writeUpdateSetCalls: Array> = []; +let previousCronSecret: string | undefined; +let previousAppUrl: string | undefined; + +function createReadBuilder(result: unknown[]) { + return { + from() { + return { + where() { + return Promise.resolve(result); + }, + }; + }, + }; +} + +function createAwaitableUpdateResult(result: unknown[]) { + const promise = Promise.resolve(result) as Promise & { + returning: () => Promise; + }; + promise.returning = () => promise; + return promise; +} + +function createUpdateBuilder(queue: unknown[][], setCalls: Array>) { + const result = queue.shift() ?? []; + + return { + set(values: Record) { + setCalls.push(values); + return { + where() { + return createAwaitableUpdateResult(result); + }, + }; + }, + }; +} + +function createInsertBuilder(queue: unknown[][]) { + const result = queue.shift() ?? []; + + return { + values() { + return { + returning() { + return Promise.resolve(result); + }, + }; + }, + }; +} + +const mockDbReadSelect = mock(() => createReadBuilder(readResultsQueue.shift() ?? [])); +const mockDbWriteUpdate = mock(() => + createUpdateBuilder(writeUpdateResultsQueue, writeUpdateSetCalls), +); +const mockDbWriteTransaction = mock(async (callback: (tx: any) => Promise) => + callback({ + update: () => createUpdateBuilder(txUpdateResultsQueue, txUpdateSetCalls), + insert: () => createInsertBuilder(txInsertResultsQueue), + }), +); +const mockListByOrganization = mock(async () => []); +const mockSendContainerShutdownWarningEmail = mock(async () => undefined); +const mockTrackServerEvent = mock(() => undefined); +const mockLogger = { + info: mock(() => undefined), + warn: mock(() => undefined), + error: mock(() => undefined), +}; + +mock.module("@/db/client", () => ({ + dbRead: { + select: mockDbReadSelect, + }, + dbWrite: { + update: mockDbWriteUpdate, + transaction: mockDbWriteTransaction, + }, +})); + +mock.module("@/db/repositories", () => ({ + usersRepository: { + listByOrganization: mockListByOrganization, + }, +})); + +mock.module("@/lib/services/email", () => ({ + emailService: { + sendContainerShutdownWarningEmail: mockSendContainerShutdownWarningEmail, + }, +})); + +mock.module("@/lib/analytics/posthog-server", () => ({ + trackServerEvent: mockTrackServerEvent, +})); + +mock.module("@/lib/utils/logger", () => ({ + logger: mockLogger, +})); + +async function importRoute() { + return await import("@/app/api/cron/milady-billing/route"); +} + +function createRequest(): NextRequest { + return new NextRequest("https://example.com/api/cron/milady-billing", { + method: "GET", + headers: { + authorization: `Bearer ${TEST_SECRET}`, + }, + }); +} + +function enqueueBaseReadState({ + sandbox, + orgBalance = "5.0000", + billingEmail = "billing@example.com", +}: { + sandbox: Record; + orgBalance?: string; + billingEmail?: string | null; +}) { + readResultsQueue.push( + [sandbox], + [], + [{ id: "org-1", name: "Milady Org", credit_balance: orgBalance }], + billingEmail === null ? [] : [{ organization_id: "org-1", billing_email: billingEmail }], + ); +} + +describe("Milady billing cron", () => { + beforeEach(() => { + previousCronSecret = process.env.CRON_SECRET; + previousAppUrl = process.env.NEXT_PUBLIC_APP_URL; + + readResultsQueue.length = 0; + txUpdateResultsQueue.length = 0; + txInsertResultsQueue.length = 0; + writeUpdateResultsQueue.length = 0; + txUpdateSetCalls.length = 0; + writeUpdateSetCalls.length = 0; + + process.env.CRON_SECRET = TEST_SECRET; + process.env.NEXT_PUBLIC_APP_URL = "https://example.com"; + + mockDbReadSelect.mockClear(); + mockDbReadSelect.mockImplementation(() => createReadBuilder(readResultsQueue.shift() ?? [])); + + mockDbWriteUpdate.mockClear(); + mockDbWriteUpdate.mockImplementation(() => + createUpdateBuilder(writeUpdateResultsQueue, writeUpdateSetCalls), + ); + + mockDbWriteTransaction.mockClear(); + mockDbWriteTransaction.mockImplementation(async (callback: (tx: any) => Promise) => + callback({ + update: () => createUpdateBuilder(txUpdateResultsQueue, txUpdateSetCalls), + insert: () => createInsertBuilder(txInsertResultsQueue), + }), + ); + + mockListByOrganization.mockClear(); + mockListByOrganization.mockResolvedValue([]); + mockSendContainerShutdownWarningEmail.mockClear(); + mockTrackServerEvent.mockClear(); + mockLogger.info.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); + }); + + afterEach(() => { + if (previousCronSecret === undefined) { + delete process.env.CRON_SECRET; + } else { + process.env.CRON_SECRET = previousCronSecret; + } + + if (previousAppUrl === undefined) { + delete process.env.NEXT_PUBLIC_APP_URL; + } else { + process.env.NEXT_PUBLIC_APP_URL = previousAppUrl; + } + }); + + test("skips a sandbox cleanly when another run already billed it", async () => { + enqueueBaseReadState({ + sandbox: { + id: "sandbox-1", + agent_name: "Already Billed", + organization_id: "org-1", + user_id: "user-1", + status: "running", + billing_status: "active", + last_billed_at: null, + total_billed: "1.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, + }, + }); + + txUpdateResultsQueue.push([]); + + const { GET } = await importRoute(); + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sandboxesProcessed).toBe(1); + expect(body.data.sandboxesBilled).toBe(0); + expect(body.data.warningsSent).toBe(0); + expect(body.data.errors).toBe(0); + expect(body.data.results[0]).toEqual( + expect.objectContaining({ + action: "skipped", + error: "Already billed recently", + }), + ); + expect(mockSendContainerShutdownWarningEmail).not.toHaveBeenCalled(); + }); + + test("sends a shutdown warning when the atomic org debit is rejected", async () => { + enqueueBaseReadState({ + sandbox: { + id: "sandbox-1", + agent_name: "Low Balance", + organization_id: "org-1", + user_id: "user-1", + status: "running", + billing_status: "active", + last_billed_at: null, + total_billed: "1.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, + }, + orgBalance: "1.0000", + }); + + // Fresh balance lookup used by queueShutdownWarning after the debit guard fails. + readResultsQueue.push([{ credit_balance: "0.0010" }]); + + txUpdateResultsQueue.push([{ id: "sandbox-1" }], []); + writeUpdateResultsQueue.push([]); + + const { GET } = await importRoute(); + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sandboxesProcessed).toBe(1); + expect(body.data.sandboxesBilled).toBe(0); + expect(body.data.warningsSent).toBe(1); + expect(body.data.errors).toBe(0); + expect(body.data.results[0]).toEqual( + expect.objectContaining({ + action: "warning_sent", + }), + ); + expect(mockSendContainerShutdownWarningEmail).toHaveBeenCalledTimes(1); + expect(mockTrackServerEvent).toHaveBeenCalledWith( + "user-1", + "milady_agent_shutdown_warning_sent", + expect.objectContaining({ + sandbox_id: "sandbox-1", + current_balance: 0.001, + }), + ); + expect(writeUpdateSetCalls[0]).toEqual( + expect.objectContaining({ + billing_status: "shutdown_pending", + }), + ); + }); + + test("persists the billed hourly rate when a charge succeeds", async () => { + enqueueBaseReadState({ + sandbox: { + id: "sandbox-1", + agent_name: "Bill Me", + organization_id: "org-1", + user_id: "user-1", + status: "running", + billing_status: "active", + last_billed_at: null, + total_billed: "1.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, + }, + }); + + txUpdateResultsQueue.push([{ id: "sandbox-1" }], [{ credit_balance: "4.9800" }], []); + txInsertResultsQueue.push([{ id: "credit-tx-1" }]); + + const { GET } = await importRoute(); + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sandboxesBilled).toBe(1); + expect(body.data.totalRevenue).toBe(0.02); + expect(txUpdateSetCalls).toContainEqual( + expect.objectContaining({ + billing_status: "active", + hourly_rate: "0.02", + }), + ); + }); + + test("marks a billed sandbox as warning when the remaining balance is low", async () => { + enqueueBaseReadState({ + sandbox: { + id: "sandbox-1", + agent_name: "Warn Me", + organization_id: "org-1", + user_id: "user-1", + status: "running", + billing_status: "active", + last_billed_at: null, + total_billed: "1.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, + }, + orgBalance: "2.0100", + }); + + txUpdateResultsQueue.push([{ id: "sandbox-1" }], [{ credit_balance: "1.9900" }], []); + txInsertResultsQueue.push([{ id: "credit-tx-1" }]); + + const { GET } = await importRoute(); + const response = await GET(createRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sandboxesBilled).toBe(1); + expect(txUpdateSetCalls).toContainEqual( + expect.objectContaining({ + billing_status: "warning", + hourly_rate: "0.02", + }), + ); + }); +}); diff --git a/packages/tests/unit/milady-create-routes.test.ts b/packages/tests/unit/milady-create-routes.test.ts index dfebbe1aa..caad2c2f2 100644 --- a/packages/tests/unit/milady-create-routes.test.ts +++ b/packages/tests/unit/milady-create-routes.test.ts @@ -8,6 +8,7 @@ const mockAuthenticateWaifuBridge = mock(); const mockCreateAgent = mock(); const mockFindByIdInOrganizationForWrite = mock(); const mockPrepareManagedMiladyEnvironment = mock(); +const mockCheckMiladyCreditGate = mock(); mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -49,6 +50,14 @@ mock.module("@/db/repositories/characters", () => ({ }, })); +mock.module("@/lib/services/milady-billing-gate", () => ({ + checkMiladyCreditGate: mockCheckMiladyCreditGate, +})); + +mock.module("@/lib/constants/milady-pricing", () => ({ + MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, +})); + mock.module("@/lib/utils/logger", () => ({ logger: { info: mock(), @@ -73,7 +82,9 @@ describe("Milady create routes reserved config stripping", () => { mockCreateAgent.mockReset(); mockFindByIdInOrganizationForWrite.mockReset(); mockPrepareManagedMiladyEnvironment.mockReset(); + mockCheckMiladyCreditGate.mockReset(); + mockCheckMiladyCreditGate.mockResolvedValue({ allowed: true, balance: 100 }); mockRequireAuthOrApiKeyWithOrg.mockResolvedValue({ user: { id: "user-1", diff --git a/packages/tests/unit/milaidy-agent-routes-followups.test.ts b/packages/tests/unit/milaidy-agent-routes-followups.test.ts index 89c75fd85..9fdeb7084 100644 --- a/packages/tests/unit/milaidy-agent-routes-followups.test.ts +++ b/packages/tests/unit/milaidy-agent-routes-followups.test.ts @@ -16,6 +16,7 @@ const mockEnqueueMiladyProvisionOnce = mock(); const mockFindCharacterForWrite = mock(); const mockCharacterDelete = mock(); const mockPrepareManagedMiladyEnvironment = mock(); +const mockCheckMiladyCreditGate = mock(); mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -75,6 +76,14 @@ mock.module("@/lib/security/outbound-url", () => ({ assertSafeOutboundUrl: mock(async (url: string) => new URL(url)), })); +mock.module("@/lib/services/milady-billing-gate", () => ({ + checkMiladyCreditGate: mockCheckMiladyCreditGate, +})); + +mock.module("@/lib/constants/milady-pricing", () => ({ + MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, +})); + mock.module("@/lib/utils/logger", () => ({ logger: { info: mock(), @@ -111,7 +120,9 @@ describe("milady agent route follow-ups", () => { mockFindCharacterForWrite.mockReset(); mockCharacterDelete.mockReset(); mockPrepareManagedMiladyEnvironment.mockReset(); + mockCheckMiladyCreditGate.mockReset(); + mockCheckMiladyCreditGate.mockResolvedValue({ allowed: true, balance: 100 }); mockRequireAuthOrApiKeyWithOrg.mockResolvedValue({ user: { id: "user-1", diff --git a/packages/tests/unit/v1-milaidy-provision-route.test.ts b/packages/tests/unit/v1-milaidy-provision-route.test.ts index 1a6e208eb..3edaba686 100644 --- a/packages/tests/unit/v1-milaidy-provision-route.test.ts +++ b/packages/tests/unit/v1-milaidy-provision-route.test.ts @@ -9,6 +9,7 @@ const mockGetAgentForWrite = mock(); const mockProvision = mock(); const mockEnqueueMiladyProvisionOnce = mock(); const mockLoggerError = mock(); +const mockCheckMiladyCreditGate = mock(); mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -38,6 +39,14 @@ mock.module("@/lib/services/provisioning-jobs", () => ({ }, })); +mock.module("@/lib/services/milady-billing-gate", () => ({ + checkMiladyCreditGate: mockCheckMiladyCreditGate, +})); + +mock.module("@/lib/constants/milady-pricing", () => ({ + MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, +})); + mock.module("@/lib/utils/logger", () => ({ logger: { info: mock(), @@ -57,7 +66,9 @@ describe("POST /api/v1/milaidy/agents/[agentId]/provision", () => { mockProvision.mockReset(); mockEnqueueMiladyProvisionOnce.mockReset(); mockLoggerError.mockReset(); + mockCheckMiladyCreditGate.mockReset(); + mockCheckMiladyCreditGate.mockResolvedValue({ allowed: true, balance: 100 }); mockRequireAuthOrApiKeyWithOrg.mockResolvedValue({ user: { id: "user-1", diff --git a/packages/types/bs58.d.ts b/packages/types/bs58.d.ts new file mode 100644 index 000000000..b3ca0fa56 --- /dev/null +++ b/packages/types/bs58.d.ts @@ -0,0 +1,7 @@ +declare module "bs58" { + const bs58: { + encode(buffer: Uint8Array | Buffer): string; + decode(str: string): Uint8Array; + }; + export default bs58; +} diff --git a/packages/types/monaco-editor.d.ts b/packages/types/monaco-editor.d.ts index 0062d0300..fe1ea3159 100644 --- a/packages/types/monaco-editor.d.ts +++ b/packages/types/monaco-editor.d.ts @@ -1,16 +1,15 @@ declare module "monaco-editor" { export namespace editor { interface IStandaloneCodeEditor { + getValue(): string; + setValue(value: string): void; + getModel(): unknown; + dispose(): void; focus(): void; getDomNode(): HTMLElement | null; + onDidChangeModelContent(listener: (e: unknown) => void): { dispose(): void }; + layout(dimension?: { width: number; height: number }): void; + updateOptions(options: Record): void; } - interface IStandaloneEditorConstructionOptions {} - function defineTheme(name: string, theme: unknown): void; - function setTheme(name: string): void; } - - export const editor: { - defineTheme: typeof editor.defineTheme; - setTheme: typeof editor.setTheme; - }; } diff --git a/packages/types/plugin-sql-node.d.ts b/packages/types/plugin-sql-node.d.ts new file mode 100644 index 000000000..60d4216f2 --- /dev/null +++ b/packages/types/plugin-sql-node.d.ts @@ -0,0 +1,9 @@ +declare module "@elizaos/plugin-sql/node" { + import type { IDatabaseAdapter } from "@elizaos/core"; + export function createDatabaseAdapter( + config: { postgresUrl: string }, + agentId: string, + ): IDatabaseAdapter; + const plugin: unknown; + export default plugin; +} diff --git a/packages/ui/src/components/admin/infrastructure-dashboard.tsx b/packages/ui/src/components/admin/infrastructure-dashboard.tsx index b139e67f4..ca38026fe 100644 --- a/packages/ui/src/components/admin/infrastructure-dashboard.tsx +++ b/packages/ui/src/components/admin/infrastructure-dashboard.tsx @@ -80,7 +80,7 @@ interface DockerNode { interface DockerContainer { id: string; - sandboxId: string; + sandboxId: string | null; organizationId: string | null; userId: string | null; agentName: string | null; @@ -114,7 +114,7 @@ interface VpnNode { } interface HeadscaleData { - serverUrl: string; + serverConfigured?: boolean; user: string; vpnNodes: VpnNode[]; summary: { total: number; online: number; offline: number }; @@ -290,7 +290,7 @@ export function InfrastructureDashboard() { const res = await fetch("/api/v1/admin/docker-nodes"); const json = await res.json(); if (!json.success) throw new Error(json.error); - setNodes(json.data.nodes); + setNodes(json.data?.nodes ?? []); } catch (err) { toast.error(`Failed to load nodes: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -307,7 +307,19 @@ export function InfrastructureDashboard() { const res = await fetch(`/api/v1/admin/docker-containers?${params}`); const json = await res.json(); if (!json.success) throw new Error(json.error); - setContainers(json.data.containers); + // Sanitize: ensure all values are primitives (Drizzle can return Date objects) + const raw: DockerContainer[] = json.data?.containers ?? []; + setContainers( + raw.map((c) => ({ + ...c, + sandboxId: c.sandboxId != null ? String(c.sandboxId) : null, + createdAt: c.createdAt != null ? String(c.createdAt) : new Date().toISOString(), + updatedAt: c.updatedAt != null ? String(c.updatedAt) : new Date().toISOString(), + lastHeartbeatAt: c.lastHeartbeatAt != null ? String(c.lastHeartbeatAt) : null, + errorMessage: c.errorMessage != null ? String(c.errorMessage) : null, + errorCount: typeof c.errorCount === "number" ? c.errorCount : 0, + })), + ); } catch (err) { toast.error(`Failed to load containers: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -879,7 +891,7 @@ export function InfrastructureDashboard() { {c.agentName ?? ( - {c.sandboxId.slice(0, 8)}… + {(c.sandboxId ?? c.id ?? "unknown").slice(0, 8)}… )} @@ -966,7 +978,7 @@ export function InfrastructureDashboard() { Headscale server online

- {headscale.serverUrl} · User: {headscale.user} · Queried{" "} + Connected · User: {headscale.user} · Queried{" "} {formatRelativeTime(headscale.queriedAt)}

diff --git a/packages/ui/src/components/apps/create-app-dialog.tsx b/packages/ui/src/components/apps/create-app-dialog.tsx index b7d02661c..f56036aff 100644 --- a/packages/ui/src/components/apps/create-app-dialog.tsx +++ b/packages/ui/src/components/apps/create-app-dialog.tsx @@ -552,9 +552,7 @@ export function CreateAppDialog({ open, onOpenChange }: CreateAppDialogProps) { -

- Select which Eliza Cloud features this app can access -

+

Select which Eliza Cloud features this app can access

diff --git a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx index e259f0a8e..3d27ee758 100644 --- a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx +++ b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx @@ -17,37 +17,292 @@ import { SelectValue, Switch, } from "@elizaos/cloud-ui"; -import { Loader2, Plus } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { type ReactNode, useState } from "react"; +import { Check, ExternalLink, Loader2, Plus, RotateCcw, X } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; import { AGENT_FLAVORS, getDefaultFlavor, getFlavorById } from "@/lib/constants/agent-flavors"; +import { openWebUIWithPairing } from "@/lib/hooks/open-web-ui"; +import { type SandboxStatus, useSandboxStatusPoll } from "@/lib/hooks/use-sandbox-status-poll"; + +// ---------------------------------------------------------------- +// Provisioning Steps +// ---------------------------------------------------------------- + +interface StepConfig { + label: string; + matchStatuses: SandboxStatus[]; +} + +const PROVISIONING_STEPS: StepConfig[] = [ + { label: "Agent created", matchStatuses: [] }, + { label: "Provisioning database", matchStatuses: ["pending"] }, + { label: "Starting container", matchStatuses: ["provisioning"] }, + { label: "Agent running", matchStatuses: ["running"] }, +]; + +function getActiveStepIndex(status: SandboxStatus): number { + if (status === "running") return 3; + if (status === "provisioning") return 2; + if (status === "pending") return 1; + return 0; +} + +type StepState = "complete" | "active" | "pending" | "error"; + +function getStepState(stepIndex: number, activeIndex: number, hasError: boolean): StepState { + if (hasError && stepIndex === activeIndex) return "error"; + if (stepIndex < activeIndex) return "complete"; + if (stepIndex === activeIndex) return "active"; + return "pending"; +} + +// ---------------------------------------------------------------- +// Step Indicator Component +// ---------------------------------------------------------------- + +function StepIndicator({ state }: { state: StepState }) { + const base = "flex h-6 w-6 shrink-0 items-center justify-center"; + + switch (state) { + case "complete": + return ( +
+ +
+ ); + case "active": + return ( +
+ +
+ ); + case "error": + return ( +
+ +
+ ); + case "pending": + default: + return ( +
+ +
+ ); + } +} + +// ---------------------------------------------------------------- +// Provisioning Progress View +// ---------------------------------------------------------------- + +function ProvisioningProgress({ + status, + error, + agentId, + elapsedSec, + onClose, + onRetry, +}: { + status: SandboxStatus; + error: string | null; + agentId: string; + elapsedSec: number; + onClose: () => void; + onRetry: () => void; +}) { + const activeIndex = getActiveStepIndex(status); + const hasError = status === "error"; + const isComplete = status === "running"; + + return ( +
+ {/* Header */} +
+

+ {isComplete + ? "Your agent is ready" + : hasError + ? "Something went wrong" + : "Setting up your agent…"} +

+ {!isComplete && !hasError && ( + + {elapsedSec < 60 + ? `${elapsedSec}s` + : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`} + {" · ~90s"} + + )} +
+ + {/* Steps */} +
+ {PROVISIONING_STEPS.map((step, i) => { + const state = getStepState(i, activeIndex, hasError); + const isLast = i === PROVISIONING_STEPS.length - 1; + return ( +
+ {/* Vertical connector */} + {!isLast && ( +
+
+
+ )} + +
+

+ {step.label} +

+
+
+ ); + })} +
+ + {/* Error detail */} + {hasError && error && ( +
+

{error}

+ +
+ )} + + {/* Footer actions */} +
+ {isComplete ? ( + <> + openWebUIWithPairing(agentId)}> + + Open Web UI + + + Done + + + ) : ( + + {hasError ? "Close" : "Close — continues in background"} + + )} +
+
+ ); +} + +// ---------------------------------------------------------------- +// Main Dialog Component +// ---------------------------------------------------------------- interface CreateMiladySandboxDialogProps { trigger?: ReactNode; onProvisionQueued?: (agentId: string, jobId: string) => void; + /** Called after a sandbox is successfully created so the parent can refresh. */ + onCreated?: () => void | Promise; } -type CreatePhase = "idle" | "creating" | "provisioning"; +type CreatePhase = "form" | "creating" | "provisioning"; export function CreateMiladySandboxDialog({ trigger, onProvisionQueued, + onCreated, }: CreateMiladySandboxDialogProps) { - const router = useRouter(); const [open, setOpen] = useState(false); const [agentName, setAgentName] = useState(""); const [flavorId, setFlavorId] = useState(getDefaultFlavor().id); const [customImage, setCustomImage] = useState(""); const [autoStart, setAutoStart] = useState(true); - const [phase, setPhase] = useState("idle"); + const [phase, setPhase] = useState("form"); const [error, setError] = useState(null); + const [createdAgentId, setCreatedAgentId] = useState(null); + const [provisionStartTime, setProvisionStartTime] = useState(null); + const [elapsedSec, setElapsedSec] = useState(0); - const busy = phase !== "idle"; + const busy = phase === "creating"; + const isProvisioningPhase = phase === "provisioning"; const selectedFlavor = getFlavorById(flavorId); const isCustom = flavorId === "custom"; const resolvedDockerImage = isCustom ? customImage.trim() : selectedFlavor?.dockerImage; + // Poll the agent status while in provisioning phase + const pollResult = useSandboxStatusPoll(isProvisioningPhase ? createdAgentId : null, { + intervalMs: 5_000, + enabled: isProvisioningPhase, + }); + + // Elapsed time counter + useEffect(() => { + if (!provisionStartTime) { + setElapsedSec(0); + return; + } + const tick = () => setElapsedSec(Math.floor((Date.now() - provisionStartTime) / 1000)); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [provisionStartTime]); + + // When provisioning completes, notify via toast (refresh happens in handleClose) + useEffect(() => { + if (isProvisioningPhase && pollResult.status === "running") { + toast.success("Agent is up and running!"); + } + }, [isProvisioningPhase, pollResult.status]); + + function resetForm() { + setAgentName(""); + setFlavorId(getDefaultFlavor().id); + setCustomImage(""); + setError(null); + setPhase("form"); + setCreatedAgentId(null); + setProvisionStartTime(null); + setElapsedSec(0); + } + + function handleClose() { + setOpen(false); + // Delay reset so the closing animation finishes + setTimeout(resetForm, 300); + // Only notify parent when an agent was actually created (skip premature dismissals) + if (createdAgentId) { + onCreated?.()?.catch(() => { + // Best-effort refresh — parent will retry on next poll cycle + }); + } + } + async function handleCreate() { const trimmedName = agentName.trim(); if (!trimmedName || busy) return; @@ -59,7 +314,6 @@ export function CreateMiladySandboxDialog({ const createBody: Record = { agentName: trimmedName, }; - // Only send dockerImage if it differs from the default milady image if (resolvedDockerImage && flavorId !== getDefaultFlavor().id) { createBody.dockerImage = resolvedDockerImage; } @@ -82,10 +336,13 @@ export function CreateMiladySandboxDialog({ throw new Error("Sandbox created but no agent id was returned"); } - toast.success(`Sandbox "${trimmedName}" created`); + setCreatedAgentId(agentId); if (autoStart) { + // Transition to provisioning view instead of closing setPhase("provisioning"); + setProvisionStartTime(Date.now()); + const provisionRes = await fetch(`/api/v1/milady/agents/${agentId}/provision`, { method: "POST", }); @@ -96,44 +353,58 @@ export function CreateMiladySandboxDialog({ if (jobId) { onProvisionQueued?.(agentId, jobId); } - toast.info( - provisionRes.status === 409 - ? jobId - ? `Provisioning already in progress, job ${jobId.slice(0, 8)} is running` - : "Provisioning is already in progress." - : jobId - ? `Provisioning queued, job ${jobId.slice(0, 8)} is running` - : "Provisioning queued. This usually takes about 90 seconds.", - ); + // Stay in provisioning view — the polling hook will track status } else if (provisionRes.ok) { - toast.success("Sandbox is running"); + // Already running (synchronous provision) + toast.success("Agent is running"); + handleClose(); } else { toast.warning( (provisionData as { error?: string }).error ?? "Sandbox created, but auto-start failed. You can start it from the table.", ); + handleClose(); } + } else { + toast.success(`Sandbox "${trimmedName}" created`); + handleClose(); } - - setOpen(false); - setAgentName(""); - setFlavorId(getDefaultFlavor().id); - setCustomImage(""); - setError(null); - setPhase("idle"); - router.refresh(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); - setPhase("idle"); + setPhase("form"); toast.error(message); } } + async function handleRetryProvision() { + if (!createdAgentId) return; + setProvisionStartTime(Date.now()); + + try { + const res = await fetch(`/api/v1/milady/agents/${createdAgentId}/provision`, { + method: "POST", + }); + const data = await res.json().catch(() => ({})); + + if (res.status === 202 || res.status === 409) { + const jobId = (data as { data?: { jobId?: string } }).data?.jobId; + if (jobId) { + onProvisionQueued?.(createdAgentId, jobId); + } + toast.info("Retrying provisioning…"); + } else if (!res.ok) { + toast.error((data as { error?: string }).error ?? "Retry failed"); + } + } catch (err) { + toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + return ( <> {trigger ? ( -
!busy && setOpen(true)}>{trigger}
+
phase === "form" && setOpen(true)}>{trigger}
) : ( setOpen(true)} disabled={busy}> @@ -141,125 +412,146 @@ export function CreateMiladySandboxDialog({ )} - !busy && setOpen(nextOpen)}> + { + if (!nextOpen && !busy) { + handleClose(); + } + }} + > - Create Milady Sandbox - - Create a new agent sandbox and optionally start provisioning right away. - + + {isProvisioningPhase ? "Launching Agent" : "Create Sandbox"} + + {!isProvisioningPhase && ( + + Create an agent sandbox and optionally start provisioning right away. + + )} -
-
- - setAgentName(e.target.value)} - disabled={busy} - className="bg-black/40 border-white/10 text-white placeholder:text-neutral-600" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleCreate(); - } - }} - maxLength={100} - autoFocus - /> -
+ {isProvisioningPhase ? ( + + ) : ( + <> +
+ {/* Agent name */} +
+ + setAgentName(e.target.value)} + disabled={busy} + className="bg-black/40 border-white/10 text-white placeholder:text-white/25 focus-visible:ring-[#FF5800]/50" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} + maxLength={100} + autoFocus + /> +
- {/* Flavor selector */} -
- - - {selectedFlavor && ( -

{selectedFlavor.description}

- )} -
+ {/* Flavor selector */} +
+ + + {selectedFlavor && ( +

{selectedFlavor.description}

+ )} +
- {/* Custom image input (only when "custom" flavor is selected) */} - {isCustom && ( -
- - setCustomImage(e.target.value)} - disabled={busy} - className="bg-black/40 border-white/10 text-white placeholder:text-neutral-600" - maxLength={256} - /> -
- )} + {/* Custom image input */} + {isCustom && ( +
+ + setCustomImage(e.target.value)} + disabled={busy} + className="bg-black/40 border-white/10 text-white placeholder:text-white/25" + maxLength={256} + /> +
+ )} -
-
- -

- Queue provisioning as soon as the sandbox record is created. -

-
- -
+ {/* Auto-start toggle */} +
+
+ +

+ Queue provisioning as soon as the record is created. +

+
+ +
- {error && ( -

- {error} -

- )} -
+ {/* Inline error */} + {error && ( +
+ {error} +
+ )} +
- - setOpen(false)} disabled={busy}> - Cancel - - void handleCreate()} - disabled={!agentName.trim() || busy || (isCustom && !customImage.trim())} - > - {busy && } - {phase === "creating" - ? "Creating..." - : phase === "provisioning" - ? "Queueing..." - : autoStart - ? "Create & Start" - : "Create Sandbox"} - - + + + Cancel + + void handleCreate()} + disabled={!agentName.trim() || busy || (isCustom && !customImage.trim())} + > + {busy && } + {busy ? "Creating…" : autoStart ? "Create & Start" : "Create Sandbox"} + + + + )}
diff --git a/packages/ui/src/components/containers/milady-page-wrapper.tsx b/packages/ui/src/components/containers/milady-page-wrapper.tsx new file mode 100644 index 000000000..545fa740b --- /dev/null +++ b/packages/ui/src/components/containers/milady-page-wrapper.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useSetPageHeader } from "@elizaos/cloud-ui"; +import { type ReactNode } from "react"; + +interface MiladyPageWrapperProps { + children: ReactNode; +} + +export function MiladyPageWrapper({ children }: MiladyPageWrapperProps): ReactNode { + useSetPageHeader({ title: "Milady Instances" }, []); + return children; +} diff --git a/packages/ui/src/components/containers/milady-sandboxes-table.tsx b/packages/ui/src/components/containers/milady-sandboxes-table.tsx index 2db6e4c38..c38c36636 100644 --- a/packages/ui/src/components/containers/milady-sandboxes-table.tsx +++ b/packages/ui/src/components/containers/milady-sandboxes-table.tsx @@ -1,7 +1,7 @@ /** * Milady Sandboxes Table — lists AI agent sandboxes in the containers dashboard. * Distinguishes between Docker-backed (node_id set) and Vercel-backed sandboxes. - * Keeps the user-facing surface focused on Milady actions instead of raw infra. + * Auto-refreshes while any sandbox is in an active (pending/provisioning) state. */ "use client"; @@ -46,11 +46,11 @@ import { Trash2, } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { openWebUIWithPairing } from "@/lib/hooks/open-web-ui"; import { useJobPoller } from "@/lib/hooks/use-job-poller"; +import { type SandboxListAgent, useSandboxListPoll } from "@/lib/hooks/use-sandbox-status-poll"; import { getClientSafeMiladyAgentWebUiUrl } from "@/lib/milady-web-ui"; import { CreateMiladySandboxDialog } from "./create-milady-sandbox-dialog"; @@ -85,33 +85,10 @@ interface MiladySandboxesTableProps { } // ---------------------------------------------------------------- -// Status helpers +// Status helpers (shared across dashboard components) // ---------------------------------------------------------------- -const STATUS_COLORS: Record = { - running: "bg-green-500 hover:bg-green-600", - provisioning: "bg-blue-500 hover:bg-blue-600", - pending: "bg-yellow-500 hover:bg-yellow-600", - stopped: "bg-gray-500 hover:bg-gray-600", - disconnected: "bg-orange-500 hover:bg-orange-600", - error: "bg-red-500 hover:bg-red-600", -}; - -const STATUS_DOTS: Record = { - running: "🟢", - provisioning: "🔵", - pending: "🟡", - stopped: "⚫", - disconnected: "🟠", - error: "🔴", -}; - -function statusColor(status: string) { - return STATUS_COLORS[status] ?? "bg-gray-500"; -} -function statusDot(status: string) { - return STATUS_DOTS[status] ?? "⚪"; -} +import { formatRelative, statusBadgeColor, statusDotColor } from "@/lib/constants/sandbox-status"; // ---------------------------------------------------------------- // Helpers @@ -128,35 +105,194 @@ function getConnectUrl(sb: MiladySandboxRow): string | null { }); } -function formatRelative(date: Date | string | null): string { - if (!date) return "Never"; - const d = new Date(date); - const diffMs = Date.now() - d.getTime(); - const diffMin = Math.floor(diffMs / 60_000); - if (diffMin < 1) return "Just now"; - if (diffMin < 60) return `${diffMin}m ago`; - const diffH = Math.floor(diffMin / 60); - if (diffH < 24) return `${diffH}h ago`; - const diffD = Math.floor(diffH / 24); - if (diffD < 7) return `${diffD}d ago`; - return d.toLocaleDateString(); +// ---------------------------------------------------------------- +// Status Cell — animated transitions +// ---------------------------------------------------------------- + +function StatusCell({ + displayStatus, + isProvisioning, + trackedJob, + errorMessage, +}: { + displayStatus: string; + isProvisioning: boolean; + trackedJob?: { jobId: string } | null; + errorMessage: string | null; +}) { + const [prevStatus, setPrevStatus] = useState(displayStatus); + const [animate, setAnimate] = useState<"success" | "error" | null>(null); + + useEffect(() => { + if (prevStatus !== displayStatus) { + if ( + displayStatus === "running" && + (prevStatus === "provisioning" || prevStatus === "pending") + ) { + setAnimate("success"); + const id = setTimeout(() => setAnimate(null), 1500); + setPrevStatus(displayStatus); + return () => clearTimeout(id); + } + if (displayStatus === "error") { + setAnimate("error"); + const id = setTimeout(() => setAnimate(null), 600); + setPrevStatus(displayStatus); + return () => clearTimeout(id); + } + setPrevStatus(displayStatus); + } + }, [displayStatus, prevStatus]); + + return ( +
+
+ + + {displayStatus} + +
+ {isProvisioning && trackedJob && ( + + + Job {trackedJob.jobId.slice(0, 8)} + + )} + {errorMessage && ( + + +

+ {errorMessage} +

+
+ +

{errorMessage}

+
+
+ )} +
+ ); } // ---------------------------------------------------------------- // Component // ---------------------------------------------------------------- -export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { - const router = useRouter(); +export function MiladySandboxesTable({ sandboxes: initialSandboxes }: MiladySandboxesTableProps) { const [deleteId, setDeleteId] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [actionInProgress, setActionInProgress] = useState(null); + // ── Client-side data management ────────────────────────────── + // Initialize from server props, then manage locally for instant UI updates. + const [localSandboxes, setLocalSandboxes] = useState(initialSandboxes); + const initialSandboxIdsRef = useRef([...initialSandboxes.map((sb) => sb.id)].sort().join(",")); + + // Re-sync from server props if the initial set changes (e.g. page navigation) + useEffect(() => { + const newIds = [...initialSandboxes.map((sb) => sb.id)].sort().join(","); + if (newIds !== initialSandboxIdsRef.current) { + initialSandboxIdsRef.current = newIds; + setLocalSandboxes(initialSandboxes); + } + }, [initialSandboxes]); + + /** + * Merge camelCase API response into local snake_case state. + * Preserves server-only fields (node_id, container_name, etc.) for existing + * agents while updating status/error/heartbeat from the API. + */ + const mergeApiData = useCallback((apiAgents: SandboxListAgent[]) => { + setLocalSandboxes((prev) => { + const apiIds = new Set(apiAgents.map((a) => a.id)); + const existingMap = new Map(prev.map((sb) => [sb.id, sb])); + + // Merge API agents with existing local state + const merged = apiAgents.map((agent) => { + const existing = existingMap.get(agent.id); + return { + // Spread existing server-only fields first (infra details) + ...(existing ?? {}), + // Then overlay API data (converting camelCase → snake_case) + id: agent.id, + agent_name: agent.agentName ?? existing?.agent_name ?? null, + status: agent.status ?? existing?.status ?? "pending", + error_message: agent.errorMessage ?? existing?.error_message ?? null, + last_heartbeat_at: agent.lastHeartbeatAt ?? existing?.last_heartbeat_at ?? null, + created_at: agent.createdAt ?? existing?.created_at ?? new Date().toISOString(), + updated_at: agent.updatedAt ?? existing?.updated_at ?? new Date().toISOString(), + // Preserve infra fields from existing data (API list doesn't return these) + node_id: existing?.node_id ?? null, + container_name: existing?.container_name ?? null, + bridge_port: existing?.bridge_port ?? null, + web_ui_port: existing?.web_ui_port ?? null, + headscale_ip: existing?.headscale_ip ?? null, + docker_image: existing?.docker_image ?? null, + sandbox_id: existing?.sandbox_id ?? null, + bridge_url: existing?.bridge_url ?? null, + canonical_web_ui_url: existing?.canonical_web_ui_url ?? null, + } as MiladySandboxRow; + }); + + // Preserve local-only entries (optimistic additions not yet in API response) + const localOnly = prev.filter((sb) => !apiIds.has(sb.id)); + return [...merged, ...localOnly]; + }); + }, []); + + /** Fetch fresh data from the API and update local state. */ + const refreshData = useCallback(async () => { + try { + const res = await fetch("/api/v1/milady/agents"); + if (!res.ok) return; + const json = await res.json(); + const agents: SandboxListAgent[] = json?.data ?? []; + mergeApiData(agents); + } catch { + // Silent — will retry on next action or poll + } + }, [mergeApiData]); + const poller = useJobPoller({ - onComplete: () => toast.success("Agent provisioning completed"), - onFailed: (job) => toast.error(job.error ?? "Provisioning failed"), + onComplete: () => { + toast.success("Agent provisioning completed"); + void refreshData(); + }, + onFailed: (job) => { + toast.error(job.error ?? "Provisioning failed"); + void refreshData(); + }, }); + // Auto-refresh polling: polls the list endpoint while any sandbox is active. + // Pushes fresh data via onDataRefresh so the table updates without page reload. + useSandboxListPoll( + localSandboxes.map((sb) => ({ + id: sb.id, + status: poller.isActive(sb.id) ? "provisioning" : sb.status, + })), + { + intervalMs: 10_000, + onTransitionToRunning: (_id, name) => { + toast.success(`${name ?? "Agent"} is now running!`); + }, + onDataRefresh: mergeApiData, + }, + ); + const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [sortField, setSortField] = useState<"name" | "status" | "created">("created"); @@ -168,7 +304,7 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { }; const filtered = useMemo(() => { - let list = sandboxes.filter((sb) => { + let list = localSandboxes.filter((sb) => { const q = searchQuery.toLowerCase(); const displayStatus = poller.isActive(sb.id) ? "provisioning" : sb.status; const matchSearch = @@ -195,12 +331,16 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { return sortDir === "asc" ? cmp : -cmp; }); return list; - }, [sandboxes, searchQuery, statusFilter, sortField, sortDir, poller.isActive]); + }, [localSandboxes, searchQuery, statusFilter, sortField, sortDir, poller.isActive]); // ── Actions ────────────────────────────────────────────────────── async function handleProvision(id: string) { setActionInProgress(id); + // Optimistic: show provisioning status immediately + setLocalSandboxes((prev) => + prev.map((sb) => (sb.id === id ? { ...sb, status: "provisioning" } : sb)), + ); try { const res = await fetch(`/api/v1/milady/agents/${id}/provision`, { method: "POST", @@ -217,6 +357,8 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { } if (!res.ok) { + // Revert optimistic update + void refreshData(); throw new Error((data as { error?: string }).error ?? "Provision failed"); } @@ -229,12 +371,12 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { } toast.success("Agent provisioning started"); - router.refresh(); + void refreshData(); return; } toast.success("Agent is already running"); - router.refresh(); + void refreshData(); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(`Failed to start agent: ${message}`); @@ -245,15 +387,23 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { async function handleSuspend(id: string) { setActionInProgress(id); + // Optimistic: show stopped status immediately + setLocalSandboxes((prev) => + prev.map((sb) => (sb.id === id ? { ...sb, status: "stopped" } : sb)), + ); try { const res = await fetch(`/api/v1/milady/agents/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "suspend" }), }); - if (!res.ok) throw new Error("Suspend failed"); + if (!res.ok) { + // Revert optimistic update + void refreshData(); + throw new Error("Suspend failed"); + } toast.success("Agent suspended (snapshot saved)"); - router.refresh(); + void refreshData(); } catch { toast.error("Failed to suspend agent"); } finally { @@ -263,16 +413,22 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { async function handleDelete(id: string) { setIsDeleting(true); + // Optimistic: remove from list immediately + const previousSandboxes = localSandboxes; + setLocalSandboxes((prev) => prev.filter((sb) => sb.id !== id)); try { const res = await fetch(`/api/v1/milady/agents/${id}`, { method: "DELETE", }); const data = await res.json().catch(() => ({})); if (!res.ok) { + // Revert optimistic removal + setLocalSandboxes(previousSandboxes); throw new Error((data as { error?: string }).error ?? "Delete failed"); } toast.success("Agent deleted"); - router.refresh(); + // Confirm with a refresh (already removed optimistically) + void refreshData(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to delete agent"; toast.error(message); @@ -284,19 +440,29 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { const deleteTargetBusy = deleteId ? poller.isActive(deleteId) : false; - if (sandboxes.length === 0) { + // ── Empty state ────────────────────────────────────────────────── + + if (localSandboxes.length === 0) { return ( -
- -
-

No Milady sandboxes yet

-

- Create your first sandbox, then provision it from the dashboard. -

+
+
+
+ +
+
+

No sandboxes yet

+

+ Create your first agent sandbox to get started. You can provision it immediately or + start it later from the dashboard. +

+
+
+ poller.track(agentId, jobId)} + onCreated={refreshData} + /> +
- poller.track(agentId, jobId)} - />
); } @@ -304,23 +470,23 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { return (
- {/* Search + filter */} + {/* Search + filter + create */}
- + setSearchQuery(e.target.value)} - className="pl-9 h-10 rounded-lg border-white/10 bg-black/40 text-white placeholder:text-neutral-500 focus-visible:ring-[#FF5800]/50" + className="pl-9 h-9 border-white/10 bg-black/40 text-white placeholder:text-white/30 focus-visible:ring-[#FF5800]/50" />
poller.track(agentId, jobId)} + onCreated={refreshData} />
{(searchQuery || statusFilter !== "all") && ( -

- Showing {filtered.length} of {sandboxes.length} agents +

+ {filtered.length} of {localSandboxes.length} agents

)} - {/* Table */} -
+ {/* Desktop table */} +
- - + + - Runtime - Web UI + + Runtime + + + Web UI + - + Actions @@ -382,10 +556,10 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { {filtered.length === 0 ? ( - -
- -

No agents match your filters

+ +
+ +

No agents match your filters

@@ -394,9 +568,9 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { const isDocker = isDockerBacked(sb); const connectUrl = getConnectUrl(sb); const trackedJob = poller.getStatus(sb.id); - const isProvisioning = poller.isActive(sb.id); - const displayStatus = isProvisioning ? "provisioning" : sb.status; - const busy = actionInProgress === sb.id || isProvisioning; + const isProvisioningActive = poller.isActive(sb.id); + const displayStatus = isProvisioningActive ? "provisioning" : sb.status; + const busy = actionInProgress === sb.id || isProvisioningActive; const canStart = ["stopped", "error", "pending", "disconnected"].includes(displayStatus) && !busy; @@ -405,36 +579,27 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { return ( - {/* Agent name + type badge */} + {/* Agent name + type */} -
+
{sb.agent_name ?? "Unnamed Agent"} -
- {isDocker ? ( - +
+ + {isDocker ? ( - Docker - - ) : ( - + ) : ( - Sandbox - - )} - + )} + {isDocker ? "Docker" : "Sandbox"} + + {sb.id.slice(0, 8)}
@@ -443,99 +608,67 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { {/* Status */} -
- - {statusDot(displayStatus)} {displayStatus} - - {isProvisioning && trackedJob && ( - - - Starting, job {trackedJob.jobId.slice(0, 8)} - - )} - {sb.error_message && ( - - -

- {sb.error_message} -

-
- -

{sb.error_message}

-
-
- )} -
+
{/* Runtime */} -
-
- {isDocker ? ( - <> - - Managed runtime - - ) : ( - <> - - Cloud sandbox - - )} -
-

- {isDocker - ? "Private Milady infrastructure" - : sb.sandbox_id - ? "Provisioned sandbox" - : "No sandbox yet"} -

-
+ + {isDocker + ? "Managed runtime" + : sb.sandbox_id + ? "Cloud sandbox" + : "Not provisioned"} +
{/* Web UI */} -
- {connectUrl && displayStatus === "running" ? ( - - ) : displayStatus === "running" ? ( - Web UI unavailable - ) : ( - Start agent to open - )} -
+ {connectUrl && displayStatus === "running" ? ( + + ) : ( + + {displayStatus === "running" ? "Unavailable" : "—"} + + )}
{/* Created */} -
-
{formatRelative(sb.created_at)}
+
+

+ {formatRelative(sb.created_at)} +

{sb.last_heartbeat_at && ( -
+

Heartbeat {formatRelative(sb.last_heartbeat_at)} -

+

)}
{/* Actions */} -
- {/* Details */} +
- @@ -545,13 +678,13 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { - {/* Connect via pairing token */} {connectUrl && displayStatus === "running" && ( @@ -562,14 +695,14 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { )} - {/* Resume */} {canStart && ( @@ -580,31 +713,31 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { )} - {/* Suspend */} {canStop && ( - Suspend agent (saves snapshot) + Suspend agent )} - {/* Delete */} @@ -622,6 +755,126 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) {
+ + {/* Mobile card list */} +
+ {filtered.length === 0 ? ( +
+ +

No agents match your filters

+
+ ) : ( + filtered.map((sb) => { + const isDocker = isDockerBacked(sb); + const connectUrl = getConnectUrl(sb); + const trackedJob = poller.getStatus(sb.id); + const isProvisioningActive = poller.isActive(sb.id); + const displayStatus = isProvisioningActive ? "provisioning" : sb.status; + const busy = actionInProgress === sb.id || isProvisioningActive; + const canStart = + ["stopped", "error", "pending", "disconnected"].includes(displayStatus) && !busy; + const canStop = displayStatus === "running" && !busy; + + return ( +
+ {/* Header: name + status */} +
+
+ + {sb.agent_name ?? "Unnamed Agent"} + +
+ + {isDocker ? ( + + ) : ( + + )} + {isDocker ? "Docker" : "Sandbox"} + + + {sb.id.slice(0, 8)} + +
+
+ +
+ + {/* Meta row */} +
+ {formatRelative(sb.created_at)} + {sb.last_heartbeat_at && ( + + Heartbeat {formatRelative(sb.last_heartbeat_at)} + + )} +
+ + {/* Actions */} +
+ + + Details + + + {connectUrl && displayStatus === "running" && ( + + )} + + {canStart && ( + + )} + + {canStop && ( + + )} + + +
+
+ ); + }) + )} +
{/* Delete confirmation */} @@ -629,10 +882,10 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { Delete Agent - + {deleteTargetBusy - ? "This agent is still provisioning. Wait for the job to finish before deleting it." - : "This will delete the agent record and stop any running container. This action cannot be undone."} + ? "This agent is still provisioning. Wait for the job to finish before deleting." + : "This will permanently delete the agent and stop any running container."} @@ -644,7 +897,7 @@ export function MiladySandboxesTable({ sandboxes }: MiladySandboxesTableProps) { disabled={isDeleting || deleteTargetBusy} className="bg-red-500 hover:bg-red-600 text-white disabled:opacity-50" > - Delete + {isDeleting ? "Deleting…" : "Delete"} diff --git a/packages/ui/src/components/layout/user-menu.tsx b/packages/ui/src/components/layout/user-menu.tsx index 71bc484db..c04dad833 100644 --- a/packages/ui/src/components/layout/user-menu.tsx +++ b/packages/ui/src/components/layout/user-menu.tsx @@ -2,6 +2,8 @@ * User menu dropdown component displaying authentication state and user actions. * Shows user avatar, credit balance, and navigation options (settings, API keys, logout). * Handles logout and chat data clearing. + * + * Wrapped in an error boundary to prevent crashes from propagating to the page. */ "use client"; @@ -31,7 +33,7 @@ import { UserCircle, } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { Component, type ErrorInfo, type ReactNode, useEffect, useState } from "react"; import { useCredits } from "@/lib/providers/CreditsProvider"; import { useChatStore } from "@/lib/stores/chat-store"; import { FeedbackModal } from "./feedback-modal"; @@ -43,7 +45,241 @@ interface UserProfile { email: string | null; } -export default function UserMenu() { +// --------------------------------------------------------------------------- +// Error Boundary – catches render errors so the whole page doesn't crash +// --------------------------------------------------------------------------- +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; +} + +class UserMenuErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[UserMenu] Render error caught by boundary:", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback ?? ( + + ) + ); + } + return this.props.children; + } +} + +// --------------------------------------------------------------------------- +// Safe helper to extract user wallet from Privy user object +// --------------------------------------------------------------------------- +function safeGetUserWallet(user: ReturnType["user"]): string | null { + try { + if (!user) return null; + + // Direct wallet property + if (user.wallet?.address && typeof user.wallet.address === "string") { + return user.wallet.address; + } + + // Check linked accounts for wallet + const accounts = user.linkedAccounts; + if (Array.isArray(accounts)) { + for (const account of accounts) { + if ( + account && + account.type === "wallet" && + "address" in account && + typeof (account as { address: unknown }).address === "string" + ) { + return (account as { address: string }).address; + } + } + } + } catch (e) { + console.warn("[UserMenu] Error reading wallet:", e); + } + return null; +} + +// --------------------------------------------------------------------------- +// Safe helper to extract user email from Privy user object +// --------------------------------------------------------------------------- +function safeGetUserEmail(user: ReturnType["user"]): string | null { + try { + if (!user) return null; + + // Direct email property + if (user.email?.address && typeof user.email.address === "string") { + return user.email.address; + } + + // Check linked accounts + const accounts = user.linkedAccounts; + if (Array.isArray(accounts)) { + for (const account of accounts) { + if (!account) continue; + if (account.type === "email" && "address" in account) { + const addr = (account as { address: unknown }).address; + if (typeof addr === "string") return addr; + } + if ("email" in account) { + const email = (account as { email: unknown }).email; + if (typeof email === "string") return email; + } + } + } + } catch (e) { + console.warn("[UserMenu] Error reading email:", e); + } + return null; +} + +// --------------------------------------------------------------------------- +// Safe helper to extract user display name from Privy user object +// --------------------------------------------------------------------------- +function safeGetUserName(user: ReturnType["user"]): string { + try { + if (!user) return "User"; + + // Try Google name + if (user.google?.name && typeof user.google.name === "string") { + return user.google.name; + } + + // Try GitHub username + if (user.github?.username && typeof user.github.username === "string") { + return user.github.username; + } + + // Try Twitter username + if (user.twitter?.username && typeof user.twitter.username === "string") { + return user.twitter.username; + } + + // Try Discord username + if (user.discord?.username && typeof user.discord.username === "string") { + return user.discord.username; + } + + // Try linked accounts + const accounts = user.linkedAccounts; + if (Array.isArray(accounts)) { + for (const account of accounts) { + if (!account) continue; + if ("name" in account && typeof (account as { name: unknown }).name === "string") { + return (account as { name: string }).name; + } + if ( + "username" in account && + typeof (account as { username: unknown }).username === "string" + ) { + return (account as { username: string }).username; + } + } + } + + // Fall back to email prefix + const email = safeGetUserEmail(user); + if (email) { + return email.split("@")[0] || "User"; + } + + // Fall back to truncated wallet + const wallet = safeGetUserWallet(user); + if (wallet && wallet.length >= 10) { + return `${wallet.substring(0, 6)}...${wallet.substring(wallet.length - 4)}`; + } + } catch (e) { + console.warn("[UserMenu] Error reading name:", e); + } + return "User"; +} + +// --------------------------------------------------------------------------- +// Safe helper to get user identifier (wallet or email) +// --------------------------------------------------------------------------- +function safeGetUserIdentifier(user: ReturnType["user"]): string { + try { + const wallet = safeGetUserWallet(user); + if (wallet && wallet.length >= 14) { + return `${wallet.substring(0, 8)}...${wallet.substring(wallet.length - 6)}`; + } + const email = safeGetUserEmail(user); + if (email) return email; + } catch (e) { + console.warn("[UserMenu] Error reading identifier:", e); + } + return "Connected"; +} + +// --------------------------------------------------------------------------- +// Safe helper to get user initials for avatar fallback +// --------------------------------------------------------------------------- +function safeGetInitials( + profile: UserProfile | null, + user: ReturnType["user"], +): string { + try { + const name = profile?.name || safeGetUserName(user); + if (name && name !== "User" && name.trim().length > 0) { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length > 0 && parts[0].length > 0) { + return parts + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + } + const email = profile?.email || safeGetUserEmail(user); + if (email && email.length >= 2) { + return email.slice(0, 2).toUpperCase(); + } + } catch (e) { + console.warn("[UserMenu] Error computing initials:", e); + } + return "U"; +} + +// --------------------------------------------------------------------------- +// Safe credit balance formatter +// --------------------------------------------------------------------------- +function formatCreditBalance(balance: number | null): string { + try { + if (balance === null || balance === undefined) return "0.00"; + const num = Number(balance); + if (Number.isNaN(num) || !Number.isFinite(num)) return "0.00"; + return num.toFixed(2); + } catch { + return "0.00"; + } +} + +// --------------------------------------------------------------------------- +// Main component (inner) +// --------------------------------------------------------------------------- +function UserMenuInner() { const { ready, authenticated, user } = usePrivy(); const pathname = usePathname(); const { logout } = useLogout(); @@ -68,15 +304,15 @@ export default function UserMenu() { const data = await response.json(); if (data.success && data.data) { setUserProfile({ - id: data.data.id, - name: data.data.name, - avatar: data.data.avatar, - email: data.data.email, + id: data.data.id ?? "", + name: data.data.name ?? null, + avatar: data.data.avatar ?? null, + email: data.data.email ?? null, }); } } } catch (error) { - console.error("Failed to fetch user profile:", error); + console.error("[UserMenu] Failed to fetch user profile:", error); } }; @@ -126,116 +362,29 @@ export default function UserMenu() { // Handle sign out const onSignOut = async () => { - // Clear chat data (rooms, entityId, localStorage) - clearChatData(); - - // Call Privy's logout to clear authentication state - await logout(); - - // Use router.replace to avoid browser history pollution - // This prevents back button issues after re-login - router.replace("/"); - }; - - // Get user details - const getUserWallet = () => { - // Check linked accounts for wallet - if (user?.linkedAccounts) { - for (const account of user.linkedAccounts) { - // Type guard: check if account is a wallet - if ( - account.type === "wallet" && - "address" in account && - typeof account.address === "string" - ) { - return account.address; - } - } - } - return null; - }; - - const getUserEmail = () => { - if (user?.email?.address) { - return user.email.address; - } - // Check linked accounts for email - if (user?.linkedAccounts) { - for (const account of user.linkedAccounts) { - // Type guard: check if account has email property - if ("address" in account && account.type === "email") { - return account.address; - } - if ("email" in account && typeof account.email === "string") { - return account.email; - } - } - } - return null; - }; + try { + // Clear chat data (rooms, entityId, localStorage) + clearChatData(); - const getUserName = () => { - // Try to get name from various sources - if (user?.google?.name) { - return user.google.name; - } - if (user?.github?.username) { - return user.github.username; - } - if (user?.linkedAccounts) { - for (const account of user.linkedAccounts) { - // Type guard: check if account has name property - if ("name" in account && typeof account.name === "string") { - return account.name; - } - // Type guard: check if account has username property - if ("username" in account && typeof account.username === "string") { - return account.username; - } - } - } - // Fall back to email or wallet - const email = getUserEmail(); - if (email) { - return email.split("@")[0]; - } - const wallet = getUserWallet(); - if (wallet) { - return `${wallet.substring(0, 6)}...${wallet.substring(wallet.length - 4)}`; - } - return "User"; - }; + // Call Privy's logout to clear authentication state + await logout(); - const getUserIdentifier = () => { - // Show wallet (preferred) or email - const wallet = getUserWallet(); - if (wallet) { - return `${wallet.substring(0, 8)}...${wallet.substring(wallet.length - 6)}`; + // Use router.replace to avoid browser history pollution + // This prevents back button issues after re-login + router.replace("/"); + } catch (error) { + console.error("[UserMenu] Error during sign out:", error); + // Still try to redirect even if logout partially fails + router.replace("/"); } - const email = getUserEmail(); - if (email) { - return email; - } - return "No identifier"; }; - // Get user initials for fallback - const getInitials = () => { - const name = userProfile?.name || getUserName(); - if (name && name !== "User") { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2); - } - const email = userProfile?.email || getUserEmail(); - if (email) { - return email.slice(0, 2).toUpperCase(); - } - return "U"; - }; + // Pre-compute all display values safely (outside JSX to keep render clean) + const displayName = safeGetUserName(user); + const displayIdentifier = safeGetUserIdentifier(user); + const initials = safeGetInitials(userProfile, user); + const feedbackName = userProfile?.name || displayName; + const feedbackEmail = userProfile?.email || safeGetUserEmail(user) || ""; // Signed in state return ( @@ -255,7 +404,7 @@ export default function UserMenu() { /> )} - {getInitials()} + {initials} @@ -263,8 +412,8 @@ export default function UserMenu() {
-

{getUserName()}

-

{getUserIdentifier()}

+

{displayName}

+

{displayIdentifier}

@@ -282,7 +431,7 @@ export default function UserMenu() { > - ${creditBalance !== null ? Number(creditBalance).toFixed(2) : "0.00"} + ${formatCreditBalance(creditBalance)} balance @@ -326,9 +475,20 @@ export default function UserMenu() { ); } + +// --------------------------------------------------------------------------- +// Exported component – wrapped in error boundary +// --------------------------------------------------------------------------- +export default function UserMenu() { + return ( + + + + ); +} diff --git a/tsconfig.json b/tsconfig.json index 0f265733e..e3135eb90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,7 +44,9 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ".next-build/types/**/*.ts", - ".next-build/dev/types/**/*.ts" + ".next-build/dev/types/**/*.ts", + ".next-dev/types/**/*.ts", + ".next-dev/dev/types/**/*.ts" ], "exclude": [ "node_modules", diff --git a/vercel.json b/vercel.json index 5a109701b..c1f06b407 100644 --- a/vercel.json +++ b/vercel.json @@ -6,6 +6,10 @@ "path": "/api/cron/container-billing", "schedule": "0 0 * * *" }, + { + "path": "/api/cron/milady-billing", + "schedule": "0 * * * *" + }, { "path": "/api/cron/social-automation", "schedule": "*/5 * * * *"