From d0acbcc41843154b9e9fff9c9c1ce097314e70e6 Mon Sep 17 00:00:00 2001 From: Sol Date: Fri, 20 Mar 2026 13:11:43 +0000 Subject: [PATCH 01/18] fix(cloud): set MILADY_CLOUD_PROVISIONED env var for cloud containers This enables the container's server.ts to skip pairing and onboarding screens for cloud-provisioned agents. The platform handles authentication via the pairing token flow, so users should go directly to the chat UI. Works in conjunction with milaidy-dev fix/cloud-agent-auth-flow which checks these env vars and returns pairingEnabled: false + onboarding complete: true for cloud containers. --- packages/lib/services/docker-sandbox-provider.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/lib/services/docker-sandbox-provider.ts b/packages/lib/services/docker-sandbox-provider.ts index 6de4eb931..594632c86 100644 --- a/packages/lib/services/docker-sandbox-provider.ts +++ b/packages/lib/services/docker-sandbox-provider.ts @@ -435,6 +435,10 @@ export class DockerSandboxProvider implements SandboxProvider { JWT_SECRET: environmentVars.JWT_SECRET || crypto.randomUUID(), // Allow the agent subdomain origin so the browser can call the API. MILADY_ALLOWED_ORIGINS: `https://${agentId}.${getAgentBaseDomain()}`, + // Cloud-provisioned containers skip pairing and onboarding UI — + // the platform handles authentication and agent setup. + MILADY_CLOUD_PROVISIONED: "1", + ELIZA_CLOUD_PROVISIONED: "1", }; // 6. SSH to node, ensure volume dir, pull image, register in Steward, From 4cab4b8f2990f769033bc8b19dfed154c1162741 Mon Sep 17 00:00:00 2001 From: Sol Date: Sat, 28 Mar 2026 17:53:14 +0000 Subject: [PATCH 02/18] fix: use agent:latest instead of hardcoded version --- packages/lib/services/docker-sandbox-provider.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/lib/services/docker-sandbox-provider.ts b/packages/lib/services/docker-sandbox-provider.ts index 594632c86..6de4eb931 100644 --- a/packages/lib/services/docker-sandbox-provider.ts +++ b/packages/lib/services/docker-sandbox-provider.ts @@ -435,10 +435,6 @@ export class DockerSandboxProvider implements SandboxProvider { JWT_SECRET: environmentVars.JWT_SECRET || crypto.randomUUID(), // Allow the agent subdomain origin so the browser can call the API. MILADY_ALLOWED_ORIGINS: `https://${agentId}.${getAgentBaseDomain()}`, - // Cloud-provisioned containers skip pairing and onboarding UI — - // the platform handles authentication and agent setup. - MILADY_CLOUD_PROVISIONED: "1", - ELIZA_CLOUD_PROVISIONED: "1", }; // 6. SSH to node, ensure volume dir, pull image, register in Steward, From 5bc67b42e0b8a478fa95d992c2001884fcf4c7a3 Mon Sep 17 00:00:00 2001 From: Sol Date: Mon, 30 Mar 2026 01:15:54 +0000 Subject: [PATCH 03/18] fix(milady): wallet proxy, Neon branches, remove provisioning cron, de-slop UI Infrastructure: - Add wallet proxy route (/api/v1/milady/agents/[id]/api/wallet/[...path]) Proxies wallet/steward requests to agent's REST API with proper auth - Switch Neon provisioning from projects to branches (fixes 100-project limit) New agents get branches within shared parent project - Add cleanup-stuck-provisioning cron (resets agents stuck >10min) - Remove process-provisioning-jobs Vercel cron (VPS worker handles this) - Add milady.ai to redirect allowlists for Stripe checkout Dashboard UI: - Agent detail: add Wallet, Transactions, Policies tabs - Billing: replace credit pack cards with custom amount + card/crypto - Agent cards: deterministic character images instead of identical fallbacks - De-slop text across all dashboard pages - Create dialog: cleaner copy, Deploy button - Pricing: tighter descriptions --- .../cron/cleanup-stuck-provisioning/route.ts | 166 ++++++++++ .../stripe/create-checkout-session/route.ts | 2 + .../[agentId]/api/wallet/[...path]/route.ts | 96 ++++++ app/dashboard/billing/page.tsx | 4 - app/dashboard/milady/agents/[id]/page.tsx | 7 + app/dashboard/milady/page.tsx | 3 - lib/milady-web-ui.ts | 89 +++++ packages/lib/security/redirect-validation.ts | 2 + packages/lib/services/milady-sandbox.ts | 105 +++++- packages/lib/services/neon-client.ts | 72 ++++ packages/lib/utils/default-avatar.ts | 8 +- .../ui/src/components/agents/agent-card.tsx | 8 +- .../billing/billing-page-client.tsx | 301 ++++++++++++----- .../billing/billing-page-wrapper.tsx | 35 +- .../billing/milady-pricing-info.tsx | 7 +- .../ui/src/components/chat/eliza-avatar.tsx | 2 +- .../create-milady-sandbox-dialog.tsx | 22 +- .../containers/milady-agent-tabs.tsx | 51 +++ .../containers/milady-policies-section.tsx | 166 ++++++++++ .../containers/milady-pricing-banner.tsx | 8 +- .../containers/milady-sandboxes-table.tsx | 7 +- .../milady-transactions-section.tsx | 277 ++++++++++++++++ .../containers/milady-wallet-section.tsx | 313 ++++++++++++++++++ .../dashboard/dashboard-action-cards.tsx | 8 +- tests/unit/milady-web-ui.test.ts | 94 ++++++ vercel.json | 8 +- 26 files changed, 1683 insertions(+), 178 deletions(-) create mode 100644 app/api/cron/cleanup-stuck-provisioning/route.ts create mode 100644 app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts create mode 100644 lib/milady-web-ui.ts create mode 100644 packages/ui/src/components/containers/milady-agent-tabs.tsx create mode 100644 packages/ui/src/components/containers/milady-policies-section.tsx create mode 100644 packages/ui/src/components/containers/milady-transactions-section.tsx create mode 100644 packages/ui/src/components/containers/milady-wallet-section.tsx create mode 100644 tests/unit/milady-web-ui.test.ts diff --git a/app/api/cron/cleanup-stuck-provisioning/route.ts b/app/api/cron/cleanup-stuck-provisioning/route.ts new file mode 100644 index 000000000..c22fac959 --- /dev/null +++ b/app/api/cron/cleanup-stuck-provisioning/route.ts @@ -0,0 +1,166 @@ +/** + * Cleanup Stuck Provisioning Cron + * + * Detects and recovers agents that are stuck in "provisioning" status with no + * active job to drive them forward. This happens when: + * + * 1. A container crashes while the agent is running, and something (e.g. + * the Next.js sync provision path) sets status = 'provisioning' but + * never creates a jobs-table record. + * 2. A provision job is enqueued but the worker invocation dies before it + * can claim the record — in this case the job-recovery logic in + * process-provisioning-jobs will already handle it, but we add a belt- + * and-suspenders check here for the no-job case. + * + * Criteria for "stuck": + * - status = 'provisioning' + * - updated_at < NOW() - 10 minutes (well beyond any normal provision time) + * - no jobs row in ('pending', 'in_progress') whose data->>'agentId' matches + * + * Action: set status = 'error', write a descriptive error_message so the user + * can see what happened and re-provision. + * + * Schedule: every 5 minutes ("* /5 * * * *" in vercel.json) + * Protected by CRON_SECRET. + */ + +import { and, eq, lt, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { dbWrite } from "@/db/client"; +import { jobs } from "@/db/schemas/jobs"; +import { miladySandboxes } from "@/db/schemas/milady-sandboxes"; +import { verifyCronSecret } from "@/lib/api/cron-auth"; +import { logger } from "@/lib/utils/logger"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +/** How long an agent must be stuck before we reset it (ms). */ +const STUCK_THRESHOLD_MINUTES = 10; + +interface CleanupResult { + agentId: string; + agentName: string | null; + organizationId: string; + stuckSinceMinutes: number; +} + +async function handleCleanupStuckProvisioning(request: NextRequest) { + try { + const authError = verifyCronSecret(request, "[Cleanup Stuck Provisioning]"); + if (authError) return authError; + + logger.info("[Cleanup Stuck Provisioning] Starting scan"); + + const cutoff = new Date(Date.now() - STUCK_THRESHOLD_MINUTES * 60 * 1000); + + /** + * Single UPDATE … RETURNING query: + * + * UPDATE milady_sandboxes + * SET status = 'error', + * error_message = '...', + * updated_at = NOW() + * WHERE status = 'provisioning' + * AND updated_at < :cutoff + * AND NOT EXISTS ( + * SELECT 1 FROM jobs + * WHERE jobs.data->>'agentId' = milady_sandboxes.id::text + * AND jobs.status IN ('pending', 'in_progress') + * ) + * RETURNING id, agent_name, organization_id, updated_at + * + * We run this inside dbWrite so it lands on the primary replica and is + * subject to the write path's connection pool. + */ + const stuckAgents = await dbWrite + .update(miladySandboxes) + .set({ + status: "error", + error_message: + "Agent was stuck in provisioning state with no active provisioning job. " + + "This usually means a container crashed before the provisioning job could be created, " + + "or the job was lost. Please try starting the agent again.", + updated_at: new Date(), + }) + .where( + and( + eq(miladySandboxes.status, "provisioning"), + lt(miladySandboxes.updated_at, cutoff), + sql`NOT EXISTS ( + SELECT 1 FROM ${jobs} + WHERE ${jobs.data}->>'agentId' = ${miladySandboxes.id}::text + AND ${jobs.status} IN ('pending', 'in_progress') + )`, + ), + ) + .returning({ + agentId: miladySandboxes.id, + agentName: miladySandboxes.agent_name, + organizationId: miladySandboxes.organization_id, + updatedAt: miladySandboxes.updated_at, + }); + + const results: CleanupResult[] = stuckAgents.map((row) => ({ + agentId: row.agentId, + agentName: row.agentName, + organizationId: row.organizationId, + // updatedAt is now the new timestamp; we can't recover the old one here, + // but the log message below captures the count. + stuckSinceMinutes: STUCK_THRESHOLD_MINUTES, // minimum — actual may be longer + })); + + if (results.length > 0) { + logger.warn("[Cleanup Stuck Provisioning] Reset stuck agents", { + count: results.length, + agents: results.map((r) => ({ + agentId: r.agentId, + agentName: r.agentName, + organizationId: r.organizationId, + })), + }); + } else { + logger.info("[Cleanup Stuck Provisioning] No stuck agents found"); + } + + return NextResponse.json({ + success: true, + data: { + cleaned: results.length, + thresholdMinutes: STUCK_THRESHOLD_MINUTES, + timestamp: new Date().toISOString(), + agents: results, + }, + }); + } catch (error) { + logger.error( + "[Cleanup Stuck Provisioning] Failed:", + error instanceof Error ? error.message : String(error), + ); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Cleanup failed", + }, + { status: 500 }, + ); + } +} + +/** + * GET /api/cron/cleanup-stuck-provisioning + * Cron endpoint — protected by CRON_SECRET (Vercel passes it automatically). + */ +export async function GET(request: NextRequest) { + return handleCleanupStuckProvisioning(request); +} + +/** + * POST /api/cron/cleanup-stuck-provisioning + * Manual trigger for testing — same auth requirement. + */ +export async function POST(request: NextRequest) { + return handleCleanupStuckProvisioning(request); +} diff --git a/app/api/stripe/create-checkout-session/route.ts b/app/api/stripe/create-checkout-session/route.ts index 0d6588c31..e0922c232 100644 --- a/app/api/stripe/create-checkout-session/route.ts +++ b/app/api/stripe/create-checkout-session/route.ts @@ -19,6 +19,8 @@ const ALLOWED_ORIGINS = [ process.env.NEXT_PUBLIC_APP_URL, "http://localhost:3000", "http://localhost:3001", + "https://milady.ai", + "https://www.milady.ai", ].filter(Boolean) as string[]; // Configurable currency diff --git a/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts new file mode 100644 index 000000000..67f044bdf --- /dev/null +++ b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorToResponse } from "@/lib/api/errors"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { miladySandboxService } from "@/lib/services/milady-sandbox"; +import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors"; + +export const dynamic = "force-dynamic"; + +const CORS_METHODS = "GET, POST, OPTIONS"; + +export function OPTIONS() { + return handleCorsOptions(CORS_METHODS); +} + +/** + * Proxy handler for both GET and POST wallet requests. + * + * Incoming URL pattern: + * /api/v1/milady/agents/[agentId]/api/wallet/[...path] + * + * Proxied to the agent at: + * {bridge_url}/api/wallet/{path} + * + * This allows the homepage dashboard (via CloudApiClient) to reach wallet + * endpoints on agents running in Docker containers, authenticated by the + * cloud API key and authorization-checked against the user's organization. + */ +async function proxyToAgent( + request: NextRequest, + params: Promise<{ agentId: string; path: string[] }>, + method: "GET" | "POST", +): Promise { + try { + const { user } = await requireAuthOrApiKeyWithOrg(request); + const { agentId, path } = await params; + + // Reconstruct the wallet sub-path (e.g. ["steward-policies"] → "steward-policies") + const walletPath = path.join("/"); + + // Forward the raw query string (e.g. ?limit=20 for steward-tx-records) + const query = request.nextUrl.search ? request.nextUrl.search.slice(1) : undefined; + + // Read POST body if present + let body: string | null = null; + if (method === "POST") { + body = await request.text(); + } + + const agentResponse = await miladySandboxService.proxyWalletRequest( + agentId, + user.organization_id, + walletPath, + method, + body, + query, + ); + + if (!agentResponse) { + return applyCorsHeaders( + NextResponse.json( + { success: false, error: "Agent is not running or unreachable" }, + { status: 503 }, + ), + CORS_METHODS, + ); + } + + // Forward status + body from agent response directly + const responseBody = await agentResponse.text(); + const contentType = agentResponse.headers.get("content-type") ?? "application/json"; + + return applyCorsHeaders( + new Response(responseBody, { + status: agentResponse.status, + headers: { "Content-Type": contentType }, + }), + CORS_METHODS, + ); + } catch (error) { + return applyCorsHeaders(errorToResponse(error), CORS_METHODS); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ agentId: string; path: string[] }> }, +) { + return proxyToAgent(request, params, "GET"); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ agentId: string; path: string[] }> }, +) { + return proxyToAgent(request, params, "POST"); +} diff --git a/app/dashboard/billing/page.tsx b/app/dashboard/billing/page.tsx index 03e296847..3ce92a7f2 100644 --- a/app/dashboard/billing/page.tsx +++ b/app/dashboard/billing/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { requireAuth } from "@/lib/auth"; -import { creditsService } from "@/lib/services/credits"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { BillingPageWrapper } from "@/packages/ui/src/components/billing/billing-page-wrapper"; @@ -14,7 +13,6 @@ export const dynamic = "force-dynamic"; /** * Billing page for managing credits and billing information. - * Displays available credit packs and current credit balance. * * @param searchParams - Search parameters, including optional `canceled` flag for canceled checkout sessions. * @returns The rendered billing page wrapper component. @@ -25,7 +23,6 @@ export default async function BillingPage({ searchParams: Promise<{ canceled?: string }>; }) { const user = await requireAuth(); - const creditPacks = await creditsService.listActiveCreditPacks(); const params = await searchParams; // Fetch agent counts for runway estimation (best-effort) @@ -45,7 +42,6 @@ export default async function BillingPage({ return ( + {/* ── Tabs: Overview | Wallet | Transactions | Policies ── */} + + {/* ── Overview tab content ── */} + {/* ── Error message ── */} {agent.error_message && (
@@ -358,6 +363,8 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { nodeId={agent.node_id} /> )} + +
); } diff --git a/app/dashboard/milady/page.tsx b/app/dashboard/milady/page.tsx index 5801abcc6..6fbe20b86 100644 --- a/app/dashboard/milady/page.tsx +++ b/app/dashboard/milady/page.tsx @@ -51,9 +51,6 @@ export default async function MiladyDashboardPage() {

Milady Instances

-

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

; + +interface MiladyWebUiUrlOptions { + baseDomain?: string | null; + allowExampleFallback?: boolean; + path?: string; +} + +function normalizeAgentBaseDomain(baseDomain?: string | null): string | null { + if (!baseDomain) { + return null; + } + + const normalizedDomain = baseDomain + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/\.+$/, ""); + + return normalizedDomain || null; +} + +function applyPath(baseUrl: string, path = "/"): string { + if (!path || path === "/") { + return baseUrl; + } + + const url = new URL(baseUrl); + const normalizedPath = new URL(path, "https://milady.local"); + + url.pathname = normalizedPath.pathname; + url.search = normalizedPath.search; + url.hash = normalizedPath.hash; + + return url.toString(); +} + +export function getMiladyAgentPublicWebUiUrl( + sandbox: Pick, + options: MiladyWebUiUrlOptions = {}, +): string | null { + if (!sandbox.headscale_ip) { + return null; + } + + const normalizedDomain = normalizeAgentBaseDomain( + options.baseDomain ?? + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN ?? + (options.allowExampleFallback ? DEFAULT_AGENT_BASE_DOMAIN : null), + ); + if (!normalizedDomain) { + return null; + } + + return applyPath(`https://${sandbox.id}.${normalizedDomain}`, options.path); +} + +export function getMiladyAgentDirectWebUiUrl( + sandbox: MiladyWebUiTarget, + options: Pick = {}, +): string | null { + if (!sandbox.headscale_ip) { + return null; + } + + const port = sandbox.web_ui_port ?? sandbox.bridge_port; + if (!port) { + return null; + } + + return applyPath(`http://${sandbox.headscale_ip}:${port}`, options.path); +} + +export function getPreferredMiladyAgentWebUiUrl( + sandbox: MiladyWebUiTarget, + options: MiladyWebUiUrlOptions = {}, +): string | null { + return ( + getMiladyAgentPublicWebUiUrl(sandbox, options) ?? + getMiladyAgentDirectWebUiUrl(sandbox, options) + ); +} diff --git a/packages/lib/security/redirect-validation.ts b/packages/lib/security/redirect-validation.ts index 1f34f93d3..42ba529ce 100644 --- a/packages/lib/security/redirect-validation.ts +++ b/packages/lib/security/redirect-validation.ts @@ -5,6 +5,8 @@ const DEFAULT_PLATFORM_REDIRECT_ORIGINS = [ "http://localhost:3001", "http://127.0.0.1:3000", "http://127.0.0.1:3001", + "https://milady.ai", + "https://www.milady.ai", ]; function isHttpUrl(url: URL): boolean { diff --git a/packages/lib/services/milady-sandbox.ts b/packages/lib/services/milady-sandbox.ts index c1574f233..b513ba794 100644 --- a/packages/lib/services/milady-sandbox.ts +++ b/packages/lib/services/milady-sandbox.ts @@ -32,6 +32,9 @@ import { getNeonClient, NeonClientError } from "./neon-client"; import { JOB_TYPES } from "./provisioning-jobs"; import { createSandboxProvider, type SandboxProvider } from "./sandbox-provider"; +/** Shared Neon project used as branch parent for per-agent databases. */ +const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID || "holy-rain-20729618"; + export interface CreateAgentParams { organizationId: string; userId: string; @@ -206,10 +209,11 @@ export class MiladySandboxService { } if (rec.neon_project_id) { try { - await this.cleanupNeon(rec.neon_project_id); + await this.cleanupNeon(rec.neon_project_id, rec.neon_branch_id); } catch (e) { logger.warn("[milady-sandbox] Neon cleanup failed during delete", { projectId: rec.neon_project_id, + branchId: rec.neon_branch_id, error: e instanceof Error ? e.message : String(e), }); return { @@ -603,6 +607,74 @@ export class MiladySandboxService { } } + /** + * Proxy an HTTP request to the agent's wallet API endpoint. + * Used by the cloud backend to forward wallet/steward requests from the dashboard. + * + * @param agentId - The sandbox record ID + * @param orgId - The organization ID (authorization) + * @param walletPath - Path after `/api/wallet/`, e.g. "steward-policies" + * @param method - HTTP method ("GET" | "POST") + * @param body - Optional request body (for POST requests) + * @param query - Optional query string (e.g. "limit=20") + * @returns The raw fetch Response, or null if the sandbox is not running + */ + async proxyWalletRequest( + agentId: string, + orgId: string, + walletPath: string, + method: "GET" | "POST", + body?: string | null, + query?: string, + ): Promise { + const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId); + if (!rec?.bridge_url) { + logger.warn("[milady-sandbox] Wallet proxy to non-running sandbox", { + agentId, + walletPath, + }); + return null; + } + + try { + const fullPath = `/api/wallet/${walletPath}${query ? `?${query}` : ""}`; + // Wallet REST API runs on web_ui_port, not the bridge (JSON-RPC) port. + // Build the URL from bridge_url but swap to web_ui_port if available. + let endpoint: string; + if (rec.web_ui_port && rec.node_id) { + // Extract host from bridge_url and use web_ui_port + const bridgeUrl = new URL(rec.bridge_url); + endpoint = `${bridgeUrl.protocol}//${bridgeUrl.hostname}:${rec.web_ui_port}${fullPath}`; + } else { + endpoint = await this.getSafeBridgeEndpoint(rec, fullPath); + } + // Agent REST API requires MILADY_API_TOKEN for auth + const apiToken = (rec as Record).environment_vars + ? ((rec as Record).environment_vars as Record)?.MILADY_API_TOKEN + : undefined; + const headers: Record = { "Content-Type": "application/json" }; + if (apiToken) { + headers["Authorization"] = `Bearer ${apiToken}`; + } + const fetchOptions: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(30_000), + }; + if (method === "POST" && body != null) { + fetchOptions.body = body; + } + return await fetch(endpoint, fetchOptions); + } catch (error) { + logger.warn("[milady-sandbox] Wallet proxy request failed", { + agentId, + walletPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + async bridgeStream(agentId: string, orgId: string, rpc: BridgeRequest): Promise { const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId); if (!rec?.bridge_url) return null; @@ -941,11 +1013,11 @@ export class MiladySandboxService { database_status: "provisioning", }); const neon = getNeonClient(); - const name = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`; - const result = await neon.createProject({ name, region: "aws-us-east-1" }); + const branchName = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`; + const result = await neon.createBranch(NEON_PARENT_PROJECT_ID, branchName); const updated = await miladySandboxesRepository.update(rec.id, { - neon_project_id: result.projectId, + neon_project_id: NEON_PARENT_PROJECT_ID, neon_branch_id: result.branchId, database_uri: result.connectionUri, database_status: "ready", @@ -953,12 +1025,13 @@ export class MiladySandboxService { }); if (!updated) { - logger.error("[milady-sandbox] DB update failed after Neon creation, cleaning orphan", { - projectId: result.projectId, + logger.error("[milady-sandbox] DB update failed after Neon branch creation, cleaning orphan", { + projectId: NEON_PARENT_PROJECT_ID, + branchId: result.branchId, }); - await neon.deleteProject(result.projectId).catch((e) => { - logger.error("[milady-sandbox] Orphan Neon project cleanup failed", { - projectId: result.projectId, + await neon.deleteBranch(NEON_PARENT_PROJECT_ID, result.branchId).catch((e) => { + logger.error("[milady-sandbox] Orphan Neon branch cleanup failed", { + branchId: result.branchId, error: e instanceof Error ? e.message : String(e), }); }); @@ -971,13 +1044,21 @@ export class MiladySandboxService { return { success: true, connectionUri: result.connectionUri }; } - private async cleanupNeon(projectId: string) { + private async cleanupNeon(projectId: string, branchId?: string | null) { + const neon = getNeonClient(); try { - await getNeonClient().deleteProject(projectId); + if (projectId === NEON_PARENT_PROJECT_ID && branchId) { + // New branch-based: delete the branch, not the project + await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId); + } else { + // Legacy project-based: delete the entire project + await neon.deleteProject(projectId); + } } catch (error) { if (error instanceof NeonClientError && error.statusCode === 404) { - logger.info("[milady-sandbox] Neon project already absent during cleanup", { + logger.info("[milady-sandbox] Neon resource already absent during cleanup", { projectId, + branchId, }); return; } diff --git a/packages/lib/services/neon-client.ts b/packages/lib/services/neon-client.ts index c15917cdc..6b8029faf 100644 --- a/packages/lib/services/neon-client.ts +++ b/packages/lib/services/neon-client.ts @@ -145,6 +145,78 @@ export class NeonClient { return result; } + /** + * Create a new branch within an existing Neon project. + * Each branch is an isolated copy-on-write fork with its own connection URI. + * + * @param projectId Parent project ID to branch from + * @param branchName Human-readable branch name + * @returns Branch details including connection URI + * @throws NeonClientError on API failure + */ + async createBranch(projectId: string, branchName: string): Promise { + logger.info("Creating Neon branch", { projectId, branchName }); + + const response = await this.fetchWithRetry(`/projects/${projectId}/branches`, { + method: "POST", + body: JSON.stringify({ + branch: { name: branchName }, + endpoints: [{ type: "read_write" }], + }), + }); + + const data = await response.json(); + const branch = data.branch; + const connectionUri = data.connection_uris?.[0]?.connection_uri; + + if (!connectionUri) { + throw new NeonClientError("No connection URI in Neon branch response", "MISSING_CONNECTION_URI"); + } + + let host: string; + try { + const uriWithoutProtocol = connectionUri.replace("postgres://", ""); + const afterAt = uriWithoutProtocol.split("@")[1]; + host = afterAt.split("/")[0]; + } catch { + host = "unknown"; + } + + const result: NeonProjectResult = { + projectId, + branchId: branch.id, + connectionUri, + host, + database: "neondb", + region: "aws-us-east-1", + }; + + logger.info("Neon branch created", { + projectId, + branchId: result.branchId, + host: result.host, + }); + + return result; + } + + /** + * Delete a branch from a Neon project. + * + * @param projectId Parent project ID + * @param branchId Branch ID to delete + * @throws NeonClientError on API failure + */ + async deleteBranch(projectId: string, branchId: string): Promise { + logger.info("Deleting Neon branch", { projectId, branchId }); + + await this.fetchWithRetry(`/projects/${projectId}/branches/${branchId}`, { + method: "DELETE", + }); + + logger.info("Neon branch deleted", { projectId, branchId }); + } + /** * Delete a Neon project and all its data. * diff --git a/packages/lib/utils/default-avatar.ts b/packages/lib/utils/default-avatar.ts index 950863a61..0e581a5ca 100644 --- a/packages/lib/utils/default-avatar.ts +++ b/packages/lib/utils/default-avatar.ts @@ -166,11 +166,15 @@ export function getAvailableAvatarStyles(): Array<{ * Ensure a character has an avatar URL, using the fallback if needed. * * @param avatarUrl - The character's current avatar URL (may be null/undefined/empty) - * @returns A valid avatar URL (either the original or the fallback) + * @param name - Optional character name for deterministic avatar selection + * @returns A valid avatar URL (either the original or a deterministic/fallback avatar) */ -export function ensureAvatarUrl(avatarUrl: string | null | undefined): string { +export function ensureAvatarUrl(avatarUrl: string | null | undefined, name?: string): string { if (avatarUrl && avatarUrl.trim() !== "") { return avatarUrl; } + if (name) { + return generateDefaultAvatarUrl(name); + } return DEFAULT_AVATAR; } diff --git a/packages/ui/src/components/agents/agent-card.tsx b/packages/ui/src/components/agents/agent-card.tsx index 1822fc5f4..ba695b78f 100644 --- a/packages/ui/src/components/agents/agent-card.tsx +++ b/packages/ui/src/components/agents/agent-card.tsx @@ -304,11 +304,11 @@ export function AgentCard({
{agent.name}
@@ -493,7 +493,7 @@ export function AgentCard({ {agent.name} {/* Gradient overlay */} diff --git a/packages/ui/src/components/billing/billing-page-client.tsx b/packages/ui/src/components/billing/billing-page-client.tsx index 6fd53dc43..420dd08ce 100644 --- a/packages/ui/src/components/billing/billing-page-client.tsx +++ b/packages/ui/src/components/billing/billing-page-client.tsx @@ -1,116 +1,255 @@ /** - * Billing page client component for purchasing credit packs. - * Displays available credit packs and handles Stripe checkout session creation. + * Billing page client component for adding funds via card or crypto. * * @param props - Billing page configuration - * @param props.creditPacks - Array of available credit packs * @param props.currentCredits - User's current credit balance */ "use client"; -import { useEffect, useState } from "react"; +import { BrandCard, CornerBrackets, Input } from "@elizaos/cloud-ui"; +import { AlertCircle, CheckCircle, CreditCard, Loader2, Wallet } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import type { CryptoStatusResponse } from "@/app/api/crypto/status/route"; import { trackEvent } from "@/lib/analytics/posthog"; -import { CreditPackCard } from "./credit-pack-card"; - -interface CreditPack { - id: string; - name: string; - description: string | null; - credits: number; - price_cents: number; - stripe_price_id: string; - is_active: boolean; - sort_order: number; -} interface BillingPageClientProps { - creditPacks: CreditPack[]; currentCredits: number; } -export function BillingPageClient({ creditPacks, currentCredits }: BillingPageClientProps) { - const [loading, setLoading] = useState(null); +const AMOUNT_LIMITS = { + MIN: 1, + MAX: 10000, +} as const; + +type PaymentMethod = "card" | "crypto"; + +export function BillingPageClient({ currentCredits }: BillingPageClientProps) { + const [purchaseAmount, setPurchaseAmount] = useState(""); + const [isProcessingCheckout, setIsProcessingCheckout] = useState(false); + const [paymentMethod, setPaymentMethod] = useState("card"); + const [cryptoStatus, setCryptoStatus] = useState(null); + const [balance, setBalance] = useState(currentCredits); + + const fetchCryptoStatus = useCallback(async () => { + try { + const response = await fetch("/api/crypto/status"); + if (response.ok) { + const data: CryptoStatusResponse = await response.json(); + setCryptoStatus(data); + } + } catch { + // crypto status unavailable, card-only mode + } + }, []); - // Track billing page viewed - only on initial mount useEffect(() => { - trackEvent("billing_page_viewed", { - current_credits: currentCredits, - available_packs: creditPacks.length, + trackEvent("billing_page_viewed", { current_credits: currentCredits, available_packs: 0 }); + fetchCryptoStatus(); + }, [currentCredits, fetchCryptoStatus]); + + const handleAddFunds = async () => { + const amount = parseFloat(purchaseAmount); + + trackEvent("checkout_attempted", { + payment_method: paymentMethod === "card" ? "stripe" : "crypto", + amount: Number.isNaN(amount) ? undefined : amount, + organization_id: "", }); - }, [creditPacks.length, currentCredits]); - - const handlePurchase = async (creditPackId: string) => { - setLoading(creditPackId); - - // Find the pack being purchased for tracking - const pack = creditPacks.find((p) => p.id === creditPackId); - if (pack) { - trackEvent("credits_purchase_started", { - pack_id: creditPackId, - pack_name: pack.name, - credits: pack.credits, - price_cents: pack.price_cents, - }); + + if (isNaN(amount) || amount < AMOUNT_LIMITS.MIN) { + toast.error(`Minimum amount is $${AMOUNT_LIMITS.MIN}`); + return; + } + if (amount > AMOUNT_LIMITS.MAX) { + toast.error(`Maximum amount is $${AMOUNT_LIMITS.MAX}`); + return; } - try { - const response = await fetch("/api/stripe/create-checkout-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ creditPackId }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || "Failed to create checkout session"); + setIsProcessingCheckout(true); + + if (paymentMethod === "crypto") { + try { + const response = await fetch("/api/crypto/payments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount }), + }); + + if (!response.ok) { + const errorData = await response.json(); + toast.error(errorData.error || "Failed to create payment"); + setIsProcessingCheckout(false); + return; + } + + const data = await response.json(); + + if (!data.payLink) { + toast.error("No payment link returned"); + setIsProcessingCheckout(false); + return; + } + + toast.success("Redirecting to payment page..."); + window.location.href = data.payLink; + } catch { + toast.error("Failed to create crypto payment"); + setIsProcessingCheckout(false); } + return; + } + + // Card / Stripe + const response = await fetch("/api/stripe/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount, returnUrl: "billing" }), + }); - const { url } = await response.json(); + if (!response.ok) { + const errorData = await response.json(); + toast.error(errorData.error || "Failed to create checkout session"); + setIsProcessingCheckout(false); + return; + } - if (!url) { - throw new Error("No checkout URL returned"); - } + const { url } = await response.json(); - window.location.href = url; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Purchase failed"; - toast.error(errorMessage); - } finally { - setLoading(null); + if (!url) { + toast.error("No checkout URL returned"); + setIsProcessingCheckout(false); + return; } + + window.location.href = url; }; - // Determine which pack is popular (middle one) - const middleIndex = Math.floor(creditPacks.length / 2); + const amountValue = parseFloat(purchaseAmount) || 0; + const isValidAmount = amountValue >= AMOUNT_LIMITS.MIN && amountValue <= AMOUNT_LIMITS.MAX; return ( -
-
+ + + +
+ {/* Header */}
-

Balance

-
${Number(currentCredits).toFixed(2)}
+
+
+

Add Funds

+
+
+ Balance + ${balance.toFixed(2)} +
+
+ + {/* Payment Method Toggle (only shown when crypto is enabled) */} + {cryptoStatus?.enabled && ( +
+ + +
+ )} + + {/* Amount Input + Button */} +
+
+ + $ + + setPurchaseAmount(e.target.value)} + className="pl-7 backdrop-blur-sm bg-[rgba(29,29,29,0.3)] border border-[rgba(255,255,255,0.15)] text-[#e1e1e1] h-11 font-mono" + placeholder="0.00" + disabled={isProcessingCheckout} + /> +
+ +
-
-
- {creditPacks.map((pack, index) => ( - - ))} + {/* Validation feedback */} + {purchaseAmount && !isValidAmount && ( +
+ + + {amountValue < AMOUNT_LIMITS.MIN + ? `Minimum amount is $${AMOUNT_LIMITS.MIN}` + : `Maximum amount is $${AMOUNT_LIMITS.MAX}`} + +
+ )} + + {isValidAmount && purchaseAmount && ( +
+ + + ${amountValue.toFixed(2)} will be added to your balance + +
+ )}
-
+
); } diff --git a/packages/ui/src/components/billing/billing-page-wrapper.tsx b/packages/ui/src/components/billing/billing-page-wrapper.tsx index 5d966c54e..aec23600d 100644 --- a/packages/ui/src/components/billing/billing-page-wrapper.tsx +++ b/packages/ui/src/components/billing/billing-page-wrapper.tsx @@ -1,29 +1,19 @@ /** * Billing page wrapper component setting page header and displaying payment cancellation alerts. - * Wraps billing page client with page context and alert handling. * * @param props - Billing page wrapper configuration - * @param props.creditPacks - Array of available credit packs * @param props.currentCredits - Current credit balance * @param props.canceled - Optional cancellation message from Stripe */ "use client"; -import { - Alert, - AlertDescription, - AlertTitle, - BrandCard, - useSetPageHeader, -} from "@elizaos/cloud-ui"; +import { Alert, AlertDescription, AlertTitle, useSetPageHeader } from "@elizaos/cloud-ui"; import { Info } from "lucide-react"; -import type { CreditPack as DBCreditPack } from "@/lib/types"; import { BillingPageClient } from "./billing-page-client"; import { MiladyPricingInfo } from "./milady-pricing-info"; interface BillingPageWrapperProps { - creditPacks: DBCreditPack[]; currentCredits: number; canceled?: string; runningAgents?: number; @@ -31,7 +21,6 @@ interface BillingPageWrapperProps { } export function BillingPageWrapper({ - creditPacks, currentCredits, canceled, runningAgents = 0, @@ -54,33 +43,13 @@ export function BillingPageWrapper({ )} - -
- -
-

How Billing Works

-

- You are charged for all AI operations including text generation, image creation, and - video rendering. Add funds in bulk to get better rates. Your balance never expires and - is shared across your organization. -

-
-
-
- - ({ - ...p, - credits: Number(p.credits), - }))} - currentCredits={currentCredits} - /> +
); } diff --git a/packages/ui/src/components/billing/milady-pricing-info.tsx b/packages/ui/src/components/billing/milady-pricing-info.tsx index 96da3a02e..de164ad5f 100644 --- a/packages/ui/src/components/billing/milady-pricing-info.tsx +++ b/packages/ui/src/components/billing/milady-pricing-info.tsx @@ -110,10 +110,9 @@ export function MiladyPricingInfo({
-

- Min. deposit {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Credits never expire ·{" "} - Auto-suspend at {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} balance ·{" "} - {MILADY_PRICING.GRACE_PERIOD_HOURS}h grace period +

+ Min. {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Never expires · Suspends at{" "} + {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} · {MILADY_PRICING.GRACE_PERIOD_HOURS}h grace

diff --git a/packages/ui/src/components/chat/eliza-avatar.tsx b/packages/ui/src/components/chat/eliza-avatar.tsx index 0d69a1a98..6fc24a62e 100644 --- a/packages/ui/src/components/chat/eliza-avatar.tsx +++ b/packages/ui/src/components/chat/eliza-avatar.tsx @@ -41,7 +41,7 @@ export const ElizaAvatar = memo(function ElizaAvatar({ animate = false, priority = false, }: ElizaAvatarProps) { - const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl); + const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl, name); return (
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 c34edfca8..0182b9019 100644 --- a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx +++ b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx @@ -4,7 +4,6 @@ import { BrandButton, Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -218,7 +217,7 @@ function ProvisioningProgress({ ) : ( - {hasError ? "Close" : "Close — continues in background"} + Close )}
@@ -414,7 +413,7 @@ export function CreateMiladySandboxDialog({ ) : ( setOpen(true)} disabled={busy}> - New Sandbox + New Agent )} @@ -429,13 +428,8 @@ export function CreateMiladySandboxDialog({ - {isProvisioningPhase ? "Launching Agent" : "Create Sandbox"} + {isProvisioningPhase ? "Launching Agent" : "New Agent"} - {!isProvisioningPhase && ( - - Create an agent sandbox and optionally start provisioning right away. - - )} {isProvisioningPhase ? ( @@ -476,7 +470,7 @@ export function CreateMiladySandboxDialog({ {/* Flavor selector */}