diff --git a/.gitignore b/.gitignore index 0be2c906..2944ebde 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ node_modules # Local file-backed stores .data/ tsconfig.tsbuildinfo +coverage/ +test-results/ diff --git a/__tests__/agent-runtime/cli.test.ts b/__tests__/agent-runtime/cli.test.ts new file mode 100644 index 00000000..b99348e5 --- /dev/null +++ b/__tests__/agent-runtime/cli.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +describe("Open-Stellar CLI", () => { + it("starts agent via CLI and saves state to .data/agent-state.json", () => { + const output = execSync( + "node bin/open-stellar.js agent start --name Nexus-7 --district data-center", + { + encoding: "utf8", + }, + ); + + expect(output).toContain("Nexus-7"); + expect(output).toContain("data-center"); + + const filePath = join(process.cwd(), ".data", "agent-state.json"); + expect(existsSync(filePath)).toBe(true); + + const data = JSON.parse(readFileSync(filePath, "utf8")); + const agent = data.agents.find((a: any) => a.name === "Nexus-7"); + + expect(agent).toBeDefined(); + expect(agent.district).toBe("data-center"); + expect(agent.status).toBe("active"); + }, 15000); + + it("lists persisted agents via CLI", () => { + execSync( + "node bin/open-stellar.js agent start --name Nexus-7 --district data-center", + { + encoding: "utf8", + }, + ); + + const output = execSync("node bin/open-stellar.js agent list", { + encoding: "utf8", + }); + + expect(output).toContain("bot-nexus-7"); + expect(output).toContain("Nexus-7"); + }, 15000); +}); diff --git a/__tests__/agent-runtime/persistence.test.ts b/__tests__/agent-runtime/persistence.test.ts new file mode 100644 index 00000000..085d0cd3 --- /dev/null +++ b/__tests__/agent-runtime/persistence.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + loadPersistedState, + savePersistedState, + upsertPersistedAgent, + removePersistedAgent, +} from "@/lib/agent-runtime/persistence"; + +describe("Agent State Persistence", () => { + it("upserts and loads persisted agent state to survive restarts", () => { + const testId = "bot-unique-persistence-test"; + upsertPersistedAgent({ + id: testId, + name: "PersistenceAgent", + model: "claude-4-sonnet", + district: "data-center", + status: "active", + cpu: 20, + memory: 45, + autoRestart: true, + updatedAt: new Date().toISOString(), + }); + + const state = loadPersistedState(); + const agent = state.agents.find((a) => a.id === testId); + + expect(agent).toBeDefined(); + expect(agent?.name).toBe("PersistenceAgent"); + expect(agent?.district).toBe("data-center"); + expect(agent?.status).toBe("active"); + + removePersistedAgent(testId); + const updated = loadPersistedState(); + expect(updated.agents.find((a) => a.id === testId)).toBeUndefined(); + }); +}); diff --git a/__tests__/agent-runtime/sdk.test.ts b/__tests__/agent-runtime/sdk.test.ts new file mode 100644 index 00000000..a189e315 --- /dev/null +++ b/__tests__/agent-runtime/sdk.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAgent } from "@/lib/agent-runtime/sdk"; + +function uniqueId(prefix: string) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +} + +describe("Agent SDK & Lifecycle Hooks", () => { + it("triggers onStart and onStop hooks during lifecycle transitions", async () => { + const onStart = vi.fn(); + const onStop = vi.fn(); + const onStateChange = vi.fn(); + const id = uniqueId("bot-test-sdk-lifecycle"); + + const sdk = createAgent({ + id, + name: "TestSDKAgent", + model: "claude-4-sonnet", + district: "data-center", + onStart, + onStop, + onStateChange, + }); + + expect(sdk.id).toBe(id); + expect(sdk.status).toBe("idle"); + + await sdk.start(); + expect(onStart).toHaveBeenCalledTimes(1); + expect(sdk.status).toBe("running"); + expect(onStateChange).toHaveBeenCalledWith("running"); + + await sdk.stop(); + expect(onStop).toHaveBeenCalledTimes(1); + expect(sdk.status).toBe("stopped"); + expect(onStateChange).toHaveBeenCalledWith("stopped"); + }); + + it("executes tasks and updates metrics", async () => { + const onTask = vi.fn().mockResolvedValue({ + summary: "Task completed successfully", + output: { result: 42 }, + }); + const id = uniqueId("bot-test-sdk-task"); + const sdk = createAgent({ + id, + name: "TaskAgent", + model: "claude-4-sonnet", + onTask, + }); + + await sdk.start(); + const res = await sdk.executeTask({ id: "t1", title: "Calculate metric" }); + + expect(res.status).toBe("completed"); + expect(res.summary).toBe("Task completed successfully"); + expect(sdk.getMetrics().tasksCompleted).toBe(1); + }); + + it("handles errors and triggers onError hook", async () => { + const onError = vi.fn(); + const id = uniqueId("bot-test-sdk-err"); + const sdk = createAgent({ + id, + name: "ErrorAgent", + model: "claude-4-sonnet", + onTask: async () => { + throw new Error("Execution failure"); + }, + onError, + }); + + await sdk.start(); + const res = await sdk.executeTask({ id: "t2", title: "Faulty task" }); + + expect(res.status).toBe("failed"); + expect(res.error).toBe("Execution failure"); + expect(onError).toHaveBeenCalled(); + }); + + it("supports inter-agent messaging", async () => { + const idA = uniqueId("bot-msg-a"); + const idB = uniqueId("bot-msg-b"); + const agentA = createAgent({ + id: idA, + name: "AgentA", + model: "claude-4-sonnet", + }); + const agentB = createAgent({ + id: idB, + name: "AgentB", + model: "claude-4-sonnet", + }); + + const received: any[] = []; + agentB.subscribe((msg) => received.push(msg)); + + await agentA.sendMessage(idB, { text: "Hello Agent B" }, "chat"); + expect(received).toHaveLength(1); + expect(received[0].payload).toEqual({ text: "Hello Agent B" }); + }); +}); diff --git a/__tests__/api/protocol/x402-subscriptions.test.ts b/__tests__/api/protocol/x402-subscriptions.test.ts index 580f99dd..1db09d29 100644 --- a/__tests__/api/protocol/x402-subscriptions.test.ts +++ b/__tests__/api/protocol/x402-subscriptions.test.ts @@ -44,9 +44,12 @@ describe("x402 subscriptions", () => { pricePerMonth: "1 XLM", }) - const first = await checkSubscription(new Request("http://localhost/api/protocol/x402/subscriptions/nexus-7/my-data-api?consume=true"), { - params: Promise.resolve({ agentId: "nexus-7", serviceId: "my-data-api" }), - }) + const first = await checkSubscription( + new Request("http://localhost/api/protocol/x402/subscriptions/nexus-7/my-data-api?consume=true"), + { + params: Promise.resolve({ agentId: "nexus-7", serviceId: "my-data-api" }), + }, + ) const firstData = await first.json() const second = checkX402Subscription("nexus-7", "my-data-api", { consumeCall: true }) const exhausted = checkX402Subscription("nexus-7", "my-data-api") diff --git a/__tests__/api/webhooks.test.ts b/__tests__/api/webhooks.test.ts index 82459f76..7f618925 100644 --- a/__tests__/api/webhooks.test.ts +++ b/__tests__/api/webhooks.test.ts @@ -823,5 +823,6 @@ describe("webhook API", () => { expect(attempts[0].event).toBe("event.200") expect(attempts[199].event).toBe("event.1") expect(attempts.some((attempt) => attempt.event === "event.0")).toBe(false) - }) + }, 15000) }) + diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index 013a71e6..073c1f7c 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -399,7 +399,7 @@ export default async function AgentPage({ params }: AgentPageProps) {
{badges.length > 0 ? badges.map((badge, i) => ( -
+
{badge.name || badge.badgeId || badge.id} {badge.rarity || 'common'} diff --git a/bin/open-stellar.js b/bin/open-stellar.js new file mode 100644 index 00000000..38da083c --- /dev/null +++ b/bin/open-stellar.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +import { parseArgs } from "node:util"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + const subcommand = args[1]; + + if (!command || command === "--help" || command === "-h") { + printHelp(); + process.exit(0); + } + + if (command === "agent") { + if (subcommand === "start") { + await handleAgentStart(args.slice(2)); + return; + } + if (subcommand === "list") { + await handleAgentList(); + return; + } + if (subcommand === "task") { + await handleAgentTask(args.slice(2)); + return; + } + } + + console.error(`Unknown command: ${args.join(" ")}`); + printHelp(); + process.exit(1); +} + +function printHelp() { + console.log(` +Open-Stellar CLI โ€” Lightweight Agent Runtime & Orchestration + +Usage: + npx open-stellar agent start --name --district [options] + npx open-stellar agent list + npx open-stellar agent task --id --title + +Commands: + agent start Register and start an agent locally or connect to runtime + agent list List all active & registered agents + agent task Dispatch a task to a registered agent + +Options: + --name Agent display name (e.g. Nexus-7) [required for start] + --district District ID (data-center | comm-hub | processing | defense | research) [default: data-center] + --model AI Model (e.g. claude-4-sonnet, gpt-5-mini) [default: claude-4-sonnet] + --endpoint Target API Endpoint URL [default: http://localhost:3000] + --capability Add agent capability flag +`); +} + +function saveLocalAgentState(agent) { + const dataDir = join(process.cwd(), ".data"); + const filePath = join(dataDir, "agent-state.json"); + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + + let current = { agents: [], updatedAt: new Date().toISOString() }; + if (existsSync(filePath)) { + try { + current = JSON.parse(readFileSync(filePath, "utf8")); + } catch { + // Ignore read errors + } + } + + const idx = current.agents.findIndex((a) => a.id === agent.id); + if (idx >= 0) { + current.agents[idx] = { + ...current.agents[idx], + ...agent, + updatedAt: new Date().toISOString(), + }; + } else { + current.agents.push({ ...agent, updatedAt: new Date().toISOString() }); + } + writeFileSync(filePath, JSON.stringify(current, null, 2), "utf8"); +} + +function listPersistedAgents() { + const filePath = join(process.cwd(), ".data", "agent-state.json"); + if (existsSync(filePath)) { + try { + const data = JSON.parse(readFileSync(filePath, "utf8")); + return data.agents || []; + } catch { + return []; + } + } + return []; +} + +function validateEndpoint(rawEndpoint) { + const parsed = new URL(rawEndpoint); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Invalid endpoint protocol: ${parsed.protocol}`); + } + return parsed.origin; +} + +async function handleAgentStart(argv) { + const { values } = parseArgs({ + args: argv, + options: { + name: { type: "string", short: "n" }, + district: { type: "string", short: "d", default: "data-center" }, + model: { type: "string", short: "m", default: "claude-4-sonnet" }, + endpoint: { + type: "string", + short: "e", + default: "http://localhost:3000", + }, + capability: { type: "string", multiple: true }, + }, + allowPositionals: true, + }); + + if (!values.name) { + console.error("Error: --name is required for starting an agent"); + console.error( + "Example: npx open-stellar agent start --name Nexus-7 --district data-center", + ); + process.exit(1); + } + + const endpoint = validateEndpoint(values.endpoint); + const slug = values.name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const agentId = slug.startsWith("bot-") ? slug : `bot-${slug}`; + + const payload = { + agentId, + name: values.name, + model: values.model, + district: values.district, + capabilities: values.capability || [values.district, "task-execution"], + x402: { accepts: true, pricePerTask: "0.01 XLM" }, + status: "active", + endpoint: `${endpoint}/api/agents/${encodeURIComponent(agentId)}`, + }; + + saveLocalAgentState({ + id: agentId, + name: values.name, + model: values.model, + district: values.district, + status: "active", + cpu: 15, + memory: 32, + autoRestart: true, + }); + + try { + const res = await fetch(`${endpoint}/api/agents`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`API registration failed (${res.status}): ${errText}`); + } + + await res.json(); + console.log(`๐Ÿš€ Starting Agent: ${values.name} (${agentId})`); + console.log(`๐Ÿ“ District: ${values.district}`); + console.log(`โœ… Agent registered with runtime API!`); + console.log(`๐ŸŸข Status: active`); + console.log(`๐Ÿ’พ Saved state to .data/agent-state.json (survives restarts)`); + } catch { + console.log(`๐Ÿš€ Starting Agent: ${values.name} (${agentId})`); + console.log(`๐Ÿ“ District: ${values.district}`); + console.log(`โš ๏ธ Runtime API server offline or unreached`); + console.log( + `โœ… Agent ${values.name} initialized in local offline runtime mode`, + ); + console.log(`๐Ÿ’พ Saved state to .data/agent-state.json (survives restarts)`); + } +} + +async function handleAgentList() { + const agents = listPersistedAgents(); + console.log("Registered Agents:"); + if (agents.length === 0) { + console.log(" (No agents registered yet)"); + return; + } + for (const agent of agents) { + console.log( + ` - ${agent.name} [${agent.id}] (District: ${agent.district}, Status: ${agent.status})`, + ); + } +} + +async function handleAgentTask(args) { + const { values } = parseArgs({ + args, + options: { + id: { type: "string" }, + title: { type: "string" }, + endpoint: { type: "string", default: "http://localhost:3000" }, + }, + }); + + if (!values.id || !values.title) { + console.error("Error: --id and --title are required"); + process.exit(1); + } + + const endpoint = validateEndpoint(values.endpoint); + try { + const res = await fetch( + `${endpoint}/api/agents/${encodeURIComponent(values.id)}/task`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: values.title }), + }, + ); + await res.json(); + console.log("Task submitted successfully."); + } catch { + console.error("Failed sending task to API server"); + } +} + +main().catch((err) => { + console.error("CLI Error:", err); + process.exit(1); +}); diff --git a/components/open-stellar/open-stellar-hub.tsx b/components/open-stellar/open-stellar-hub.tsx index b111d07c..60818008 100644 --- a/components/open-stellar/open-stellar-hub.tsx +++ b/components/open-stellar/open-stellar-hub.tsx @@ -1,23 +1,67 @@ -"use client" - -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react" -import { Activity, Bot, BriefcaseBusiness, MessageSquare, PanelBottomOpen, Palette, ScrollText, WalletCards, Wrench } from "lucide-react" -import { toast } from "sonner" -import { PixelCity, type FloatingOverlay, type ParticleTrigger, type TxAnimation } from "@/components/pixel-city" -import { SidebarPanel, SIDEBAR_TABS, type SidebarTabId } from "@/components/sidebar-panel" -import { AudioControls } from "@/components/audio-controls" -import { DistrictEventOverlay } from "@/components/open-stellar/district-event-overlay" -import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer" -import { MOCK_OFFERS } from "@/components/task-board" -import { CityAudioEngine } from "@/lib/audio/city-audio" -import { DISTRICTS, createAgents, generateChatMessage, getRandomTask } from "@/lib/data" -import { LEGAL_LINKS } from "@/lib/legal-links" -import type { PublishedSystemEvent } from "@/lib/events/system-events" -import { XP_AWARDS } from "@/lib/gamification/constants" -import { getActiveDistrictEvent, getDistrictStandings } from "@/lib/gamification/events" -import { upgradeAgentSkill } from "@/lib/gamification/skill-upgrades" -import { awardSkillXP, checkLevelUp, getXpToNextLevel } from "@/lib/gamification/xp" -import type { AgentAppearance, ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; +import { + Activity, + Bot, + BriefcaseBusiness, + MessageSquare, + PanelBottomOpen, + Palette, + ScrollText, + WalletCards, + Wrench, +} from "lucide-react"; +import { toast } from "sonner"; +import { + PixelCity, + type FloatingOverlay, + type ParticleTrigger, + type TxAnimation, +} from "@/components/pixel-city"; +import { + SidebarPanel, + SIDEBAR_TABS, + type SidebarTabId, +} from "@/components/sidebar-panel"; +import { AudioControls } from "@/components/audio-controls"; +import { DistrictEventOverlay } from "@/components/open-stellar/district-event-overlay"; +import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"; +import { MOCK_OFFERS } from "@/components/task-board"; +import { CityAudioEngine } from "@/lib/audio/city-audio"; +import { + DISTRICTS, + createAgents, + generateChatMessage, + getRandomTask, +} from "@/lib/data"; +import { LEGAL_LINKS } from "@/lib/legal-links"; +import type { PublishedSystemEvent } from "@/lib/events/system-events"; +import { XP_AWARDS } from "@/lib/gamification/constants"; +import { + getActiveDistrictEvent, + getDistrictStandings, +} from "@/lib/gamification/events"; +import { upgradeAgentSkill } from "@/lib/gamification/skill-upgrades"; +import { + awardSkillXP, + checkLevelUp, + getXpToNextLevel, +} from "@/lib/gamification/xp"; +import type { + AgentAppearance, + ChatMessage, + LogEntry, + MoltbotAgent, + WalletTransaction, +} from "@/lib/types"; function nowTime() { return new Date().toLocaleTimeString("en-US", { @@ -25,7 +69,7 @@ function nowTime() { minute: "2-digit", second: "2-digit", hour12: false, - }) + }); } function secureRandom(): number { @@ -51,9 +95,12 @@ const ONBOARDING_STEPS = [ body: "Visit /admin to manage ZK passports, x402 payment rails, subscription plans, and API keys.", hint: "โ†— click Admin in the sidebar", }, -] +]; -const MOBILE_NAV_ICONS: Record<SidebarTabId, ComponentType<{ size?: number; "aria-hidden"?: boolean | "true" }>> = { +const MOBILE_NAV_ICONS: Record< + SidebarTabId, + ComponentType<{ size?: number; "aria-hidden"?: boolean | "true" }> +> = { overview: Activity, chat: MessageSquare, offers: BriefcaseBusiness, @@ -61,64 +108,75 @@ const MOBILE_NAV_ICONS: Record<SidebarTabId, ComponentType<{ size?: number; "ari quests: ScrollText, wallet: WalletCards, appearance: Palette, -} +}; interface AgentHealthApiSnapshot { - agentId: string - status: "healthy" | "stale" | "offline" - runtimeStatus: "active" | "idle" | "working" | "error" | "offline" - lastHeartbeat: string - offlineForSeconds: number - cpu: number | null - memory: number | null - currentTask: string | null + agentId: string; + status: "healthy" | "stale" | "offline"; + runtimeStatus: "active" | "idle" | "working" | "error" | "offline"; + lastHeartbeat: string; + offlineForSeconds: number; + cpu: number | null; + memory: number | null; + currentTask: string | null; } interface AgentPositionPayload { - agentId: string - pixelX: number - pixelY: number - targetX: number - targetY: number - direction: "left" | "right" + agentId: string; + pixelX: number; + pixelY: number; + targetX: number; + targetY: number; + direction: "left" | "right"; } interface AgentPositionSnapshotPayload { - type: "agent.positions.snapshot" - positions: AgentPositionPayload[] + type: "agent.positions.snapshot"; + positions: AgentPositionPayload[]; } interface AgentPositionDeltaPayload { - type: "agent.position" - agents: AgentPositionPayload[] + type: "agent.position"; + agents: AgentPositionPayload[]; } function OnboardingModal({ onDone }: { onDone: () => void }) { - const [step, setStep] = useState(0) - const current = ONBOARDING_STEPS[step] - const isLast = step === ONBOARDING_STEPS.length - 1 + const [step, setStep] = useState(0); + const current = ONBOARDING_STEPS[step]; + const isLast = step === ONBOARDING_STEPS.length - 1; return ( - <div style={{ - position: "fixed", - inset: 0, - zIndex: 100, - background: "rgba(3,7,18,0.88)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }}> - <div style={{ - background: "#111827", - border: "1px solid #2a3a52", - borderRadius: 16, - padding: 32, - maxWidth: 380, - width: "90%", - boxShadow: "0 24px 80px rgba(0,0,0,0.6)", - }}> + <div + style={{ + position: "fixed", + inset: 0, + zIndex: 100, + background: "rgba(3,7,18,0.88)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <div + style={{ + background: "#111827", + border: "1px solid #2a3a52", + borderRadius: 16, + padding: 32, + maxWidth: 380, + width: "90%", + boxShadow: "0 24px 80px rgba(0,0,0,0.6)", + }} + > {/* Step dots */} - <div style={{ display: "flex", gap: 6, justifyContent: "center", marginBottom: 24 }}> + <div + style={{ + display: "flex", + gap: 6, + justifyContent: "center", + marginBottom: 24, + }} + > {ONBOARDING_STEPS.map((_, i) => ( <div key={i} @@ -133,33 +191,58 @@ function OnboardingModal({ onDone }: { onDone: () => void }) { ))} </div> - <div style={{ - fontFamily: "monospace", - fontSize: 9, - color: "#22d3ee", - textTransform: "uppercase", - letterSpacing: 2, - marginBottom: 12, - }}> + <div + style={{ + fontFamily: "monospace", + fontSize: 9, + color: "#22d3ee", + textTransform: "uppercase", + letterSpacing: 2, + marginBottom: 12, + }} + > {`Step ${step + 1} of ${ONBOARDING_STEPS.length}`} </div> - <div style={{ fontFamily: "monospace", fontSize: 16, fontWeight: 700, color: "#e2e8f0", marginBottom: 12 }}> + <div + style={{ + fontFamily: "monospace", + fontSize: 16, + fontWeight: 700, + color: "#e2e8f0", + marginBottom: 12, + }} + > {current.title} </div> - <div style={{ fontFamily: "monospace", fontSize: 11, color: "#94a3b8", lineHeight: 1.7, marginBottom: 16 }}> + <div + style={{ + fontFamily: "monospace", + fontSize: 11, + color: "#94a3b8", + lineHeight: 1.7, + marginBottom: 16, + }} + > {current.body} </div> - <div style={{ fontFamily: "monospace", fontSize: 10, color: "#475569", marginBottom: 28 }}> + <div + style={{ + fontFamily: "monospace", + fontSize: 10, + color: "#475569", + marginBottom: 28, + }} + > {current.hint} </div> <div style={{ display: "flex", gap: 8 }}> {step > 0 && ( <button - onClick={() => setStep(s => s - 1)} + onClick={() => setStep((s) => s - 1)} style={{ flex: 1, padding: "8px 16px", @@ -176,7 +259,13 @@ function OnboardingModal({ onDone }: { onDone: () => void }) { </button> )} <button - onClick={() => { if (isLast) { onDone() } else { setStep(s => s + 1) } }} + onClick={() => { + if (isLast) { + onDone(); + } else { + setStep((s) => s + 1); + } + }} style={{ flex: 2, padding: "8px 16px", @@ -212,15 +301,17 @@ function OnboardingModal({ onDone }: { onDone: () => void }) { skip </button> - <div style={{ - display: "flex", - justifyContent: "center", - gap: 10, - flexWrap: "wrap", - marginTop: 16, - borderTop: "1px solid #1f2a44", - paddingTop: 14, - }}> + <div + style={{ + display: "flex", + justifyContent: "center", + gap: 10, + flexWrap: "wrap", + marginTop: 16, + borderTop: "1px solid #1f2a44", + paddingTop: 14, + }} + > {LEGAL_LINKS.map((link) => ( <a key={link.href} @@ -238,7 +329,7 @@ function OnboardingModal({ onDone }: { onDone: () => void }) { </div> </div> </div> - ) + ); } export function OpenStellarHub() { @@ -275,80 +366,85 @@ export function OpenStellarHub() { const lastLeadingDistrictRef = useRef<string | null>(null) useEffect(() => { - return () => audioEngine.dispose() - }, [audioEngine]) + return () => audioEngine.dispose(); + }, [audioEngine]); // Show onboarding once on first visit useEffect(() => { - if (typeof window === "undefined") return - const params = new URLSearchParams(window.location.search) - const storedColorBlind = localStorage.getItem("colorblind-mode") - const storedTab = localStorage.getItem("sidebar-tab") as SidebarTabId | null - const queryColorBlind = params.get("colorblind") - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)") - const mobileQuery = window.matchMedia("(max-width: 767px)") - - const colorBlindEnabled = queryColorBlind === "true" || storedColorBlind === "true" - setColorBlindMode(colorBlindEnabled) + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const storedColorBlind = localStorage.getItem("colorblind-mode"); + const storedTab = localStorage.getItem( + "sidebar-tab", + ) as SidebarTabId | null; + const queryColorBlind = params.get("colorblind"); + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + const mobileQuery = window.matchMedia("(max-width: 767px)"); + + const colorBlindEnabled = + queryColorBlind === "true" || storedColorBlind === "true"; + setColorBlindMode(colorBlindEnabled); if (queryColorBlind === "true") { - localStorage.setItem("colorblind-mode", "true") + localStorage.setItem("colorblind-mode", "true"); } if (storedTab && SIDEBAR_TABS.some((tab) => tab.id === storedTab)) { - setSidebarTab(storedTab) + setSidebarTab(storedTab); } - setReduceMotion(prefersReducedMotion.matches) + setReduceMotion(prefersReducedMotion.matches); const handleMotionChange = (event: MediaQueryListEvent) => { - setReduceMotion(event.matches) - } + setReduceMotion(event.matches); + }; const handleMobileChange = (event: MediaQueryListEvent) => { - setIsMobile(event.matches) - setSidebarOpen(!event.matches) + setIsMobile(event.matches); + setSidebarOpen(!event.matches); if (!event.matches) { - setMobileControlsOpen(false) + setMobileControlsOpen(false); } - } + }; - prefersReducedMotion.addEventListener("change", handleMotionChange) - mobileQuery.addEventListener("change", handleMobileChange) - setIsMobile(mobileQuery.matches) + prefersReducedMotion.addEventListener("change", handleMotionChange); + mobileQuery.addEventListener("change", handleMobileChange); + setIsMobile(mobileQuery.matches); if (!localStorage.getItem("onboarding-seen")) { - setShowOnboarding(true) + setShowOnboarding(true); } // Collapse sidebar by default on small screens if (mobileQuery.matches) { - setSidebarOpen(false) + setSidebarOpen(false); } return () => { - prefersReducedMotion.removeEventListener("change", handleMotionChange) - mobileQuery.removeEventListener("change", handleMobileChange) - } - }, []) + prefersReducedMotion.removeEventListener("change", handleMotionChange); + mobileQuery.removeEventListener("change", handleMobileChange); + }; + }, []); // Persist the active tab whenever it changes. useEffect(() => { if (typeof window !== "undefined") { - localStorage.setItem("sidebar-tab", sidebarTab) + localStorage.setItem("sidebar-tab", sidebarTab); } - }, [sidebarTab]) + }, [sidebarTab]); const handleColorBlindModeChange = useCallback((enabled: boolean) => { - setColorBlindMode(enabled) - localStorage.setItem("colorblind-mode", String(enabled)) - }, []) + setColorBlindMode(enabled); + localStorage.setItem("colorblind-mode", String(enabled)); + }, []); const handleDoneOnboarding = useCallback(() => { - setShowOnboarding(false) - localStorage.setItem("onboarding-seen", "1") - }, []) + setShowOnboarding(false); + localStorage.setItem("onboarding-seen", "1"); + }, []); const selectedAgent = useMemo( () => agents.find((agent) => agent.id === selectedAgentId) || null, - [agents, selectedAgentId] - ) + [agents, selectedAgentId], + ); const pushLog = useCallback((message: string, type: LogEntry["type"] = "info", agent = "system") => { setLogs((prev) => [ @@ -363,23 +459,25 @@ export function OpenStellarHub() { ]) }, []) - const agentsRef = useRef(agents) + const agentsRef = useRef(agents); useEffect(() => { - agentsRef.current = agents + agentsRef.current = agents; for (const agent of agents) { if (!agentLevelsRef.current.has(agent.id)) { - agentLevelsRef.current.set(agent.id, agent.level ?? 1) + agentLevelsRef.current.set(agent.id, agent.level ?? 1); } } - }, [agents]) + }, [agents]); useEffect(() => { - pushLog("Open-Stellar v0 frontend initialized", "success") - }, [pushLog]) + pushLog("Open-Stellar v0 frontend initialized", "success"); + }, [pushLog]); const animateAgentToDistrict = useCallback((agent: MoltbotAgent) => { - const district = DISTRICTS.find((candidate) => candidate.id === agent.district) - if (!district) return + const district = DISTRICTS.find( + (candidate) => candidate.id === agent.district, + ); + if (!district) return; setTxAnimations((prev) => [ ...prev, @@ -392,8 +490,8 @@ export function OpenStellarHub() { startedAt: Date.now(), duration: 1600, }, - ]) - }, []) + ]); + }, []); const showAgentOverlay = useCallback((agent: MoltbotAgent, text: string, color = "#fbbf24") => { setFloatingOverlays((prev) => [ @@ -411,7 +509,12 @@ export function OpenStellarHub() { }, []) const spawnParticles = useCallback( - (type: ParticleTrigger["type"], x: number, y: number, opts?: ParticleTrigger["opts"]) => { + ( + type: ParticleTrigger["type"], + x: number, + y: number, + opts?: ParticleTrigger["opts"], + ) => { setParticleTriggers((prev) => [ ...prev, { @@ -421,213 +524,407 @@ export function OpenStellarHub() { y, opts, }, - ]) + ]); }, - [] - ) - + [], + ); const districtStandings = useMemo( () => getDistrictStandings(agents), - [agents] - ) + [agents], + ); useEffect(() => { const id = window.setInterval(() => { - setActiveDistrictEvent(getActiveDistrictEvent()) - }, 60_000) + setActiveDistrictEvent(getActiveDistrictEvent()); + }, 60_000); - return () => window.clearInterval(id) - }, []) + return () => window.clearInterval(id); + }, []); useEffect(() => { - const leader = districtStandings[0] - if (!leader) return - const previousLeader = lastLeadingDistrictRef.current - lastLeadingDistrictRef.current = leader.districtId - if (!previousLeader || previousLeader === leader.districtId) return - - const district = DISTRICTS.find((candidate) => candidate.id === leader.districtId) - if (!district) return - pushLog(`${leader.districtName} takes the lead in ${activeDistrictEvent.challenge.name}`, "success") + const leader = districtStandings[0]; + if (!leader) return; + const previousLeader = lastLeadingDistrictRef.current; + lastLeadingDistrictRef.current = leader.districtId; + if (!previousLeader || previousLeader === leader.districtId) return; + + const district = DISTRICTS.find( + (candidate) => candidate.id === leader.districtId, + ); + if (!district) return; + pushLog( + `${leader.districtName} takes the lead in ${activeDistrictEvent.challenge.name}`, + "success", + ); spawnParticles("district-win", district.x + district.w / 2, district.y, { color: district.color, spreadW: district.w * 0.7, - }) - }, [activeDistrictEvent.challenge.name, districtStandings, pushLog, spawnParticles]) - - const applySystemEvent = useCallback((event: PublishedSystemEvent) => { - const animatedAgentBox: { current: MoltbotAgent | null } = { current: null } - - setAgents((prev) => - prev.map((agent) => { - if (agent.id !== event.agentId) return agent + }); + }, [ + activeDistrictEvent.challenge.name, + districtStandings, + pushLog, + spawnParticles, + ]); + +function updateAgentWithSystemEvent( + agent: MoltbotAgent, + event: PublishedSystemEvent, +): MoltbotAgent { + if (agent.id !== event.agentId) return agent; + + if (event.type === "agent.status") { + return { ...agent, status: event.status }; + } + + if (event.type === "task.started") { + return { + ...agent, + status: "working", + currentTask: event.task.title, + taskProgress: 0, + }; + } + + if (event.type === "task.completed") { + const skillId = event.skillId ?? agent.skills[0]?.id; + return { + ...agent, + status: "active", + currentTask: + event.result.summary || getRandomTask(agent.district), + taskProgress: 0, + tasksCompleted: agent.tasksCompleted + 1, + skills: awardSkillXP( + agent.skills, + skillId, + XP_AWARDS.TASK_COMPLETED, + ), + }; + } + + if (event.type === "payment.received") { + return { + ...agent, + status: "active", + }; + } + + if (event.type === "agent.xp") { + const level = event.level; + return { + ...agent, + xp: event.totalXp ?? (agent.xp ?? 0) + event.xp, + level, + xpToNext: event.xpToNext ?? getXpToNextLevel(level), + }; + } + + return agent; +} - if (event.type === "agent.status") { - return { ...agent, status: event.status } - } +function mergeNewAgents( + prev: MoltbotAgent[], + incoming: MoltbotAgent[], +): MoltbotAgent[] { + const existing = new Set(prev.map((agent) => agent.id)); + const nextCloudAgents = incoming.filter( + (agent) => !existing.has(agent.id), + ); + return nextCloudAgents.length > 0 ? [...prev, ...nextCloudAgents] : prev; +} - if (event.type === "task.started") { - return { - ...agent, - status: "working", - currentTask: event.task.title, - taskProgress: 0, - } - } +function pruneAnimations(prev: TxAnimation[], now: number): TxAnimation[] { + return prev.filter((a) => now - a.startedAt < a.duration); +} - if (event.type === "task.completed") { - animatedAgentBox.current = agent - const skillId = event.skillId ?? agent.skills[0]?.id - return { - ...agent, - status: "active", - currentTask: event.result.summary || getRandomTask(agent.district), - taskProgress: 0, - tasksCompleted: agent.tasksCompleted + 1, - skills: awardSkillXP(agent.skills, skillId, XP_AWARDS.TASK_COMPLETED), - } - } +function pruneOverlays(prev: FloatingOverlay[], now: number): FloatingOverlay[] { + return prev.filter((overlay) => now - overlay.startedAt < overlay.duration); +} - if (event.type === "payment.received") { - animatedAgentBox.current = agent - return { - ...agent, - status: "active", - } - } +function getTabBadgeColor(tabId: SidebarTabId): string { + if (tabId === "wallet") return "#fbbf24"; + if (tabId === "overview") return "#f87171"; + return "#34d399"; +} - if (event.type === "agent.xp") { - const level = event.level - return { - ...agent, - xp: event.totalXp ?? (agent.xp ?? 0) + event.xp, - level, - xpToNext: event.xpToNext ?? getXpToNextLevel(level), - } - } +function handleTaskCompletedEvent( + event: PublishedSystemEvent & { type: "task.completed" }, + agent: MoltbotAgent | null, + audioEngine: CityAudioEngine, + pushLog: (msg: string, type: any, agent: string) => void, + animateAgentToDistrict: (agent: MoltbotAgent) => void, + showAgentOverlay: (agent: MoltbotAgent, text: string, color: string) => void, + spawnParticles: (type: any, x: number, y: number, opts?: any) => void, +) { + audioEngine.playEvent("task_complete"); + pushLog( + `task completed: ${event.taskId} โ€” ${event.result.summary}`, + "success", + event.agentId, + ); + if (agent) { + animateAgentToDistrict(agent); + showAgentOverlay(agent, "+task", "#34d399"); + const district = DISTRICTS.find((candidate) => candidate.id === agent.district); + spawnParticles("xp-burst", agent.pixelX + 8, agent.pixelY, { + color: district?.color ?? agent.color, + }); + } +} - return agent - }) - ) - - if (event.type === "task.completed") { - audioEngine.playEvent("task_complete") - pushLog(`task completed: ${event.taskId} โ€” ${event.result.summary}`, "success", event.agentId) - const agent = animatedAgentBox.current - if (agent) { - animateAgentToDistrict(agent) - showAgentOverlay(agent, "+task", "#34d399") - const district = DISTRICTS.find((candidate) => candidate.id === agent.district) - spawnParticles("xp-burst", agent.pixelX + 8, agent.pixelY, { - color: district?.color ?? agent.color, - }) - } - return - } +function handlePaymentReceivedEvent( + event: PublishedSystemEvent & { type: "payment.received" }, + agent: MoltbotAgent | null, + audioEngine: CityAudioEngine, + pushLog: (msg: string, type: any, agent: string) => void, + animateAgentToDistrict: (agent: MoltbotAgent) => void, + showAgentOverlay: (agent: MoltbotAgent, text: string, color: string) => void, + spawnParticles: (type: any, x: number, y: number, opts?: any) => void, +) { + audioEngine.playEvent("payment_received"); + pushLog( + `payment received on ${event.receipt.chain}: ${event.receipt.txHash.slice(0, 12)}...`, + "success", + event.agentId, + ); + const amount = event.receipt.amountUsd + ? `$${event.receipt.amountUsd.toFixed(3)}` + : event.receipt.chain; + toast.success("Payment received", { + description: `${event.agentId} settled ${amount}`, + }); + if (agent) { + animateAgentToDistrict(agent); + showAgentOverlay(agent, `+${amount}`, "#fbbf24"); + const xlmAmount = event.receipt.amountUnits + ? `+${event.receipt.amountUnits} XLM` + : "+0.01 XLM"; + spawnParticles("payment-spark", agent.pixelX + 8, agent.pixelY + 10, { + amount: xlmAmount, + }); + } +} - if (event.type === "payment.received") { - audioEngine.playEvent("payment_received") - pushLog(`payment received on ${event.receipt.chain}: ${event.receipt.txHash.slice(0, 12)}...`, "success", event.agentId) - const amount = event.receipt.amountUsd ? `$${event.receipt.amountUsd.toFixed(3)}` : event.receipt.chain - toast.success("Payment received", { description: `${event.agentId} settled ${amount}` }) - const agent = animatedAgentBox.current - if (agent) { - animateAgentToDistrict(agent) - showAgentOverlay(agent, `+${amount}`, "#fbbf24") - const xlmAmount = event.receipt.amountUnits ? `+${event.receipt.amountUnits} XLM` : "+0.01 XLM" - spawnParticles("payment-spark", agent.pixelX + 8, agent.pixelY + 10, { - amount: xlmAmount, - }) - } - return +function handleAgentXpEvent( + event: PublishedSystemEvent & { type: "agent.xp" }, + agent: MoltbotAgent | null, + audioEngine: CityAudioEngine, + pushLog: (msg: string, type: any, agent: string) => void, + showAgentOverlay: (agent: MoltbotAgent, text: string, color: string) => void, + spawnParticles: (type: any, x: number, y: number, opts?: any) => void, + agentLevelsRef: { current: Map<string, number> }, +) { + audioEngine.playEvent("level_up"); + pushLog( + `XP update: +${event.xp}, level ${event.level}`, + "success", + event.agentId, + ); + if (agent) { + showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee"); + const previousLevel = + agentLevelsRef.current.get(event.agentId) ?? event.level; + if (event.level > previousLevel) { + toast.success("Agent leveled up", { + description: `${agent.name} reached level ${event.level}`, + }); + spawnParticles("level-up", agent.pixelX + 8, agent.pixelY, { + color: agent.color, + level: event.level, + }); } + agentLevelsRef.current.set(event.agentId, event.level); + } +} - if (event.type === "agent.xp") { - audioEngine.playEvent("level_up") - pushLog(`XP update: +${event.xp}, level ${event.level}`, "success", event.agentId) - const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) { - showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") - const previousLevel = agentLevelsRef.current.get(event.agentId) ?? event.level - if (event.level > previousLevel) { - toast.success("Agent leveled up", { description: `${agent.name} reached level ${event.level}` }) - spawnParticles("level-up", agent.pixelX + 8, agent.pixelY, { - color: agent.color, - level: event.level, - }) - } - agentLevelsRef.current.set(event.agentId, event.level) - } - return - } +function handleQuestCompletedEvent( + event: PublishedSystemEvent & { type: "quest.completed" }, + pushLog: (msg: string, type: any, agent: string) => void, +) { + const questTitle = event.quest?.title ?? event.questId ?? "Quest"; + const rewards = [ + typeof event.reward?.xp === "number" ? `+${event.reward.xp} XP` : null, + event.reward?.xlm ? `${event.reward.xlm} XLM` : null, + event.reward?.badge ?? null, + event.reward?.title ?? null, + ].filter((reward): reward is string => Boolean(reward)); + const rewardDescription = + rewards.length > 0 ? ` โ€” ${rewards.join(" ยท ")}` : ""; + + pushLog( + `quest completed: ${questTitle}${rewardDescription}`, + "success", + event.agentId, + ); + toast.success("Quest completed", { + description: `${questTitle}${rewardDescription}`, + }); +} - if (event.type === "quest.completed") { - const questTitle = event.quest?.title ?? event.questId ?? "Quest" - const rewards = [ - typeof event.reward?.xp === "number" ? `+${event.reward.xp} XP` : null, - event.reward?.xlm ? `${event.reward.xlm} XLM` : null, - event.reward?.badge ?? null, - event.reward?.title ?? null, - ].filter((reward): reward is string => Boolean(reward)) - const rewardDescription = rewards.length > 0 ? ` โ€” ${rewards.join(" ยท ")}` : "" - - pushLog(`quest completed: ${questTitle}${rewardDescription}`, "success", event.agentId) - toast.success("Quest completed", { - description: `${questTitle}${rewardDescription}`, - }) - return - } +function handleBadgeUnlockedEvent( + event: PublishedSystemEvent & { type: "badge.unlocked" }, + agent: MoltbotAgent | null, + audioEngine: CityAudioEngine, + pushLog: (msg: string, type: any, agent: string) => void, + showAgentOverlay: (agent: MoltbotAgent, text: string, color: string) => void, + spawnParticles: (type: any, x: number, y: number, opts?: any) => void, +) { + audioEngine.playEvent("badge_unlock"); + pushLog( + `badge unlocked: ${event.badge.name}`, + "success", + event.agentId, + ); + toast.success("Badge unlocked", { + description: `${event.agentId}: ${event.badge.name}`, + }); + if (agent) { + showAgentOverlay(agent, event.badge.name, "#a78bfa"); + spawnParticles("badge-unlock", agent.pixelX + 8, agent.pixelY, { + rarity: event.badge.rarity ?? "common", + }); + } +} - if (event.type === "badge.unlocked") { - audioEngine.playEvent("badge_unlock") - pushLog(`badge unlocked: ${event.badge.name}`, "success", event.agentId) - toast.success("Badge unlocked", { description: `${event.agentId}: ${event.badge.name}` }) - const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) { - showAgentOverlay(agent, event.badge.name, "#a78bfa") - spawnParticles("badge-unlock", agent.pixelX + 8, agent.pixelY, { - rarity: event.badge.rarity ?? "common", - }) - } - return - } +function handleDistrictUnlockedEvent( + event: PublishedSystemEvent & { type: "district.unlocked" }, + audioEngine: CityAudioEngine, + pushLog: (msg: string, type: any, agent: string) => void, + spawnParticles: (type: any, x: number, y: number, opts?: any) => void, +) { + audioEngine.playEvent("district_win"); + const districtId = + "districtId" in event ? event.districtId : event.district?.id; + const district = DISTRICTS.find((candidate) => candidate.id === districtId); + const districtName = + ("district" in event && event.district?.name) || + district?.name || + districtId || + "a district"; + pushLog( + `district unlocked: ${districtName}`, + "success", + event.agentId ?? "system", + ); + toast.success("District unlocked", { + description: String(districtName), + }); + if (district) { + spawnParticles("district-win", district.x + district.w / 2, district.y, { + color: district.color, + spreadW: district.w * 0.7, + }); + } +} - if (event.type === "district.unlocked") { - audioEngine.playEvent("district_win") - const districtId = "districtId" in event ? event.districtId : event.district?.id - const district = DISTRICTS.find((candidate) => candidate.id === districtId) - const districtName = ("district" in event && event.district?.name) || district?.name || districtId || "a district" - pushLog(`district unlocked: ${districtName}`, "success", event.agentId ?? "system") - toast.success("District unlocked", { description: String(districtName) }) - if (district) { - spawnParticles("district-win", district.x + district.w / 2, district.y, { - color: district.color, - spreadW: district.w * 0.7, - }) + const handleSystemEventSideEffect = useCallback( + (event: PublishedSystemEvent, agent: MoltbotAgent | null) => { + switch (event.type) { + case "task.completed": + handleTaskCompletedEvent( + event, + agent, + audioEngine, + pushLog, + animateAgentToDistrict, + showAgentOverlay, + spawnParticles, + ); + break; + case "payment.received": + handlePaymentReceivedEvent( + event, + agent, + audioEngine, + pushLog, + animateAgentToDistrict, + showAgentOverlay, + spawnParticles, + ); + break; + case "agent.xp": + handleAgentXpEvent( + event, + agent, + audioEngine, + pushLog, + showAgentOverlay, + spawnParticles, + agentLevelsRef, + ); + break; + case "quest.completed": + handleQuestCompletedEvent(event, pushLog); + break; + case "badge.unlocked": + handleBadgeUnlockedEvent( + event, + agent, + audioEngine, + pushLog, + showAgentOverlay, + spawnParticles, + ); + break; + case "district.unlocked": + handleDistrictUnlockedEvent( + event, + audioEngine, + pushLog, + spawnParticles, + ); + break; + case "task.started": + pushLog(`task started: ${event.task.title}`, "info", event.agentId); + break; + case "agent.status": + if (event.status === "error") audioEngine.playEvent("agent_error"); + pushLog(`status changed: ${event.status}`, "info", event.agentId); + break; + case "agent.registry": + pushLog( + `registry ${event.action}: ${event.agent.agentId}`, + "info", + event.agentId, + ); + break; + default: + break; } - return - } - - if (event.type === "task.started") { - pushLog(`task started: ${event.task.title}`, "info", event.agentId) - return - } + }, + [ + animateAgentToDistrict, + audioEngine, + pushLog, + showAgentOverlay, + spawnParticles, + ], + ); + + const applySystemEvent = useCallback( + (event: PublishedSystemEvent) => { + let targetAgent: MoltbotAgent | null = null; + setAgents((prev) => + prev.map((agent) => { + if (agent.id === event.agentId) targetAgent = agent; + return updateAgentWithSystemEvent(agent, event); + }), + ); - if (event.type === "agent.status") { - if (event.status === "error") audioEngine.playEvent("agent_error") - pushLog(`status changed: ${event.status}`, "info", event.agentId) - return - } + const agent = + targetAgent ?? + agentsRef.current.find((candidate) => candidate.id === event.agentId) ?? + null; - if (event.type === "agent.registry") { - pushLog(`registry ${event.action}: ${event.agent.agentId}`, "info", event.agentId) - return - } - }, [animateAgentToDistrict, audioEngine, pushLog, showAgentOverlay, spawnParticles]) + handleSystemEventSideEffect(event, agent); + }, + [handleSystemEventSideEffect], + ); useEffect(() => { - const eventSource = new EventSource("/api/events") + const eventSource = new EventSource("/api/events"); const eventTypes = [ "agent.status", "task.started", @@ -638,56 +935,66 @@ export function OpenStellarHub() { "badge.unlocked", "district.unlocked", "agent.registry", - ] + ]; const handleEvent = (message: MessageEvent) => { try { - setHasRealtimeEvents(true) - applySystemEvent(JSON.parse(String(message.data)) as PublishedSystemEvent) + setHasRealtimeEvents(true); + applySystemEvent( + JSON.parse(String(message.data)) as PublishedSystemEvent, + ); } catch { - pushLog("received malformed real-time event", "warning") + pushLog("received malformed real-time event", "warning"); } - } + }; eventSource.onopen = () => { - setEventStreamConnected(true) - fallbackLoggedRef.current = false - pushLog("real-time event stream connected", "success") - } + setEventStreamConnected(true); + fallbackLoggedRef.current = false; + pushLog("real-time event stream connected", "success"); + }; eventSource.onerror = () => { - setEventStreamConnected(false) - setHasRealtimeEvents(false) + setEventStreamConnected(false); + setHasRealtimeEvents(false); if (!fallbackLoggedRef.current) { - pushLog("event stream unavailable; using local simulation fallback", "warning") - fallbackLoggedRef.current = true + pushLog( + "event stream unavailable; using local simulation fallback", + "warning", + ); + fallbackLoggedRef.current = true; } - eventSource.close() - } + eventSource.close(); + }; for (const eventType of eventTypes) { - eventSource.addEventListener(eventType, handleEvent as EventListener) + eventSource.addEventListener(eventType, handleEvent as EventListener); } return () => { for (const eventType of eventTypes) { - eventSource.removeEventListener(eventType, handleEvent as EventListener) + eventSource.removeEventListener( + eventType, + handleEvent as EventListener, + ); } - eventSource.close() - } - }, [applySystemEvent, pushLog]) + eventSource.close(); + }; + }, [applySystemEvent, pushLog]); useEffect(() => { - const eventSource = new EventSource("/api/agents/stream") + const eventSource = new EventSource("/api/agents/stream"); const applyPositions = (positions: AgentPositionPayload[]) => { - if (positions.length === 0) return - const positionsById = new Map(positions.map((position) => [position.agentId, position])) + if (positions.length === 0) return; + const positionsById = new Map( + positions.map((position) => [position.agentId, position]), + ); setAgents((prev) => prev.map((agent) => { - const position = positionsById.get(agent.id) - if (!position) return agent + const position = positionsById.get(agent.id); + if (!position) return agent; return { ...agent, @@ -696,83 +1003,95 @@ export function OpenStellarHub() { targetX: position.targetX, targetY: position.targetY, direction: position.direction, - } + }; }), - ) - } + ); + }; const handleSnapshot = (message: MessageEvent) => { try { - const payload = JSON.parse(String(message.data)) as AgentPositionSnapshotPayload - applyPositions(payload.positions) + const payload = JSON.parse( + String(message.data), + ) as AgentPositionSnapshotPayload; + applyPositions(payload.positions); } catch { - pushLog("received malformed agent position snapshot", "warning") + pushLog("received malformed agent position snapshot", "warning"); } - } + }; const handleDelta = (message: MessageEvent) => { try { - const payload = JSON.parse(String(message.data)) as AgentPositionDeltaPayload - applyPositions(payload.agents) + const payload = JSON.parse( + String(message.data), + ) as AgentPositionDeltaPayload; + applyPositions(payload.agents); } catch { - pushLog("received malformed agent position delta", "warning") + pushLog("received malformed agent position delta", "warning"); } - } + }; eventSource.onopen = () => { - positionStreamErrorLoggedRef.current = false - } + positionStreamErrorLoggedRef.current = false; + }; eventSource.onerror = () => { if (!positionStreamErrorLoggedRef.current) { - pushLog("agent position stream reconnecting", "warning") - positionStreamErrorLoggedRef.current = true + pushLog("agent position stream reconnecting", "warning"); + positionStreamErrorLoggedRef.current = true; } - } + }; - eventSource.addEventListener("agent.positions.snapshot", handleSnapshot as EventListener) - eventSource.addEventListener("agent.position", handleDelta as EventListener) + eventSource.addEventListener( + "agent.positions.snapshot", + handleSnapshot as EventListener, + ); + eventSource.addEventListener( + "agent.position", + handleDelta as EventListener, + ); return () => { - eventSource.removeEventListener("agent.positions.snapshot", handleSnapshot as EventListener) - eventSource.removeEventListener("agent.position", handleDelta as EventListener) - eventSource.close() - } - }, [pushLog]) - + eventSource.removeEventListener( + "agent.positions.snapshot", + handleSnapshot as EventListener, + ); + eventSource.removeEventListener( + "agent.position", + handleDelta as EventListener, + ); + eventSource.close(); + }; + }, [pushLog]); useEffect(() => { - let stopped = false + let stopped = false; const syncCloudAgents = async () => { try { - const res = await fetch("/api/admin/agents", { cache: "no-store" }) - if (!res.ok) return - const data = await res.json() as { agents?: MoltbotAgent[] } - if (stopped || !Array.isArray(data.agents) || data.agents.length === 0) return - setAgents((prev) => { - const existing = new Set(prev.map((agent) => agent.id)) - const nextCloudAgents = data.agents!.filter((agent) => !existing.has(agent.id)) - return nextCloudAgents.length > 0 ? [...prev, ...nextCloudAgents] : prev - }) + const res = await fetch("/api/admin/agents", { cache: "no-store" }); + if (!res.ok) return; + const data = (await res.json()) as { agents?: MoltbotAgent[] }; + if (stopped || !Array.isArray(data.agents) || data.agents.length === 0) + return; + setAgents((prev) => mergeNewAgents(prev, data.agents!)); } catch { // Cloud agent provisioning is optional for the local simulation. } - } + }; - syncCloudAgents() - const interval = window.setInterval(syncCloudAgents, 15_000) + syncCloudAgents(); + const interval = window.setInterval(syncCloudAgents, 15_000); return () => { - stopped = true - window.clearInterval(interval) - } - }, []) + stopped = true; + window.clearInterval(interval); + }; + }, []); useEffect(() => { - let stopped = false + let stopped = false; const sendHeartbeats = async () => { - const snapshot = agentsRef.current + const snapshot = agentsRef.current; await Promise.allSettled( snapshot.map((agent) => fetch(`/api/agents/${encodeURIComponent(agent.id)}/heartbeat`, { @@ -787,71 +1106,75 @@ export function OpenStellarHub() { }), }), ), - ) - } + ); + }; const syncHealth = async () => { - const snapshot = agentsRef.current + const snapshot = agentsRef.current; const settled = await Promise.allSettled( snapshot.map(async (agent) => { - const res = await fetch(`/api/agents/${encodeURIComponent(agent.id)}/health`, { cache: "no-store" }) - if (!res.ok) return null - const data = await res.json() - return data.health as AgentHealthApiSnapshot + const res = await fetch( + `/api/agents/${encodeURIComponent(agent.id)}/health`, + { cache: "no-store" }, + ); + if (!res.ok) return null; + const data = await res.json(); + return data.health as AgentHealthApiSnapshot; }), - ) + ); - if (stopped) return + if (stopped) return; - const healthById = new Map<string, AgentHealthApiSnapshot>() + const healthById = new Map<string, AgentHealthApiSnapshot>(); for (const item of settled) { if (item.status === "fulfilled" && item.value) { - healthById.set(item.value.agentId, item.value) + healthById.set(item.value.agentId, item.value); } } - if (healthById.size === 0) return + if (healthById.size === 0) return; setAgents((prev) => prev.map((agent) => { - const health = healthById.get(agent.id) - if (!health) return agent + const health = healthById.get(agent.id); + if (!health) return agent; return { ...agent, - status: health.status === "offline" ? "offline" : health.runtimeStatus, + status: + health.status === "offline" ? "offline" : health.runtimeStatus, cpu: health.cpu ?? agent.cpu, memory: health.memory ?? agent.memory, currentTask: health.currentTask ?? agent.currentTask, lastHeartbeat: health.lastHeartbeat, offlineForSeconds: health.offlineForSeconds, - } + }; }), - ) - } + ); + }; - sendHeartbeats() - syncHealth() - const heartbeatId = window.setInterval(sendHeartbeats, 15_000) - const healthId = window.setInterval(syncHealth, 30_000) + sendHeartbeats(); + syncHealth(); + const heartbeatId = window.setInterval(sendHeartbeats, 15_000); + const healthId = window.setInterval(syncHealth, 30_000); return () => { - stopped = true - window.clearInterval(heartbeatId) - window.clearInterval(healthId) - } - }, []) + stopped = true; + window.clearInterval(heartbeatId); + window.clearInterval(healthId); + }; + }, []); useEffect(() => { const interval = window.setInterval(() => { - setTick((prev) => prev + 1) - }, 1200) + setTick((prev) => prev + 1); + }, 1200); - return () => window.clearInterval(interval) - }, []) + return () => window.clearInterval(interval); + }, []); useEffect(() => { - if (eventStreamConnected && hasRealtimeEvents) return + if (eventStreamConnected && hasRealtimeEvents) return; const interval = window.setInterval(() => { setAgents((prev) => @@ -862,7 +1185,7 @@ export function OpenStellarHub() { cpu: 0, memory: Math.max(0, agent.memory - 1), taskProgress: 0, - } + }; } const progressDelta = secureRandom() * 14 @@ -890,164 +1213,241 @@ export function OpenStellarHub() { memory: Math.max(20, Math.min(95, agent.memory + (secureRandom() - 0.5) * 6)), status, taskProgress: finishedTask ? 0 : taskProgress, - tasksCompleted: finishedTask ? agent.tasksCompleted + 1 : agent.tasksCompleted, - currentTask: finishedTask ? getRandomTask(agent.district) : agent.currentTask, - } - }) - ) - }, 1200) + tasksCompleted: finishedTask + ? agent.tasksCompleted + 1 + : agent.tasksCompleted, + currentTask: finishedTask + ? getRandomTask(agent.district) + : agent.currentTask, + }; + }), + ); + }, 1200); - return () => window.clearInterval(interval) - }, [eventStreamConnected, hasRealtimeEvents]) + return () => window.clearInterval(interval); + }, [eventStreamConnected, hasRealtimeEvents]); useEffect(() => { const chatInterval = window.setInterval(() => { setChatMessages((prev) => { - const next = generateChatMessage(agentsRef.current) - if (!next) return prev + const next = generateChatMessage(agentsRef.current); + if (!next) return prev; if (secureRandom() < 0.5) { pushLog(`relay ${next.fromName} -> ${next.toName}: ${next.message}`, "info", next.fromName) } - return [...prev.slice(-79), next] - }) - }, 2200) + return [...prev.slice(-79), next]; + }); + }, 2200); - return () => window.clearInterval(chatInterval) - }, [pushLog]) + return () => window.clearInterval(chatInterval); + }, [pushLog]); // Prune finished tx animations useEffect(() => { - if (txAnimations.length === 0) return + if (txAnimations.length === 0) return; const id = window.setInterval(() => { - const now = Date.now() - setTxAnimations(prev => prev.filter(a => now - a.startedAt < a.duration)) - }, 500) - return () => window.clearInterval(id) - }, [txAnimations.length]) + const now = Date.now(); + setTxAnimations((prev) => pruneAnimations(prev, now)); + }, 500); + return () => window.clearInterval(id); + }, [txAnimations.length]); useEffect(() => { - if (floatingOverlays.length === 0) return + if (floatingOverlays.length === 0) return; const id = window.setInterval(() => { - const now = Date.now() - setFloatingOverlays(prev => prev.filter(overlay => now - overlay.startedAt < overlay.duration)) - }, 500) - return () => window.clearInterval(id) - }, [floatingOverlays.length]) + const now = Date.now(); + setFloatingOverlays((prev) => pruneOverlays(prev, now)); + }, 500); + return () => window.clearInterval(id); + }, [floatingOverlays.length]); // Particle triggers are one-shot โ€” PixelCity consumes them into its ParticleSystem on // receipt, so this just garbage-collects the request objects shortly after. useEffect(() => { - if (particleTriggers.length === 0) return + if (particleTriggers.length === 0) return; const id = window.setTimeout(() => { - setParticleTriggers([]) - }, 500) - return () => window.clearTimeout(id) - }, [particleTriggers]) - - const handleSelectAgent = useCallback((id: string | null) => { - setSelectedAgentId(id) - - const picked = agentsRef.current.find((agent) => agent.id === id) - if (picked) { - pushLog(`agent selected: ${picked.name} (${picked.model})`, "info", picked.name) - } - }, [pushLog]) - - const handleUpdateAgentWallet = useCallback((agentId: string, wallet: MoltbotAgent["wallet"]) => { - setAgents((prev) => { - const updated = prev.map((agent) => (agent.id === agentId ? { ...agent, wallet } : agent)) - const updatedAgent = updated.find((agent) => agent.id === agentId) - if (updatedAgent && wallet?.publicKey) { - pushLog(`wallet linked: ${updatedAgent.name} -> ${wallet.publicKey.slice(0, 8)}...`, "success", updatedAgent.name) + setParticleTriggers([]); + }, 500); + return () => window.clearTimeout(id); + }, [particleTriggers]); + + const handleSelectAgent = useCallback( + (id: string | null) => { + setSelectedAgentId(id); + + const picked = agentsRef.current.find((agent) => agent.id === id); + if (picked) { + pushLog( + `agent selected: ${picked.name} (${picked.model})`, + "info", + picked.name, + ); + } + }, + [pushLog], + ); + + const handleUpdateAgentWallet = useCallback( + (agentId: string, wallet: MoltbotAgent["wallet"]) => { + setAgents((prev) => { + const updated = prev.map((agent) => + agent.id === agentId ? { ...agent, wallet } : agent, + ); + const updatedAgent = updated.find((agent) => agent.id === agentId); + if (updatedAgent && wallet?.publicKey) { + pushLog( + `wallet linked: ${updatedAgent.name} -> ${wallet.publicKey.slice(0, 8)}...`, + "success", + updatedAgent.name, + ); + } + return updated; + }); + }, + [pushLog], + ); + + const handleUpgradeSkill = useCallback( + (agentId: string, skillId: string) => { + const currentAgent = agentsRef.current.find( + (agent) => agent.id === agentId, + ); + if (!currentAgent) { + pushLog("skill upgrade blocked: agent not found", "warning", agentId); + return; } - return updated - }) - }, [pushLog]) - - const handleUpgradeSkill = useCallback((agentId: string, skillId: string) => { - const currentAgent = agentsRef.current.find((agent) => agent.id === agentId) - if (!currentAgent) { - pushLog("skill upgrade blocked: agent not found", "warning", agentId) - return - } - const preview = upgradeAgentSkill(currentAgent, skillId) - if (!preview.result) { - pushLog("skill upgrade blocked: skill not found", "warning", currentAgent.name) - return - } + const preview = upgradeAgentSkill(currentAgent, skillId); + if (!preview.result) { + pushLog( + "skill upgrade blocked: skill not found", + "warning", + currentAgent.name, + ); + return; + } - if (!preview.result.upgraded) { - const blockedReason = preview.result.reason === "max-level" ? "already at max level" : "not enough XP" - pushLog(`skill upgrade blocked: ${blockedReason}`, "warning", currentAgent.name) - toast.error("Skill Upgrade Blocked", { description: `${currentAgent.name}: ${blockedReason}` }) - return - } + if (!preview.result.upgraded) { + const blockedReason = + preview.result.reason === "max-level" + ? "already at max level" + : "not enough XP"; + pushLog( + `skill upgrade blocked: ${blockedReason}`, + "warning", + currentAgent.name, + ); + toast.error("Skill Upgrade Blocked", { + description: `${currentAgent.name}: ${blockedReason}`, + }); + return; + } - setAgents((prev) => - prev.map((agent) => (agent.id === agentId ? upgradeAgentSkill(agent, skillId).agent : agent)), - ) - - pushLog(`${preview.result.skill.name} upgraded to level ${preview.result.skill.level}`, "success", preview.agent.name) - toast.success("Skill Upgraded!", { description: `${preview.agent.name} upgraded ${preview.result.skill.name} to Level ${preview.result.skill.level}` }) - showAgentOverlay(preview.agent, `${preview.result.skill.name} Lv.${preview.result.skill.level}`, preview.agent.color) - }, [pushLog, showAgentOverlay]) - - const handleUpdateAgentAppearance = useCallback((agentId: string, appearance: AgentAppearance) => { - setAgents((prev) => - prev.map((agent) => - agent.id === agentId - ? { ...agent, appearance, color: appearance.customColor || agent.color } - : agent, - ), - ) - }, []) + setAgents((prev) => + prev.map((agent) => + agent.id === agentId + ? upgradeAgentSkill(agent, skillId).agent + : agent, + ), + ); + + pushLog( + `${preview.result.skill.name} upgraded to level ${preview.result.skill.level}`, + "success", + preview.agent.name, + ); + toast.success("Skill Upgraded!", { + description: `${preview.agent.name} upgraded ${preview.result.skill.name} to Level ${preview.result.skill.level}`, + }); + showAgentOverlay( + preview.agent, + `${preview.result.skill.name} Lv.${preview.result.skill.level}`, + preview.agent.color, + ); + }, + [pushLog, showAgentOverlay], + ); - const handleAddTransaction = useCallback((tx: WalletTransaction) => { - setTransactions((prev) => [tx, ...prev.slice(0, 99)]) - pushLog(`tx ${tx.fromName} -> ${tx.toName} (${tx.amount} XLM)`, "success", tx.fromName) - // Spawn a tx animation between the two agents - const current = agentsRef.current - const fromAgent = current.find(a => a.name === tx.fromName) - const toAgent = current.find(a => a.name === tx.toName) - if (fromAgent && toAgent) { - setTxAnimations(prev => [ - ...prev, - { - id: tx.id, - fromX: fromAgent.pixelX + 8, - fromY: fromAgent.pixelY + 10, - toX: toAgent.pixelX + 8, - toY: toAgent.pixelY + 10, - startedAt: Date.now(), - duration: 1800, - }, - ]) - } - }, [pushLog]) + const handleUpdateAgentAppearance = useCallback( + (agentId: string, appearance: AgentAppearance) => { + setAgents((prev) => + prev.map((agent) => + agent.id === agentId + ? { + ...agent, + appearance, + color: appearance.customColor || agent.color, + } + : agent, + ), + ); + }, + [], + ); + + const handleAddTransaction = useCallback( + (tx: WalletTransaction) => { + setTransactions((prev) => [tx, ...prev.slice(0, 99)]); + pushLog( + `tx ${tx.fromName} -> ${tx.toName} (${tx.amount} XLM)`, + "success", + tx.fromName, + ); + + // Spawn a tx animation between the two agents + const current = agentsRef.current; + const fromAgent = current.find((a) => a.name === tx.fromName); + const toAgent = current.find((a) => a.name === tx.toName); + if (fromAgent && toAgent) { + setTxAnimations((prev) => [ + ...prev, + { + id: tx.id, + fromX: fromAgent.pixelX + 8, + fromY: fromAgent.pixelY + 10, + toX: toAgent.pixelX + 8, + toY: toAgent.pixelY + 10, + startedAt: Date.now(), + duration: 1800, + }, + ]); + } + }, + [pushLog], + ); const handleMobileTabSelect = useCallback((tab: SidebarTabId) => { - setSidebarTab(tab) - setMobileControlsOpen(true) - }, []) - - const errorCount = agents.filter((agent) => agent.status === "error").length - const walletAlert = agents.some((agent) => !agent.wallet || (agent.wallet.funded && parseFloat(agent.wallet.balance) < 10)) - const openOfferCount = MOCK_OFFERS.filter((offer) => offer.status === "open").length + setSidebarTab(tab); + setMobileControlsOpen(true); + }, []); + + const errorCount = agents.filter((agent) => agent.status === "error").length; + const walletAlert = agents.some( + (agent) => + !agent.wallet || + (agent.wallet.funded && Number.parseFloat(agent.wallet.balance) < 10), + ); + const openOfferCount = MOCK_OFFERS.filter( + (offer) => offer.status === "open", + ).length; return ( - <div style={{ - width: "100%", - height: "100dvh", - display: "flex", - overflow: "hidden", - background: "#030712", - position: "relative", - paddingBottom: isMobile ? "calc(72px + env(safe-area-inset-bottom))" : 0, - }}> + <div + style={{ + width: "100%", + height: "100dvh", + display: "flex", + overflow: "hidden", + background: "#030712", + position: "relative", + paddingBottom: isMobile + ? "calc(72px + env(safe-area-inset-bottom))" + : 0, + }} + > {showOnboarding && <OnboardingModal onDone={handleDoneOnboarding} />} {/* Canvas area */} @@ -1067,13 +1467,16 @@ export function OpenStellarHub() { districtStandings={districtStandings} /> - <DistrictEventOverlay event={activeDistrictEvent} standings={districtStandings} /> + <DistrictEventOverlay + event={activeDistrictEvent} + standings={districtStandings} + /> <AudioControls engine={audioEngine} /> {isMobile === false && ( <button - onClick={() => setSidebarOpen(o => !o)} + onClick={() => setSidebarOpen((o) => !o)} style={{ position: "absolute", top: "50%", @@ -1098,21 +1501,23 @@ export function OpenStellarHub() { </button> )} - <footer style={{ - position: "absolute", - left: 12, - bottom: 10, - zIndex: 4, - display: "flex", - gap: 12, - flexWrap: "wrap", - alignItems: "center", - padding: "7px 9px", - background: "rgba(3,7,18,0.78)", - border: "1px solid rgba(42,58,82,0.86)", - borderRadius: 6, - backdropFilter: "blur(6px)", - }}> + <footer + style={{ + position: "absolute", + left: 12, + bottom: 10, + zIndex: 4, + display: "flex", + gap: 12, + flexWrap: "wrap", + alignItems: "center", + padding: "7px 9px", + background: "rgba(3,7,18,0.78)", + border: "1px solid rgba(42,58,82,0.86)", + borderRadius: 6, + backdropFilter: "blur(6px)", + }} + > {LEGAL_LINKS.map((link) => ( <a key={link.href} @@ -1195,13 +1600,13 @@ export function OpenStellarHub() { }} > {SIDEBAR_TABS.map((tab) => { - const Icon = MOBILE_NAV_ICONS[tab.id] - const active = sidebarTab === tab.id + const Icon = MOBILE_NAV_ICONS[tab.id]; + const active = sidebarTab === tab.id; const hasBadge = (tab.id === "chat" && chatMessages.length > 0) || (tab.id === "overview" && errorCount > 0) || (tab.id === "offers" && openOfferCount > 0) || - (tab.id === "wallet" && walletAlert) + (tab.id === "wallet" && walletAlert); return ( <button @@ -1214,7 +1619,9 @@ export function OpenStellarHub() { position: "relative", minWidth: 0, minHeight: 54, - border: active ? "1px solid #22d3ee66" : "1px solid transparent", + border: active + ? "1px solid #22d3ee66" + : "1px solid transparent", borderRadius: 8, background: active ? "#111827" : "transparent", color: active ? "#22d3ee" : "#94a3b8", @@ -1231,7 +1638,14 @@ export function OpenStellarHub() { }} > <Icon size={18} aria-hidden="true" /> - <span style={{ maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> + <span + style={{ + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > {tab.label} </span> {hasBadge && ( @@ -1244,12 +1658,12 @@ export function OpenStellarHub() { width: 7, height: 7, borderRadius: "50%", - background: tab.id === "wallet" ? "#fbbf24" : tab.id === "overview" ? "#f87171" : "#34d399", + background: getTabBadgeColor(tab.id), }} /> )} </button> - ) + ); })} <a href="/admin" @@ -1275,7 +1689,10 @@ export function OpenStellarHub() { </a> </nav> - <Drawer open={mobileControlsOpen} onOpenChange={setMobileControlsOpen}> + <Drawer + open={mobileControlsOpen} + onOpenChange={setMobileControlsOpen} + > <DrawerContent aria-describedby={undefined} style={{ @@ -1311,5 +1728,5 @@ export function OpenStellarHub() { </> )} </div> - ) + ); } diff --git a/components/sidebar-panel.tsx b/components/sidebar-panel.tsx index 8e62888b..f7cddb0d 100644 --- a/components/sidebar-panel.tsx +++ b/components/sidebar-panel.tsx @@ -780,7 +780,7 @@ export function SidebarPanel({ const chatCount = chatMessages.length const errorCount = agents.filter(a => a.status === "error").length - const walletAlert = agents.some(a => !a.wallet || (a.wallet.funded && parseFloat(a.wallet.balance) < 10)) + const walletAlert = agents.some(a => !a.wallet || (a.wallet.funded && Number.parseFloat(a.wallet.balance) < 10)) const openOfferCount = MOCK_OFFERS.filter(offer => offer.status === "open").length return ( diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx index da22d6e0..5d4f61d5 100644 --- a/components/ui/chart.tsx +++ b/components/ui/chart.tsx @@ -189,7 +189,7 @@ function ChartTooltipContent({ return ( <div - key={item.dataKey} + key={item.dataKey || index} className={cn( '[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5', indicator === 'dot' && 'items-center', diff --git a/lib/agent-runtime/__tests__/agent.test.ts b/lib/agent-runtime/__tests__/agent.test.ts index 8827fb7d..8b087a52 100644 --- a/lib/agent-runtime/__tests__/agent.test.ts +++ b/lib/agent-runtime/__tests__/agent.test.ts @@ -1,11 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { Agent } from "@/lib/agent-runtime/agent" -import type { AgentConfig, Task } from "@/lib/agent-runtime/types" -import { POST } from "@/app/api/agents/[id]/task/route" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Agent } from "@/lib/agent-runtime/agent"; +import type { AgentConfig, Task } from "@/lib/agent-runtime/types"; +import { POST } from "@/app/api/agents/[id]/task/route"; -const jest = vi +const jest = vi; -function createConfig(id: string, overrides: Partial<AgentConfig> = {}): AgentConfig { +function createConfig( + id: string, + overrides: Partial<AgentConfig> = {}, +): AgentConfig { return { id, name: `Agent ${id}`, @@ -13,7 +16,7 @@ function createConfig(id: string, overrides: Partial<AgentConfig> = {}): AgentCo heartbeatIntervalMs: 1_000, offlineAfterMs: 60_000, ...overrides, - } + }; } function createTask(id = "task-1"): Task { @@ -22,102 +25,118 @@ function createTask(id = "task-1"): Task { title: "Test task", payload: { ok: true }, createdAt: new Date(Date.now()).toISOString(), - } + }; } describe("Agent SDK lifecycle", () => { beforeEach(() => { - jest.useFakeTimers() - jest.setSystemTime(new Date("2026-06-30T00:00:00.000Z")) - }) + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-06-30T00:00:00.000Z")); + }); afterEach(() => { - jest.runOnlyPendingTimers() - jest.useRealTimers() - vi.restoreAllMocks() - }) + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + vi.restoreAllMocks(); + }); it("start() transitions agent status from idle to running and starts the heartbeat", async () => { - const agent = new Agent(createConfig("agent-start")) + const agent = new Agent(createConfig("agent-start")); - expect(agent.getStatus()).toBe("idle") + expect(agent.getStatus()).toBe("idle"); - await agent.start() - const firstHeartbeat = agent.getMetrics().lastHeartbeat + await agent.start(); + const firstHeartbeat = agent.getMetrics().lastHeartbeat; - expect(agent.getStatus()).toBe("running") - expect(firstHeartbeat).toBe("2026-06-30T00:00:00.000Z") + expect(agent.getStatus()).toBe("running"); + expect(firstHeartbeat).toBe("2026-06-30T00:00:00.000Z"); - jest.advanceTimersByTime(1_000) + jest.advanceTimersByTime(1_000); - expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:01.000Z") - }) + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:01.000Z"); + }); it("stop() transitions to stopped and clears the heartbeat interval", async () => { - const agent = new Agent(createConfig("agent-stop")) - await agent.start() + const agent = new Agent(createConfig("agent-stop")); + await agent.start(); - jest.advanceTimersByTime(1_000) - await agent.stop() - const stoppedHeartbeat = agent.getMetrics().lastHeartbeat + jest.advanceTimersByTime(1_000); + await agent.stop(); + const stoppedHeartbeat = agent.getMetrics().lastHeartbeat; - expect(agent.getStatus()).toBe("stopped") + expect(agent.getStatus()).toBe("stopped"); - jest.advanceTimersByTime(5_000) + jest.advanceTimersByTime(5_000); - expect(agent.getMetrics().lastHeartbeat).toBe(stoppedHeartbeat) - }) + expect(agent.getMetrics().lastHeartbeat).toBe(stoppedHeartbeat); + }); it("restart() calls stop() then start()", async () => { - const agent = new Agent(createConfig("agent-restart")) - const calls: string[] = [] - const stopSpy = vi.spyOn(agent, "stop").mockImplementation(async () => { calls.push("stop") }) - const startSpy = vi.spyOn(agent, "start").mockImplementation(async () => { calls.push("start") }) - - await agent.restart() - - expect(stopSpy).toHaveBeenCalledTimes(1) - expect(startSpy).toHaveBeenCalledTimes(1) - expect(calls).toEqual(["stop", "start"]) - }) + const agent = new Agent(createConfig("agent-restart")); + const calls: string[] = []; + const stopSpy = vi.spyOn(agent, "stop").mockImplementation(async () => { + calls.push("stop"); + }); + const startSpy = vi.spyOn(agent, "start").mockImplementation(async () => { + calls.push("start"); + }); + + await agent.restart(); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(startSpy).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["stop", "start"]); + }); it("executeTask() sets status to working, calls the executor, and returns to running", async () => { - const agent = new Agent(createConfig("agent-task")) - await agent.start() - const task = createTask() + const agent = new Agent(createConfig("agent-task")); + await agent.start(); + const task = createTask(); const handler = vi.fn(() => { - expect(agent.getStatus()).toBe("working") - return "executor completed" - }) - agent.onTask(handler) - - const result = await agent.executeTask(task) - - expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: task.id }), agent) - expect(result).toMatchObject({ taskId: task.id, agentId: agent.id, status: "completed", summary: "executor completed" }) - expect(agent.getStatus()).toBe("running") - }) + expect(agent.getStatus()).toBe("working"); + return "executor completed"; + }); + agent.onTask(handler); + + const result = await agent.executeTask(task); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ id: task.id }), + agent, + ); + expect(result).toMatchObject({ + taskId: task.id, + agentId: agent.id, + status: "completed", + summary: "executor completed", + }); + expect(agent.getStatus()).toBe("running"); + }); it("executeTask() on a stopped agent throws an error", async () => { - const agent = new Agent(createConfig("agent-stopped-task")) - await agent.start() - await agent.stop() + const agent = new Agent(createConfig("agent-stopped-task")); + await agent.start(); + await agent.stop(); - await expect(agent.executeTask(createTask())).rejects.toThrow("Cannot execute task on a stopped agent") - }) + await expect(agent.executeTask(createTask())).rejects.toThrow( + "Cannot execute task on a stopped agent", + ); + }); it("heartbeat updates lastSeen timestamp every interval", async () => { - const agent = new Agent(createConfig("agent-heartbeat", { heartbeatIntervalMs: 2_500 })) - await agent.start() + const agent = new Agent( + createConfig("agent-heartbeat", { heartbeatIntervalMs: 2_500 }), + ); + await agent.start(); - expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:00.000Z") + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:00.000Z"); - jest.advanceTimersByTime(2_500) - expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:02.500Z") + jest.advanceTimersByTime(2_500); + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:02.500Z"); - jest.advanceTimersByTime(2_500) - expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:05.000Z") - }) + jest.advanceTimersByTime(2_500); + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:05.000Z"); + }); it("POST /api/agents/[id]/task returns 404 for unknown agent IDs", async () => { const response = await POST( @@ -126,9 +145,12 @@ describe("Agent SDK lifecycle", () => { body: JSON.stringify(createTask("missing-agent-task")), }), { params: Promise.resolve({ id: "not-a-real-agent" }) }, - ) - - await expect(response.json()).resolves.toMatchObject({ ok: false, error: "agent_not_found" }) - expect(response.status).toBe(404) - }) -}) + ); + + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: "agent_not_found", + }); + expect(response.status).toBe(404); + }); +}); diff --git a/lib/agent-runtime/agent.ts b/lib/agent-runtime/agent.ts index 17b1a7a8..af07b38f 100644 --- a/lib/agent-runtime/agent.ts +++ b/lib/agent-runtime/agent.ts @@ -1,204 +1,362 @@ -import { recordAgentHeartbeat } from "@/lib/agents/agent-health-store" -import { getAgentHealthSummary, recordAgentExecutionError, recordAgentExecutionSuccess, recordAgentInvocation } from "@/lib/agents/agent-error-store" -import { sendAgentMessage } from "@/lib/agent-runtime/messaging" -import type { AgentConfig, AgentMetrics, AgentRuntimeContext, AgentMessage, MessageHandler, Task, TaskHandler, TaskResult } from "@/lib/agent-runtime/types" -import { publishSystemEvent } from "@/lib/events/system-events" -import type { AgentStatus } from "@/lib/types" +import { randomBytes } from "node:crypto"; +import { recordAgentHeartbeat } from "@/lib/agents/agent-health-store"; +import { + getAgentHealthSummary, + recordAgentExecutionError, + recordAgentExecutionSuccess, + recordAgentInvocation, +} from "@/lib/agents/agent-error-store"; +import { sendAgentMessage } from "@/lib/agent-runtime/messaging"; +import type { + AgentConfig, + AgentMetrics, + AgentRuntimeContext, + AgentMessage, + MessageHandler, + Task, + TaskHandler, + TaskResult, +} from "@/lib/agent-runtime/types"; +import { publishSystemEvent } from "@/lib/events/system-events"; +import type { AgentStatus } from "@/lib/types"; -const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000 -const DEFAULT_OFFLINE_AFTER_MS = 30_000 +const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; +const DEFAULT_OFFLINE_AFTER_MS = 30_000; interface TaskRecord { - task: Task - result: TaskResult | null - status: "queued" | "running" | "completed" | "failed" - updatedAt: string + task: Task; + result: TaskResult | null; + status: "queued" | "running" | "completed" | "failed"; + updatedAt: string; } interface RuntimeState { - agents: Map<string, Agent> - tasks: Map<string, TaskRecord[]> + agents: Map<string, Agent>; + tasks: Map<string, TaskRecord[]>; } -const globalState = globalThis as typeof globalThis & { __openStellarAgentRuntime__?: RuntimeState } -const runtimeState: RuntimeState = globalState.__openStellarAgentRuntime__ ?? { agents: new Map(), tasks: new Map() } -if (!globalState.__openStellarAgentRuntime__) globalState.__openStellarAgentRuntime__ = runtimeState +const globalState = globalThis as typeof globalThis & { + __openStellarAgentRuntime__?: RuntimeState; +}; +const runtimeState: RuntimeState = + (globalState.__openStellarAgentRuntime__ ??= { + agents: new Map(), + tasks: new Map(), + }); function isoNow(): string { - return new Date().toISOString() + return new Date().toISOString(); } function normalizeTask(input: unknown): Task { - const body = typeof input === "object" && input !== null ? input as Record<string, unknown> : {} - const title = String(body.title || body.description || "Agent task").trim() + const body = + typeof input === "object" && input !== null + ? (input as Record<string, unknown>) + : {}; + const randSuffix = globalThis.crypto?.randomUUID() + ? globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, 6) + : randomBytes(4).toString("hex").slice(0, 6); + const idStr = + typeof body.id === "string" && body.id + ? body.id + : `task_${Date.now()}_${randSuffix}`; + let rawTitle = "Agent task"; + if (typeof body.title === "string" && body.title) { + rawTitle = body.title; + } else if (typeof body.description === "string" && body.description) { + rawTitle = body.description; + } + const title = String(rawTitle).trim(); return { - id: String(body.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`), + id: idStr, title: title || "Agent task", description: body.description ? String(body.description) : undefined, payload: body.payload ?? {}, district: body.district as Task["district"], - createdAt: body.createdAt ? String(body.createdAt) : isoNow(), - } + createdAt: typeof body.createdAt === "string" ? body.createdAt : isoNow(), + }; } function writeTaskRecord(agentId: string, record: TaskRecord): void { - const records = runtimeState.tasks.get(agentId) ?? [] - const index = records.findIndex((entry) => entry.task.id === record.task.id) - if (index >= 0) records[index] = record - else records.unshift(record) - runtimeState.tasks.set(agentId, records.slice(0, 200)) + const records = runtimeState.tasks.get(agentId) ?? []; + const index = records.findIndex((entry) => entry.task.id === record.task.id); + if (index >= 0) records[index] = record; + else records.unshift(record); + runtimeState.tasks.set(agentId, records.slice(0, 200)); } export function listAgentTaskRecords(agentId: string): TaskRecord[] { - return runtimeState.tasks.get(agentId) ?? [] + return runtimeState.tasks.get(agentId) ?? []; } -export function getAgentTaskRecord(agentId: string, taskId: string): TaskRecord | null { - return listAgentTaskRecords(agentId).find((record) => record.task.id === taskId) ?? null +export function getAgentTaskRecord( + agentId: string, + taskId: string, +): TaskRecord | null { + return ( + listAgentTaskRecords(agentId).find((record) => record.task.id === taskId) ?? + null + ); } export class Agent implements AgentRuntimeContext { - readonly id: string - readonly config: AgentConfig - private status: AgentStatus - private taskHandlers: TaskHandler[] = [] - private messageHandlers: MessageHandler[] = [] - private heartbeatTimer: ReturnType<typeof setInterval> | null = null - private lastHeartbeatMs: number | null = null - private startedAtMs: number | null = null - private stoppedAtMs: number | null = null - private taskDurations: number[] = [] - private metrics = { tasksCompleted: 0, tasksFailed: 0, messagesSent: 0, messagesReceived: 0 } + readonly id: string; + readonly config: AgentConfig; + private status: AgentStatus; + private taskHandlers: TaskHandler[] = []; + private messageHandlers: MessageHandler[] = []; + private heartbeatTimer: ReturnType<typeof setInterval> | null = null; + private lastHeartbeatMs: number | null = null; + private startedAtMs: number | null = null; + private stoppedAtMs: number | null = null; + private taskDurations: number[] = []; + private metrics = { + tasksCompleted: 0, + tasksFailed: 0, + messagesSent: 0, + messagesReceived: 0, + }; + + private startHooks: (() => void | Promise<void>)[] = []; + private stopHooks: (() => void | Promise<void>)[] = []; + private errorHooks: ((error: Error) => void | Promise<void>)[] = []; + private stateChangeHooks: ((status: AgentStatus) => void | Promise<void>)[] = + []; constructor(config: AgentConfig) { - if (!config.id.trim()) throw new Error("Agent id is required") - if (!config.name.trim()) throw new Error("Agent name is required") - this.id = config.id.trim() - this.config = { ...config, id: this.id } - this.status = config.status ?? "idle" - runtimeState.agents.set(this.id, this) + if (!config.id.trim()) throw new Error("Agent id is required"); + if (!config.name.trim()) throw new Error("Agent name is required"); + this.id = config.id.trim(); + this.config = { ...config, id: this.id }; + this.status = config.status ?? "idle"; + runtimeState.agents.set(this.id, this); + } + + private setStatus(newStatus: AgentStatus): void { + const prev = this.status; + this.status = newStatus; + if (prev !== newStatus) { + for (const hook of this.stateChangeHooks) { + void hook(newStatus); + } + } + } + + onStart(handler: () => void | Promise<void>): void { + this.startHooks.push(handler); + } + + onStop(handler: () => void | Promise<void>): void { + this.stopHooks.push(handler); + } + + onError(handler: (error: Error) => void | Promise<void>): void { + this.errorHooks.push(handler); + } + + onStateChange(handler: (status: AgentStatus) => void | Promise<void>): void { + this.stateChangeHooks.push(handler); } async start(): Promise<void> { - this.startedAtMs = this.startedAtMs ?? Date.now() - this.stoppedAtMs = null - this.status = "running" - this.recordHeartbeat() - this.heartbeatTimer ??= setInterval(() => this.recordHeartbeat(), this.config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS) - publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status }) + this.startedAtMs = this.startedAtMs ?? Date.now(); + this.stoppedAtMs = null; + this.setStatus("running"); + this.recordHeartbeat(); + this.heartbeatTimer ??= setInterval( + () => this.recordHeartbeat(), + this.config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, + ); + publishSystemEvent({ + type: "agent.status", + agentId: this.id, + status: this.status, + }); + for (const hook of this.startHooks) { + await hook(); + } } async stop(): Promise<void> { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) - this.heartbeatTimer = null - this.stoppedAtMs = Date.now() - this.status = "stopped" - this.recordHeartbeat() - publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status }) + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + this.stoppedAtMs = Date.now(); + this.setStatus("stopped"); + this.recordHeartbeat(); + publishSystemEvent({ + type: "agent.status", + agentId: this.id, + status: this.status, + }); + for (const hook of this.stopHooks) { + await hook(); + } } async restart(): Promise<void> { - await this.stop() - await this.start() + await this.stop(); + await this.start(); } onTask(handler: TaskHandler): void { - this.taskHandlers.push(handler) + this.taskHandlers.push(handler); } onMessage(handler: MessageHandler): void { - this.messageHandlers.push(handler) + this.messageHandlers.push(handler); } async executeTask(taskInput: Task): Promise<TaskResult> { - if (this.status === "stopped" || this.status === "offline") throw new Error("Cannot execute task on a stopped agent") - const task = normalizeTask(taskInput) - const startedAt = isoNow() - const startedMs = Date.now() - const health = getAgentHealthSummary(this.id) - if (health.degraded) console.warn(`[agent-health] Executing task for degraded agent ${this.id}`) - this.status = health.degraded ? "degraded" : "working" - recordAgentInvocation(this.id) - this.recordHeartbeat(task.title) - writeTaskRecord(this.id, { task, result: null, status: "running", updatedAt: startedAt }) - publishSystemEvent({ type: "task.started", agentId: this.id, task: { id: task.id, title: task.title, district: task.district } }) + if (this.status === "stopped" || this.status === "offline") + throw new Error("Cannot execute task on a stopped agent"); + const task = normalizeTask(taskInput); + const startedAt = isoNow(); + const startedMs = Date.now(); + const health = getAgentHealthSummary(this.id); + if (health.degraded) + console.warn( + `[agent-health] Executing task for degraded agent ${this.id}`, + ); + this.status = health.degraded ? "degraded" : "working"; + recordAgentInvocation(this.id); + this.recordHeartbeat(task.title); + writeTaskRecord(this.id, { + task, + result: null, + status: "running", + updatedAt: startedAt, + }); + publishSystemEvent({ + type: "task.started", + agentId: this.id, + task: { id: task.id, title: task.title, district: task.district }, + }); try { - const handler = this.taskHandlers.at(-1) - const handled = handler ? await handler(task, this) : undefined - const completedAt = isoNow() - const durationMs = Date.now() - startedMs + const handler = this.taskHandlers.at(-1); + const handled = handler ? await handler(task, this) : undefined; + const completedAt = isoNow(); + const durationMs = Date.now() - startedMs; const result: TaskResult = { taskId: task.id, agentId: this.id, status: "completed", - summary: typeof handled === "string" ? handled : handled?.summary ?? `Completed task: ${task.title}`, + summary: + typeof handled === "string" + ? handled + : (handled?.summary ?? `Completed task: ${task.title}`), output: typeof handled === "object" ? handled.output : undefined, startedAt, completedAt, durationMs, - } - this.metrics.tasksCompleted += 1 - recordAgentExecutionSuccess(this.id) - this.taskDurations.push(durationMs) - this.status = "running" - this.recordHeartbeat() - writeTaskRecord(this.id, { task, result, status: "completed", updatedAt: completedAt }) - publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.summary, durationMs } }) - return result + }; + this.metrics.tasksCompleted += 1; + recordAgentExecutionSuccess(this.id); + this.taskDurations.push(durationMs); + this.status = "running"; + this.recordHeartbeat(); + writeTaskRecord(this.id, { + task, + result, + status: "completed", + updatedAt: completedAt, + }); + publishSystemEvent({ + type: "task.completed", + agentId: this.id, + taskId: task.id, + result: { summary: result.summary, durationMs }, + }); + return result; } catch (error) { - const completedAt = isoNow() - const durationMs = Date.now() - startedMs + const errObj = error instanceof Error ? error : new Error(String(error)); + for (const hook of this.errorHooks) { + void hook(errObj); + } + const completedAt = isoNow(); + const durationMs = Date.now() - startedMs; const result: TaskResult = { taskId: task.id, agentId: this.id, status: "failed", summary: "Task failed", - error: error instanceof Error ? error.message : "Unknown task failure", + error: errObj.message, startedAt, completedAt, durationMs, - } - this.metrics.tasksFailed += 1 - const health = recordAgentExecutionError({ agentId: this.id, error, taskExcerpt: task.title }) - this.status = health.degraded ? "degraded" : "error" - this.recordHeartbeat(task.title) - writeTaskRecord(this.id, { task, result, status: "failed", updatedAt: completedAt }) - publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.error ?? result.summary, durationMs } }) - return result + }; + this.metrics.tasksFailed += 1; + const health = recordAgentExecutionError({ + agentId: this.id, + error, + taskExcerpt: task.title, + }); + this.setStatus(health.degraded ? "degraded" : "error"); + this.recordHeartbeat(task.title); + writeTaskRecord(this.id, { + task, + result, + status: "failed", + updatedAt: completedAt, + }); + publishSystemEvent({ + type: "task.completed", + agentId: this.id, + taskId: task.id, + result: { summary: result.error ?? result.summary, durationMs }, + }); + return result; } } async sendMessage(toAgentId: string, message: AgentMessage): Promise<void> { - sendAgentMessage({ ...message, fromAgentId: this.id, toAgentId }) - this.metrics.messagesSent += 1 + sendAgentMessage({ ...message, fromAgentId: this.id, toAgentId }); + this.metrics.messagesSent += 1; } receiveMessage(message: AgentMessage): void { - this.metrics.messagesReceived += 1 - for (const handler of this.messageHandlers) void handler(message, this) + this.metrics.messagesReceived += 1; + for (const handler of this.messageHandlers) void handler(message, this); } getStatus(): AgentStatus { - if (this.lastHeartbeatMs && Date.now() - this.lastHeartbeatMs > (this.config.offlineAfterMs ?? DEFAULT_OFFLINE_AFTER_MS)) return "offline" - return this.status + if ( + this.lastHeartbeatMs && + Date.now() - this.lastHeartbeatMs > + (this.config.offlineAfterMs ?? DEFAULT_OFFLINE_AFTER_MS) + ) + return "offline"; + return this.status; } getMetrics(): AgentMetrics { - const uptimeMs = this.startedAtMs ? Math.max(0, (this.stoppedAtMs ?? Date.now()) - this.startedAtMs) : 0 - const totalDuration = this.taskDurations.reduce((sum, duration) => sum + duration, 0) + const uptimeMs = this.startedAtMs + ? Math.max(0, (this.stoppedAtMs ?? Date.now()) - this.startedAtMs) + : 0; + const totalDuration = this.taskDurations.reduce( + (sum, duration) => sum + duration, + 0, + ); return { ...this.metrics, - startedAt: this.startedAtMs ? new Date(this.startedAtMs).toISOString() : null, - stoppedAt: this.stoppedAtMs ? new Date(this.stoppedAtMs).toISOString() : null, - lastHeartbeat: this.lastHeartbeatMs ? new Date(this.lastHeartbeatMs).toISOString() : null, + startedAt: this.startedAtMs + ? new Date(this.startedAtMs).toISOString() + : null, + stoppedAt: this.stoppedAtMs + ? new Date(this.stoppedAtMs).toISOString() + : null, + lastHeartbeat: this.lastHeartbeatMs + ? new Date(this.lastHeartbeatMs).toISOString() + : null, uptimeMs, - averageTaskDurationMs: this.taskDurations.length ? Math.round(totalDuration / this.taskDurations.length) : 0, - } + averageTaskDurationMs: this.taskDurations.length + ? Math.round(totalDuration / this.taskDurations.length) + : 0, + }; } private recordHeartbeat(currentTask: string | null = null): void { - this.lastHeartbeatMs = Date.now() + this.lastHeartbeatMs = Date.now(); recordAgentHeartbeat(this.id, { status: this.status, cpu: this.config.cpu, @@ -206,18 +364,18 @@ export class Agent implements AgentRuntimeContext { currentTask, autoRestart: this.config.autoRestart, nowMs: this.lastHeartbeatMs, - }) + }); } } export function getOrCreateAgent(config: AgentConfig): Agent { - return runtimeState.agents.get(config.id) ?? new Agent(config) + return runtimeState.agents.get(config.id) ?? new Agent(config); } export function getAgent(agentId: string): Agent | null { - return runtimeState.agents.get(agentId) ?? null + return runtimeState.agents.get(agentId) ?? null; } export function normalizeTaskInput(input: unknown): Task { - return normalizeTask(input) + return normalizeTask(input); } diff --git a/lib/agent-runtime/cloud-agents.ts b/lib/agent-runtime/cloud-agents.ts index 0cc291ff..36ddcece 100644 --- a/lib/agent-runtime/cloud-agents.ts +++ b/lib/agent-runtime/cloud-agents.ts @@ -1,46 +1,66 @@ -import type { DistrictId, MoltbotAgent } from "@/lib/types" -import { DISTRICTS, SPRITE_COUNT } from "@/lib/data" -import { publishSystemEvent } from "@/lib/events/system-events" -import { recordAgentHeartbeat } from "@/lib/agents/agent-health-store" -import { getAgentHealthSummary, recordAgentExecutionSuccess } from "@/lib/agents/agent-error-store" +import type { DistrictId, MoltbotAgent } from "@/lib/types"; +import { DISTRICTS, SPRITE_COUNT } from "@/lib/data"; +import { publishSystemEvent } from "@/lib/events/system-events"; +import { recordAgentHeartbeat } from "@/lib/agents/agent-health-store"; +import { + getAgentHealthSummary, + recordAgentExecutionSuccess, +} from "@/lib/agents/agent-error-store"; export type CloudAgentConfig = { - id: string - name: string - model: string - district: DistrictId - endpointUrl: string - queueMode: "post" | "sse" - createdAt: string - lastTaskAt: string | null - lastResult: string | null -} + id: string; + name: string; + model: string; + district: DistrictId; + endpointUrl: string; + queueMode: "post" | "sse"; + createdAt: string; + lastTaskAt: string | null; + lastResult: string | null; +}; -const globalCloud = globalThis as typeof globalThis & { __openStellarCloudAgents__?: Map<string, CloudAgentConfig> } -const configs = globalCloud.__openStellarCloudAgents__ ?? new Map<string, CloudAgentConfig>() -if (!globalCloud.__openStellarCloudAgents__) globalCloud.__openStellarCloudAgents__ = configs +const globalCloud = globalThis as typeof globalThis & { + __openStellarCloudAgents__?: Map<string, CloudAgentConfig>; +}; +const configs = + (globalCloud.__openStellarCloudAgents__ ??= new Map<string, CloudAgentConfig>()); function slugify(value: string): string { - return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 48); } function appUrl(req?: Request): string { - const envUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "") - if (envUrl) return envUrl + const envUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ""); + if (envUrl) return envUrl; if (req) { - const url = new URL(req.url) - return `${url.protocol}//${url.host}` + const url = new URL(req.url); + return `${url.protocol}//${url.host}`; } - return "http://localhost:3000" + return "http://localhost:3000"; } -export function provisionCloudAgent(input: { name?: string; model?: string; district?: DistrictId; queueMode?: "post" | "sse" }, req?: Request): CloudAgentConfig { - const district = DISTRICTS.some((d) => d.id === input.district) ? input.district! : "research" - const name = (input.name || `Cloud-${configs.size + 1}`).trim().slice(0, 40) - const idBase = slugify(name) || "cloud-agent" - let id = idBase.startsWith("cloud-") ? idBase : `cloud-${idBase}` - let suffix = 2 - while (configs.has(id)) id = `${idBase}-${suffix++}` +export function provisionCloudAgent( + input: { + name?: string; + model?: string; + district?: DistrictId; + queueMode?: "post" | "sse"; + }, + req?: Request, +): CloudAgentConfig { + const district = DISTRICTS.some((d) => d.id === input.district) + ? input.district! + : "research"; + const name = (input.name || `Cloud-${configs.size + 1}`).trim().slice(0, 40); + const idBase = slugify(name) || "cloud-agent"; + let id = idBase.startsWith("cloud-") ? idBase : `cloud-${idBase}`; + let suffix = 2; + while (configs.has(id)) id = `${idBase}-${suffix++}`; const config: CloudAgentConfig = { id, @@ -52,35 +72,54 @@ export function provisionCloudAgent(input: { name?: string; model?: string; dist createdAt: new Date().toISOString(), lastTaskAt: null, lastResult: null, - } - configs.set(id, config) - recordAgentHeartbeat(id, { status: "active", cpu: 5, memory: 18, currentTask: "Waiting for cloud tasks", autoRestart: true }) - publishSystemEvent({ type: "agent.status", agentId: id, status: "active" }) - return config + }; + configs.set(id, config); + recordAgentHeartbeat(id, { + status: "active", + cpu: 5, + memory: 18, + currentTask: "Waiting for cloud tasks", + autoRestart: true, + }); + publishSystemEvent({ type: "agent.status", agentId: id, status: "active" }); + return config; } export function listCloudAgentConfigs(): CloudAgentConfig[] { - return Array.from(configs.values()).sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + return Array.from(configs.values()).sort((a, b) => + a.createdAt.localeCompare(b.createdAt), + ); } export function getCloudAgentConfig(id: string): CloudAgentConfig | null { - return configs.get(id) ?? null + return configs.get(id) ?? null; } -export function updateCloudAgentResult(id: string, summary: string): CloudAgentConfig | null { - const config = configs.get(id) - if (!config) return null - recordAgentExecutionSuccess(id) - const updated = { ...config, lastTaskAt: new Date().toISOString(), lastResult: summary.slice(0, 240) } - configs.set(id, updated) - return updated +export function updateCloudAgentResult( + id: string, + summary: string, +): CloudAgentConfig | null { + const config = configs.get(id); + if (!config) return null; + recordAgentExecutionSuccess(id); + const updated = { + ...config, + lastTaskAt: new Date().toISOString(), + lastResult: summary.slice(0, 240), + }; + configs.set(id, updated); + return updated; } -export function cloudConfigToAgent(config: CloudAgentConfig, index = 0): MoltbotAgent { - const district = DISTRICTS.find((d) => d.id === config.district) ?? DISTRICTS[0] - const x = district.x + 42 + (index * 34) % Math.max(80, district.w - 80) - const y = district.y + 58 + (index * 29) % Math.max(80, district.h - 80) - const health = getAgentHealthSummary(config.id) +export function cloudConfigToAgent( + config: CloudAgentConfig, + index = 0, +): MoltbotAgent { + const district = + DISTRICTS.find((d) => d.id === config.district) ?? DISTRICTS[0]; + const x = district.x + 42 + ((index * 34) % Math.max(80, district.w - 80)); + const y = district.y + 58 + ((index * 29) % Math.max(80, district.h - 80)); + const health = getAgentHealthSummary(config.id); return { id: config.id, name: config.name, @@ -91,7 +130,9 @@ export function cloudConfigToAgent(config: CloudAgentConfig, index = 0): Moltbot cpu: 5, memory: 18, tasksCompleted: config.lastTaskAt ? 1 : 0, - currentTask: health.degraded ? `Degraded: ${health.errorCount24h} errors in 24h` : config.lastResult ?? "Waiting for cloud tasks", + currentTask: health.degraded + ? `Degraded: ${health.errorCount24h} errors in 24h` + : (config.lastResult ?? "Waiting for cloud tasks"), taskProgress: 0, color: "#38bdf8", pixelX: x, @@ -101,10 +142,19 @@ export function cloudConfigToAgent(config: CloudAgentConfig, index = 0): Moltbot frame: 0, direction: "right", spriteId: (index + 5) % SPRITE_COUNT, - skills: [{ id: `${config.id}-edge`, name: "Edge Runtime", level: 1, maxLevel: 5, xp: 0, xpToNext: 100 }], + skills: [ + { + id: `${config.id}-edge`, + name: "Edge Runtime", + level: 1, + maxLevel: 5, + xp: 0, + xpToNext: 100, + }, + ], autoRestart: true, lastHeartbeat: health.lastSeen ?? config.createdAt, offlineForSeconds: health.errorCount24h, appearance: { skin: "neon", accessories: [], customColor: null }, - } + }; } diff --git a/lib/agent-runtime/cloud-deploy.ts b/lib/agent-runtime/cloud-deploy.ts new file mode 100644 index 00000000..148defdf --- /dev/null +++ b/lib/agent-runtime/cloud-deploy.ts @@ -0,0 +1,64 @@ +import { getOrCreateAgent, normalizeTaskInput } from "./agent"; +import type { AgentConfig, TaskResult } from "./types"; + +export interface CloudAgentHandlerConfig extends AgentConfig { + apiKey?: string; +} + +export function createServerlessAgentHandler(config: CloudAgentHandlerConfig) { + return async function handler(req: Request): Promise<Response> { + try { + if (req.method === "GET") { + const agent = getOrCreateAgent(config); + return new Response( + JSON.stringify({ + ok: true, + agent: { + id: agent.id, + status: agent.getStatus(), + metrics: agent.getMetrics(), + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }, + ); + } + + if (req.method === "POST") { + const body = await req.json().catch(() => ({})); + const agent = getOrCreateAgent(config); + await agent.start(); + + const task = normalizeTaskInput(body); + const result: TaskResult = await agent.executeTask(task); + + return new Response(JSON.stringify({ ok: true, result }), { + status: 201, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } + + return new Response( + JSON.stringify({ ok: false, error: "Method not allowed" }), + { status: 405 }, + ); + } catch (err) { + const errorMsg = + err instanceof Error ? err.message : "Serverless execution failed"; + return new Response(JSON.stringify({ ok: false, error: errorMsg }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }; +} + +export const createEdgeAgentHandler = createServerlessAgentHandler; diff --git a/lib/agent-runtime/costs.ts b/lib/agent-runtime/costs.ts index 4046d08d..085e235b 100644 --- a/lib/agent-runtime/costs.ts +++ b/lib/agent-runtime/costs.ts @@ -1,65 +1,90 @@ export interface ClaudeCostRecord { - id: string - taskId: string - agentId: string - model: string - inputTokens: number - outputTokens: number - costUsd: number - createdAt: string + id: string; + taskId: string; + agentId: string; + model: string; + inputTokens: number; + outputTokens: number; + costUsd: number; + createdAt: string; } -const MODEL_PRICES_PER_MILLION: Record<string, { input: number; output: number }> = { +const MODEL_PRICES_PER_MILLION: Record< + string, + { input: number; output: number } +> = { "claude-opus-4-5": { input: 5, output: 25 }, "claude-4-opus": { input: 15, output: 75 }, "claude-sonnet-4-5": { input: 3, output: 15 }, "claude-4-sonnet": { input: 3, output: 15 }, "claude-haiku-4-5": { input: 1, output: 5 }, "claude-3.5-haiku": { input: 0.8, output: 4 }, -} +}; const globalState = globalThis as typeof globalThis & { - __openStellarClaudeCostRecords__?: ClaudeCostRecord[] -} + __openStellarClaudeCostRecords__?: ClaudeCostRecord[]; +}; function records(): ClaudeCostRecord[] { - if (!globalState.__openStellarClaudeCostRecords__) globalState.__openStellarClaudeCostRecords__ = [] - return globalState.__openStellarClaudeCostRecords__ + if (!globalState.__openStellarClaudeCostRecords__) { + globalState.__openStellarClaudeCostRecords__ = []; + } + return globalState.__openStellarClaudeCostRecords__; } function priceForModel(model: string) { - const key = Object.keys(MODEL_PRICES_PER_MILLION).find((candidate) => model.includes(candidate)) - return MODEL_PRICES_PER_MILLION[key ?? "claude-4-sonnet"] + const key = Object.keys(MODEL_PRICES_PER_MILLION).find((candidate) => + model.includes(candidate), + ); + return MODEL_PRICES_PER_MILLION[key ?? "claude-4-sonnet"]; } -export function estimateClaudeCostUsd(model: string, inputTokens: number, outputTokens: number): number { - const price = priceForModel(model) - return Number(((inputTokens / 1_000_000) * price.input + (outputTokens / 1_000_000) * price.output).toFixed(6)) +export function estimateClaudeCostUsd( + model: string, + inputTokens: number, + outputTokens: number, +): number { + const price = priceForModel(model); + return Number( + ( + (inputTokens / 1_000_000) * price.input + + (outputTokens / 1_000_000) * price.output + ).toFixed(6), + ); } -export function recordClaudeTaskCost(input: Omit<ClaudeCostRecord, "id" | "createdAt" | "costUsd">): ClaudeCostRecord { - const costUsd = estimateClaudeCostUsd(input.model, input.inputTokens, input.outputTokens) +export function recordClaudeTaskCost( + input: Omit<ClaudeCostRecord, "id" | "createdAt" | "costUsd">, +): ClaudeCostRecord { + const costUsd = estimateClaudeCostUsd( + input.model, + input.inputTokens, + input.outputTokens, + ); const record: ClaudeCostRecord = { ...input, id: `claude_cost_${Date.now()}_${records().length + 1}`, costUsd, createdAt: new Date().toISOString(), - } - records().unshift(record) - return record + }; + records().unshift(record); + return record; } export function listClaudeCostRecords(agentId?: string): ClaudeCostRecord[] { - return records().filter((record) => !agentId || record.agentId === agentId) + return records().filter((record) => !agentId || record.agentId === agentId); } export function getAgentClaudeAnalytics(agentId: string, dailyBudgetUsd = 5) { - const agentRecords = listClaudeCostRecords(agentId) - const since = Date.now() - 24 * 60 * 60 * 1000 + const agentRecords = listClaudeCostRecords(agentId); + const since = Date.now() - 24 * 60 * 60 * 1000; const dailySpendUsd = agentRecords .filter((record) => Date.parse(record.createdAt) >= since) - .reduce((sum, record) => sum + record.costUsd, 0) - const lifetimeSpendUsd = agentRecords.reduce((sum, record) => sum + record.costUsd, 0) + .reduce((sum, record) => sum + record.costUsd, 0); + const lifetimeSpendUsd = agentRecords.reduce( + (sum, record) => sum + record.costUsd, + 0, + ); return { agentId, @@ -68,9 +93,9 @@ export function getAgentClaudeAnalytics(agentId: string, dailyBudgetUsd = 5) { lifetimeSpendUsd: Number(lifetimeSpendUsd.toFixed(6)), dailyBudgetUsd, overDailyBudget: dailySpendUsd > dailyBudgetUsd, - } + }; } export function resetClaudeCostRecordsForTests(): void { - globalState.__openStellarClaudeCostRecords__ = [] + globalState.__openStellarClaudeCostRecords__ = []; } diff --git a/lib/agent-runtime/executor.test.ts b/lib/agent-runtime/executor.test.ts index 1e841e86..5262c2af 100644 --- a/lib/agent-runtime/executor.test.ts +++ b/lib/agent-runtime/executor.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it, beforeEach } from "vitest" -import { executeTask } from "@/lib/agent-runtime/executor" -import { getAgentTools } from "@/lib/agent-runtime/tools" -import { getAgentClaudeAnalytics, resetClaudeCostRecordsForTests } from "@/lib/agent-runtime/costs" -import type { MoltbotAgent } from "@/lib/types" +import { describe, expect, it, beforeEach } from "vitest"; +import { executeTask } from "@/lib/agent-runtime/executor"; +import { getAgentTools } from "@/lib/agent-runtime/tools"; +import { + getAgentClaudeAnalytics, + resetClaudeCostRecordsForTests, +} from "@/lib/agent-runtime/costs"; +import type { MoltbotAgent } from "@/lib/types"; const baseAgent: MoltbotAgent = { id: "bot-test", @@ -23,37 +26,52 @@ const baseAgent: MoltbotAgent = { frame: 0, direction: "right", spriteId: 0, - skills: [{ id: "s1", name: "Threat Detection", level: 2, maxLevel: 5, xp: 0, xpToNext: 10 }], + skills: [ + { + id: "s1", + name: "Threat Detection", + level: 2, + maxLevel: 5, + xp: 0, + xpToNext: 10, + }, + ], appearance: { skin: "default", accessories: [], customColor: null }, -} +}; describe("Claude-backed agent executor", () => { - beforeEach(() => resetClaudeCostRecordsForTests()) + beforeEach(() => resetClaudeCostRecordsForTests()); it("passes district tools, prompt, and task payload to Claude", async () => { - const calls: Record<string, unknown>[] = [] + const calls: Record<string, unknown>[] = []; const client = { messages: { async create(input: Record<string, unknown>) { - calls.push(input) + calls.push(input); return { content: [{ type: "text", text: "Threat scan completed." }], stop_reason: "end_turn", usage: { input_tokens: 1000, output_tokens: 200 }, - } + }; }, }, - } + }; - const result = await executeTask(baseAgent, { id: "task-1", title: "Scan", payload: { target: "edge" } }, { client }) + const result = await executeTask( + baseAgent, + { id: "task-1", title: "Scan", payload: { target: "edge" } }, + { client }, + ); - expect(result.summary).toBe("Threat scan completed.") - expect(result.cost.costUsd).toBeGreaterThan(0) - expect(calls[0].model).toBe("claude-sonnet-4-5-20250929") - expect(String(calls[0].system)).toContain("Defense Grid") - expect(calls[0].messages).toEqual([{ role: "user", content: expect.stringContaining('"target": "edge"') }]) - expect(calls[0].tools).toEqual(getAgentTools("defense")) - }) + expect(result.summary).toBe("Threat scan completed."); + expect(result.cost.costUsd).toBeGreaterThan(0); + expect(calls[0].model).toBe("claude-sonnet-4-5-20250929"); + expect(String(calls[0].system)).toContain("Defense Grid"); + expect(calls[0].messages).toEqual([ + { role: "user", content: expect.stringContaining('"target": "edge"') }, + ]); + expect(calls[0].tools).toEqual(getAgentTools("defense")); + }); it("tracks per-agent daily budget analytics", async () => { const client = { @@ -62,16 +80,20 @@ describe("Claude-backed agent executor", () => { return { content: [{ type: "text", text: "done" }], usage: { input_tokens: 2_000_000, output_tokens: 500_000 }, - } + }; }, }, - } + }; - await executeTask(baseAgent, { id: "task-2", payload: "expensive" }, { client }) + await executeTask( + baseAgent, + { id: "task-2", payload: "expensive" }, + { client }, + ); - const analytics = getAgentClaudeAnalytics(baseAgent.id, 1) - expect(analytics.taskCount).toBe(1) - expect(analytics.dailySpendUsd).toBeGreaterThan(1) - expect(analytics.overDailyBudget).toBe(true) - }) -}) + const analytics = getAgentClaudeAnalytics(baseAgent.id, 1); + expect(analytics.taskCount).toBe(1); + expect(analytics.dailySpendUsd).toBeGreaterThan(1); + expect(analytics.overDailyBudget).toBe(true); + }); +}); diff --git a/lib/agent-runtime/executor.ts b/lib/agent-runtime/executor.ts index aaf8f00c..dc2da212 100644 --- a/lib/agent-runtime/executor.ts +++ b/lib/agent-runtime/executor.ts @@ -1,53 +1,64 @@ -import { buildAgentSystemPrompt } from "@/lib/agent-runtime/prompts" -import { recordClaudeTaskCost, type ClaudeCostRecord } from "@/lib/agent-runtime/costs" -import { getAgentTools } from "@/lib/agent-runtime/tools" -import type { MoltbotAgent } from "@/lib/types" +import { buildAgentSystemPrompt } from "@/lib/agent-runtime/prompts"; +import { + recordClaudeTaskCost, + type ClaudeCostRecord, +} from "@/lib/agent-runtime/costs"; +import { getAgentTools } from "@/lib/agent-runtime/tools"; +import type { MoltbotAgent } from "@/lib/types"; export interface AgentTask { - id: string - title?: string - payload: unknown + id: string; + title?: string; + payload: unknown; } export interface TaskResult { - taskId: string - agentId: string - model: string - summary: string - rawContent: unknown - stopReason?: string - cost: ClaudeCostRecord + taskId: string; + agentId: string; + model: string; + summary: string; + rawContent: unknown; + stopReason?: string; + cost: ClaudeCostRecord; } interface ClaudeMessagesClient { messages: { create(input: Record<string, unknown>): Promise<{ - content?: Array<{ type: string; text?: string; [key: string]: unknown }> - stop_reason?: string - usage?: { input_tokens?: number; output_tokens?: number } - }> - } + content?: Array<{ type: string; text?: string; [key: string]: unknown }>; + stop_reason?: string; + usage?: { input_tokens?: number; output_tokens?: number }; + }>; + }; } export function formatTask(task: AgentTask): string { - return JSON.stringify({ id: task.id, title: task.title, payload: task.payload }, null, 2) + return JSON.stringify( + { id: task.id, title: task.title, payload: task.payload }, + null, + 2, + ); } function normalizeClaudeModel(model: string): string { - if (model.startsWith("claude-") && /-\d{8}$/.test(model)) return model + if (model.startsWith("claude-") && /-\d{8}$/.test(model)) return model; const aliases: Record<string, string> = { "claude-4-sonnet": "claude-sonnet-4-5-20250929", "claude-4-opus": "claude-opus-4-5-20251101", "claude-haiku-4-5": "claude-haiku-4-5-20251001", "claude-3.5-haiku": "claude-3-5-haiku-20241022", - } - return aliases[model] ?? model + }; + return aliases[model] ?? model; } function parseSummary(content: TaskResult["rawContent"]): string { - if (!Array.isArray(content)) return "Claude returned no text content." - const text = content.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n").trim() - return text || "Claude returned tool calls without a final text summary." + if (!Array.isArray(content)) return "Claude returned no text content."; + const text = content + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block.text) + .join("\n") + .trim(); + return text || "Claude returned tool calls without a final text summary."; } function createFetchClaudeClient(apiKey: string): ClaudeMessagesClient { @@ -62,26 +73,44 @@ function createFetchClaudeClient(apiKey: string): ClaudeMessagesClient { "anthropic-version": "2023-06-01", }, body: JSON.stringify(input), - }) - if (!response.ok) throw new Error(`Claude API failed with ${response.status}: ${await response.text()}`) - return response.json() + }); + if (!response.ok) + throw new Error( + `Claude API failed with ${response.status}: ${await response.text()}`, + ); + return response.json(); }, }, - } + }; } -export async function executeTask(agent: MoltbotAgent, task: AgentTask, options: { client?: ClaudeMessagesClient; apiKey?: string; maxTokens?: number } = {}): Promise<TaskResult> { - const client = options.client ?? createFetchClaudeClient(options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "") - if (!options.client && !(options.apiKey ?? process.env.ANTHROPIC_API_KEY)) throw new Error("ANTHROPIC_API_KEY is required to execute Claude-backed agent tasks") +export async function executeTask( + agent: MoltbotAgent, + task: AgentTask, + options: { + client?: ClaudeMessagesClient; + apiKey?: string; + maxTokens?: number; + } = {}, +): Promise<TaskResult> { + const client = + options.client ?? + createFetchClaudeClient( + options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "", + ); + if (!options.client && !(options.apiKey ?? process.env.ANTHROPIC_API_KEY)) + throw new Error( + "ANTHROPIC_API_KEY is required to execute Claude-backed agent tasks", + ); - const model = normalizeClaudeModel(agent.model) + const model = normalizeClaudeModel(agent.model); const response = await client.messages.create({ model, max_tokens: options.maxTokens ?? 2048, system: buildAgentSystemPrompt(agent), messages: [{ role: "user", content: formatTask(task) }], tools: getAgentTools(agent.district), - }) + }); const cost = recordClaudeTaskCost({ taskId: task.id, @@ -89,7 +118,7 @@ export async function executeTask(agent: MoltbotAgent, task: AgentTask, options: model, inputTokens: response.usage?.input_tokens ?? 0, outputTokens: response.usage?.output_tokens ?? 0, - }) + }); return { taskId: task.id, @@ -99,5 +128,5 @@ export async function executeTask(agent: MoltbotAgent, task: AgentTask, options: rawContent: response.content ?? [], stopReason: response.stop_reason, cost, - } + }; } diff --git a/lib/agent-runtime/messaging.ts b/lib/agent-runtime/messaging.ts index 4c475e8e..c0fb385c 100644 --- a/lib/agent-runtime/messaging.ts +++ b/lib/agent-runtime/messaging.ts @@ -1,62 +1,62 @@ -import { DISTRICTS } from "@/lib/data" -import type { DistrictId } from "@/lib/types" - -export type AgentMessageType = "task.request" | "task.response" | "data.share" | "status.query" | "chat" -export type AgentMessageTarget = string | "broadcast" +import { DISTRICTS } from "@/lib/data"; +import type { DistrictId } from "@/lib/types"; +export type AgentMessageType = + "task.request" | "task.response" | "data.share" | "status.query" | "chat"; export interface AgentMessage { - id: string - fromAgentId: string - toAgentId: AgentMessageTarget - type: AgentMessageType - payload: unknown - replyTo?: string - sentAt: string - expiresAt?: string - districtId?: DistrictId + id: string; + fromAgentId: string; + toAgentId: string; + type: AgentMessageType; + payload: unknown; + replyTo?: string; + sentAt: string; + expiresAt?: string; + districtId?: DistrictId; } export interface AgentMessageInput { - fromAgentId: string - toAgentId?: AgentMessageTarget - type: AgentMessageType - payload: unknown - replyTo?: string - expiresAt?: string - districtId?: DistrictId + fromAgentId: string; + toAgentId?: string; + type: AgentMessageType; + payload: unknown; + replyTo?: string; + expiresAt?: string; + districtId?: DistrictId; } -type MessageListener = (message: AgentMessage) => void +type MessageListener = (message: AgentMessage) => void; interface MessageBusState { - messages: AgentMessage[] - listeners: Map<string, Set<MessageListener>> - sequence: number + messages: AgentMessage[]; + listeners: Map<string, Set<MessageListener>>; + sequence: number; } const globalState = globalThis as typeof globalThis & { - __openStellarAgentMessageBus__?: MessageBusState -} + __openStellarAgentMessageBus__?: MessageBusState; +}; -const messageBus: MessageBusState = globalState.__openStellarAgentMessageBus__ ?? { - messages: [], - listeners: new Map(), - sequence: 0, -} +const messageBus: MessageBusState = + globalState.__openStellarAgentMessageBus__ ?? { + messages: [], + listeners: new Map(), + sequence: 0, + }; if (!globalState.__openStellarAgentMessageBus__) { - globalState.__openStellarAgentMessageBus__ = messageBus + globalState.__openStellarAgentMessageBus__ = messageBus; } function nextMessageId(type: AgentMessageType) { - messageBus.sequence += 1 - return `msg_${type.replace(".", "_")}_${Date.now()}_${messageBus.sequence}` + messageBus.sequence += 1; + return `msg_${type.replace(".", "_")}_${Date.now()}_${messageBus.sequence}`; } export function resetAgentMessagesForTests() { - messageBus.messages = [] - messageBus.listeners.clear() - messageBus.sequence = 0 + messageBus.messages = []; + messageBus.listeners.clear(); + messageBus.sequence = 0; } export function isAgentMessageType(value: unknown): value is AgentMessageType { @@ -66,21 +66,24 @@ export function isAgentMessageType(value: unknown): value is AgentMessageType { value === "data.share" || value === "status.query" || value === "chat" - ) + ); } export function isDistrictId(value: unknown): value is DistrictId { - return typeof value === "string" && DISTRICTS.some((district) => district.id === value) + return ( + typeof value === "string" && + DISTRICTS.some((district) => district.id === value) + ); } function normalizeMessage(input: AgentMessageInput): AgentMessage { if (!input.fromAgentId.trim()) { - throw new Error("fromAgentId is required") + throw new Error("fromAgentId is required"); } - const toAgentId = input.toAgentId ?? "broadcast" + const toAgentId = input.toAgentId ?? "broadcast"; if (!toAgentId.trim()) { - throw new Error("toAgentId is required") + throw new Error("toAgentId is required"); } return { @@ -93,75 +96,88 @@ function normalizeMessage(input: AgentMessageInput): AgentMessage { sentAt: new Date().toISOString(), expiresAt: input.expiresAt, districtId: input.districtId, - } + }; } function listenerKeysFor(message: AgentMessage) { - const keys = new Set<string>([message.toAgentId]) - keys.add(message.fromAgentId) - if (message.districtId) keys.add(`district:${message.districtId}`) - return keys + const keys = new Set<string>([message.toAgentId]); + keys.add(message.fromAgentId); + if (message.districtId) keys.add(`district:${message.districtId}`); + return keys; } function publish(message: AgentMessage) { for (const key of listenerKeysFor(message)) { - const listeners = messageBus.listeners.get(key) - if (!listeners) continue + const listeners = messageBus.listeners.get(key); + if (!listeners) continue; for (const listener of listeners) { - listener(message) + listener(message); } } } export function sendAgentMessage(input: AgentMessageInput): AgentMessage { - const message = normalizeMessage(input) - messageBus.messages.unshift(message) - messageBus.messages = messageBus.messages.slice(0, 500) - publish(message) - return message + const message = normalizeMessage(input); + messageBus.messages.unshift(message); + messageBus.messages = messageBus.messages.slice(0, 500); + publish(message); + return message; } -export function broadcastDistrictMessage(districtId: DistrictId, input: Omit<AgentMessageInput, "toAgentId" | "districtId">): AgentMessage { +export function broadcastDistrictMessage( + districtId: DistrictId, + input: Omit<AgentMessageInput, "toAgentId" | "districtId">, +): AgentMessage { return sendAgentMessage({ ...input, toAgentId: "broadcast", districtId, - }) + }); } export function listAgentMessages(agentId: string): AgentMessage[] { - return messageBus.messages.filter((message) => ( - message.toAgentId === agentId || - message.fromAgentId === agentId || - message.toAgentId === "broadcast" - )) + return messageBus.messages.filter( + (message) => + message.toAgentId === agentId || + message.fromAgentId === agentId || + message.toAgentId === "broadcast", + ); } export function listDistrictMessages(districtId: DistrictId): AgentMessage[] { - return messageBus.messages.filter((message) => message.districtId === districtId) + return messageBus.messages.filter( + (message) => message.districtId === districtId, + ); } -export function subscribeToAgentMessages(agentId: string, listener: MessageListener) { - const listeners = messageBus.listeners.get(agentId) ?? new Set<MessageListener>() - listeners.add(listener) - messageBus.listeners.set(agentId, listeners) +export function subscribeToAgentMessages( + agentId: string, + listener: MessageListener, +) { + const listeners = + messageBus.listeners.get(agentId) ?? new Set<MessageListener>(); + listeners.add(listener); + messageBus.listeners.set(agentId, listeners); return () => { - listeners.delete(listener) - if (listeners.size === 0) messageBus.listeners.delete(agentId) - } + listeners.delete(listener); + if (listeners.size === 0) messageBus.listeners.delete(agentId); + }; } -export function subscribeToDistrictMessages(districtId: DistrictId, listener: MessageListener) { - const key = `district:${districtId}` - const listeners = messageBus.listeners.get(key) ?? new Set<MessageListener>() - listeners.add(listener) - messageBus.listeners.set(key, listeners) +export function subscribeToDistrictMessages( + districtId: DistrictId, + listener: MessageListener, +) { + const key = `district:${districtId}`; + const listeners = messageBus.listeners.get(key) ?? new Set<MessageListener>(); + listeners.add(listener); + messageBus.listeners.set(key, listeners); return () => { - listeners.delete(listener) - if (listeners.size === 0) messageBus.listeners.delete(key) - } + listeners.delete(listener); + if (listeners.size === 0) messageBus.listeners.delete(key); + }; } export function encodeAgentMessageSse(message: AgentMessage) { @@ -171,24 +187,26 @@ export function encodeAgentMessageSse(message: AgentMessage) { `data: ${JSON.stringify(message)}`, "", "", - ].join("\n") + ].join("\n"); } export function createAgentMessageStream(agentId: string) { - const encoder = new TextEncoder() - let cleanup = () => {} + const encoder = new TextEncoder(); + let cleanup = () => {}; return new ReadableStream<Uint8Array>({ start(controller) { - controller.enqueue(encoder.encode(`: open-stellar messages for ${agentId}\n\n`)) + controller.enqueue( + encoder.encode(`: open-stellar messages for ${agentId}\n\n`), + ); cleanup = subscribeToAgentMessages(agentId, (message) => { - controller.enqueue(encoder.encode(encodeAgentMessageSse(message))) - }) + controller.enqueue(encoder.encode(encodeAgentMessageSse(message))); + }); }, cancel() { - cleanup() + cleanup(); }, - }) + }); } export function agentMessageStreamHeaders() { @@ -197,5 +215,5 @@ export function agentMessageStreamHeaders() { "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", - } + }; } diff --git a/lib/agent-runtime/persistence.ts b/lib/agent-runtime/persistence.ts new file mode 100644 index 00000000..0f564950 --- /dev/null +++ b/lib/agent-runtime/persistence.ts @@ -0,0 +1,136 @@ +export interface PersistedAgentState { + id: string; + name: string; + model: string; + district: string; + status: string; + cpu?: number; + memory?: number; + autoRestart?: boolean; + lastHeartbeat?: string; + tasksCompleted?: number; + updatedAt: string; +} + +export interface PersistedStateData { + agents: PersistedAgentState[]; + updatedAt: string; +} + +function isEdgeRuntime(): boolean { + return process.env.NEXT_RUNTIME === "edge" || typeof window !== "undefined"; +} + +// Dynamically obtain node require without triggering Webpack Edge module bundling +function getDynamicRequire() { + if (isEdgeRuntime()) return null; + try { + const g = globalThis as Record<string, unknown>; + if (typeof g.__non_webpack_require__ === "function") { + return g.__non_webpack_require__ as (id: string) => unknown; + } + return require; + } catch { + return null; + } +} + +function getFilePath(): string { + const req = getDynamicRequire(); + if (!req) return ""; + try { + const path = req("node:path"); + const dataDir = process.env.AGENT_STATE_FILE + ? "" + : path.join(process.cwd(), ".data"); + return ( + process.env.AGENT_STATE_FILE || path.join(dataDir, "agent-state.json") + ); + } catch { + return ""; + } +} + +export function loadPersistedState(): PersistedStateData { + const req = getDynamicRequire(); + if (!req) return { agents: [], updatedAt: new Date().toISOString() }; + + try { + const filePath = getFilePath(); + if (!filePath) return { agents: [], updatedAt: new Date().toISOString() }; + + const fs = req("node:fs"); + if (!fs.existsSync(filePath)) { + return { agents: [], updatedAt: new Date().toISOString() }; + } + const content = fs.readFileSync(filePath, "utf8").trim(); + if (!content) { + return { agents: [], updatedAt: new Date().toISOString() }; + } + const parsed = JSON.parse(content) as PersistedStateData; + if (Array.isArray(parsed.agents)) { + return parsed; + } + return { agents: [], updatedAt: new Date().toISOString() }; + } catch { + return { agents: [], updatedAt: new Date().toISOString() }; + } +} + +export function savePersistedState(agents: PersistedAgentState[]): void { + const req = getDynamicRequire(); + if (!req) return; + + try { + const filePath = getFilePath(); + if (!filePath) return; + + const fs = req("node:fs"); + const path = req("node:path"); + + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const data: PersistedStateData = { + agents, + updatedAt: new Date().toISOString(), + }; + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8"); + try { + fs.renameSync(tempPath, filePath); + } catch { + fs.copyFileSync(tempPath, filePath); + try { + fs.unlinkSync(tempPath); + } catch {} + } + } catch (err) { + console.error(`[persistence] Error saving agent state:`, err); + } +} + +export function upsertPersistedAgent(agent: PersistedAgentState): void { + const current = loadPersistedState(); + const idx = current.agents.findIndex((a) => a.id === agent.id); + if (idx >= 0) { + current.agents[idx] = { + ...current.agents[idx], + ...agent, + updatedAt: new Date().toISOString(), + }; + } else { + current.agents.push({ ...agent, updatedAt: new Date().toISOString() }); + } + savePersistedState(current.agents); +} + +export function removePersistedAgent(id: string): void { + const current = loadPersistedState(); + const filtered = current.agents.filter((a) => a.id !== id); + if (filtered.length !== current.agents.length) { + savePersistedState(filtered); + } +} diff --git a/lib/agent-runtime/prompts.ts b/lib/agent-runtime/prompts.ts index 53f79eea..be7c8270 100644 --- a/lib/agent-runtime/prompts.ts +++ b/lib/agent-runtime/prompts.ts @@ -1,9 +1,12 @@ -import { DISTRICTS } from "@/lib/data" -import type { MoltbotAgent } from "@/lib/types" +import { DISTRICTS } from "@/lib/data"; +import type { MoltbotAgent } from "@/lib/types"; export function buildAgentSystemPrompt(agent: MoltbotAgent): string { - const district = DISTRICTS.find((item) => item.id === agent.district) - const skills = agent.skills.map((skill) => `${skill.name} (level ${skill.level}/${skill.maxLevel})`).join(", ") || "general operations" + const district = DISTRICTS.find((item) => item.id === agent.district); + const skills = + agent.skills + .map((skill) => `${skill.name} (level ${skill.level}/${skill.maxLevel})`) + .join(", ") || "general operations"; return [ `You are ${agent.name}, an autonomous Open Stellar agent.`, @@ -13,5 +16,5 @@ export function buildAgentSystemPrompt(agent: MoltbotAgent): string { "Use only the tools provided to you for side effects or system access.", "Return concise, auditable results with a summary, evidence, and next actions.", "If a requested action is unsafe or outside your district capability, explain the limitation and propose a safe alternative.", - ].join("\n") + ].join("\n"); } diff --git a/lib/agent-runtime/sdk.ts b/lib/agent-runtime/sdk.ts new file mode 100644 index 00000000..11faee57 --- /dev/null +++ b/lib/agent-runtime/sdk.ts @@ -0,0 +1,96 @@ +import { Agent, getOrCreateAgent, normalizeTaskInput } from "./agent"; +import { + listAgentMessages, + subscribeToAgentMessages, +} from "./messaging"; +import type { + AgentConfig, + AgentMessage, + TaskResult, + TaskHandler, + MessageHandler, +} from "./types"; + +export interface CreateAgentOptions extends AgentConfig { + onStart?: () => void | Promise<void>; + onStop?: () => void | Promise<void>; + onTask?: TaskHandler; + onMessage?: MessageHandler; + onError?: (error: Error) => void | Promise<void>; + onStateChange?: (status: string) => void | Promise<void>; +} + +export class AgentSDK { + readonly agent: Agent; + + constructor(options: CreateAgentOptions) { + this.agent = getOrCreateAgent(options); + + if (options.onStart) this.agent.onStart(options.onStart); + if (options.onStop) this.agent.onStop(options.onStop); + if (options.onTask) this.agent.onTask(options.onTask); + if (options.onMessage) this.agent.onMessage(options.onMessage); + if (options.onError) this.agent.onError(options.onError); + if (options.onStateChange) this.agent.onStateChange(options.onStateChange); + } + + get id(): string { + return this.agent.id; + } + + get status(): string { + return this.agent.getStatus(); + } + + async start(): Promise<void> { + await this.agent.start(); + } + + async stop(): Promise<void> { + await this.agent.stop(); + } + + async executeTask(task: unknown): Promise<TaskResult> { + return this.agent.executeTask(normalizeTaskInput(task)); + } + + async sendMessage( + toAgentId: string, + payload: unknown, + type: AgentMessage["type"] = "chat", + ): Promise<void> { + const msg: AgentMessage = { + id: `msg_${Date.now()}`, + fromAgentId: this.id, + toAgentId, + type, + payload, + sentAt: new Date().toISOString(), + }; + await this.agent.sendMessage(toAgentId, msg); + } + + getMessages(): AgentMessage[] { + return listAgentMessages(this.id); + } + + subscribe(listener: (message: AgentMessage) => void): () => void { + return subscribeToAgentMessages(this.id, listener); + } + + getMetrics() { + return this.agent.getMetrics(); + } +} + +export function createAgent(options: CreateAgentOptions): AgentSDK { + return new AgentSDK(options); +} + +export { Agent, getAgent, getOrCreateAgent } from "./agent"; +export { enqueueTask, getTask, listTasks } from "./task-queue"; +export { + sendAgentMessage, + listAgentMessages, + subscribeToAgentMessages, +} from "./messaging"; diff --git a/lib/agent-runtime/task-queue.ts b/lib/agent-runtime/task-queue.ts index 3116ec70..d0b329d8 100644 --- a/lib/agent-runtime/task-queue.ts +++ b/lib/agent-runtime/task-queue.ts @@ -1,65 +1,72 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" -import { dirname } from "node:path" -import { DISTRICTS } from "@/lib/data" -import type { DistrictId } from "@/lib/types" - -export type QueuedTaskPriority = "critical" | "high" | "normal" | "low" -export type QueuedTaskStatus = "pending" | "leased" | "completed" | "failed" | "cancelled" | "dead-letter" +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { DISTRICTS } from "@/lib/data"; +import type { DistrictId } from "@/lib/types"; + +export type QueuedTaskPriority = "critical" | "high" | "normal" | "low"; +export type QueuedTaskStatus = + "pending" | "leased" | "completed" | "failed" | "cancelled" | "dead-letter"; export interface QueuedTask { - id: string - type: string - payload: Record<string, unknown> - priority: QueuedTaskPriority - targetAgentId?: string - targetDistrict?: DistrictId - targetCapability?: string - retryCount: number - maxRetries: number - createdAt: string - scheduledFor?: string - status: QueuedTaskStatus - result?: unknown - error?: string - updatedAt: string - deadLetteredAt?: string + id: string; + type: string; + payload: Record<string, unknown>; + priority: QueuedTaskPriority; + targetAgentId?: string; + targetDistrict?: DistrictId; + targetCapability?: string; + retryCount: number; + maxRetries: number; + createdAt: string; + scheduledFor?: string; + status: QueuedTaskStatus; + result?: unknown; + error?: string; + updatedAt: string; + deadLetteredAt?: string; } export interface DeadLetterTaskEntry { - task: QueuedTask - failedAt: string - errorMessage: string - retryCount: number + task: QueuedTask; + failedAt: string; + errorMessage: string; + retryCount: number; } export interface EnqueueTaskInput { - id?: string - type: string - payload?: Record<string, unknown> - priority?: QueuedTaskPriority - targetAgentId?: string - targetDistrict?: DistrictId - targetCapability?: string - maxRetries?: number - scheduledFor?: string + id?: string; + type: string; + payload?: Record<string, unknown>; + priority?: QueuedTaskPriority; + targetAgentId?: string; + targetDistrict?: DistrictId; + targetCapability?: string; + maxRetries?: number; + scheduledFor?: string; } interface TaskQueueState { - tasks: Map<string, QueuedTask> - sequence: number + tasks: Map<string, QueuedTask>; + sequence: number; } const globalState = globalThis as typeof globalThis & { - __openStellarTaskQueue__?: TaskQueueState -} + __openStellarTaskQueue__?: TaskQueueState; +}; const queueState: TaskQueueState = globalState.__openStellarTaskQueue__ ?? { tasks: new Map(), sequence: 0, -} +}; if (!globalState.__openStellarTaskQueue__) { - globalState.__openStellarTaskQueue__ = queueState + globalState.__openStellarTaskQueue__ = queueState; } const PRIORITY_WEIGHT: Record<QueuedTaskPriority, number> = { @@ -67,29 +74,36 @@ const PRIORITY_WEIGHT: Record<QueuedTaskPriority, number> = { high: 1, normal: 2, low: 3, -} +}; -const RETRY_BACKOFF_SECONDS = [5, 15, 45, 135] as const +const RETRY_BACKOFF_SECONDS = [5, 15, 45, 135] as const; function getDeadLetterQueuePath(): string { - return process.env.TASK_DLQ_FILE ?? "/.data/task-dlq.json" + return process.env.TASK_DLQ_FILE ?? "/.data/task-dlq.json"; } function nextTaskId(): string { - queueState.sequence += 1 - return `task_${Date.now()}_${queueState.sequence}` + queueState.sequence += 1; + return `task_${Date.now()}_${queueState.sequence}`; } export function resetTaskQueueForTests(): void { - queueState.tasks.clear() - queueState.sequence = 0 + queueState.tasks.clear(); + queueState.sequence = 0; } export function loadDeadLetterQueueForTests(): void { - loadDeadLetterQueue() + loadDeadLetterQueue(); } -export function isQueuedTaskPriority(value: unknown): value is QueuedTaskPriority { - return value === "critical" || value === "high" || value === "normal" || value === "low" +export function isQueuedTaskPriority( + value: unknown, +): value is QueuedTaskPriority { + return ( + value === "critical" || + value === "high" || + value === "normal" || + value === "low" + ); } export function isQueuedTaskStatus(value: unknown): value is QueuedTaskStatus { @@ -100,15 +114,18 @@ export function isQueuedTaskStatus(value: unknown): value is QueuedTaskStatus { value === "failed" || value === "cancelled" || value === "dead-letter" - ) + ); } export function isValidTaskDistrict(value: unknown): value is DistrictId { - return typeof value === "string" && DISTRICTS.some((district) => district.id === value) + return ( + typeof value === "string" && + DISTRICTS.some((district) => district.id === value) + ); } function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } function toDeadLetterEntry(task: QueuedTask): DeadLetterTaskEntry { @@ -117,77 +134,92 @@ function toDeadLetterEntry(task: QueuedTask): DeadLetterTaskEntry { failedAt: task.deadLetteredAt ?? task.updatedAt, errorMessage: task.error ?? "Task failed", retryCount: task.retryCount, - } + }; } function persistDeadLetterQueue(): void { - const dlqFilePath = getDeadLetterQueuePath() + const dlqFilePath = getDeadLetterQueuePath(); const entries = [...queueState.tasks.values()] .filter((task) => task.status === "dead-letter") - .map(toDeadLetterEntry) - mkdirSync(dirname(dlqFilePath), { recursive: true }) - const tempPath = `${dlqFilePath}.${process.pid}.${Date.now()}.tmp` - writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8") - renameSync(tempPath, dlqFilePath) + .map(toDeadLetterEntry); + mkdirSync(dirname(dlqFilePath), { recursive: true }); + const tempPath = `${dlqFilePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8"); + renameSync(tempPath, dlqFilePath); } function hydrateTask(value: unknown): QueuedTask | null { - if (!isRecord(value)) return null - if (typeof value.id !== "string" || typeof value.type !== "string") return null - if (!isQueuedTaskPriority(value.priority) || value.status !== "dead-letter") return null - const now = new Date().toISOString() + if (!isRecord(value)) return null; + if (typeof value.id !== "string" || typeof value.type !== "string") + return null; + if (!isQueuedTaskPriority(value.priority) || value.status !== "dead-letter") + return null; + const now = new Date().toISOString(); return { id: value.id, type: value.type, payload: isRecord(value.payload) ? value.payload : {}, priority: value.priority, - targetAgentId: typeof value.targetAgentId === "string" ? value.targetAgentId : undefined, - targetDistrict: isValidTaskDistrict(value.targetDistrict) ? value.targetDistrict : undefined, - targetCapability: typeof value.targetCapability === "string" ? value.targetCapability : undefined, + targetAgentId: + typeof value.targetAgentId === "string" ? value.targetAgentId : undefined, + targetDistrict: isValidTaskDistrict(value.targetDistrict) + ? value.targetDistrict + : undefined, + targetCapability: + typeof value.targetCapability === "string" + ? value.targetCapability + : undefined, retryCount: typeof value.retryCount === "number" ? value.retryCount : 0, maxRetries: typeof value.maxRetries === "number" ? value.maxRetries : 0, createdAt: typeof value.createdAt === "string" ? value.createdAt : now, - scheduledFor: typeof value.scheduledFor === "string" ? value.scheduledFor : undefined, + scheduledFor: + typeof value.scheduledFor === "string" ? value.scheduledFor : undefined, status: "dead-letter", result: value.result, error: typeof value.error === "string" ? value.error : undefined, updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now, - deadLetteredAt: typeof value.deadLetteredAt === "string" ? value.deadLetteredAt : undefined, - } + deadLetteredAt: + typeof value.deadLetteredAt === "string" + ? value.deadLetteredAt + : undefined, + }; } function loadDeadLetterQueue(): void { - const dlqFilePath = getDeadLetterQueuePath() - if (!existsSync(dlqFilePath)) return - const parsed: unknown = JSON.parse(readFileSync(dlqFilePath, "utf8")) - if (!Array.isArray(parsed)) return + const dlqFilePath = getDeadLetterQueuePath(); + if (!existsSync(dlqFilePath)) return; + const parsed: unknown = JSON.parse(readFileSync(dlqFilePath, "utf8")); + if (!Array.isArray(parsed)) return; for (const entry of parsed) { - const taskValue = isRecord(entry) && "task" in entry ? entry.task : entry - const task = hydrateTask(taskValue) - if (task) queueState.tasks.set(task.id, task) + const taskValue = isRecord(entry) && "task" in entry ? entry.task : entry; + const task = hydrateTask(taskValue); + if (task) queueState.tasks.set(task.id, task); } } -loadDeadLetterQueue() +loadDeadLetterQueue(); function assertNonEmpty(value: string, field: string): string { - const trimmed = value.trim() - if (!trimmed) throw new Error(`${field} is required`) - return trimmed + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${field} is required`); + return trimmed; } function normalizeScheduledFor(value: string | undefined): string | undefined { - if (!value) return undefined - const date = new Date(value) - if (Number.isNaN(date.getTime())) throw new Error("scheduledFor must be an ISO date") - return date.toISOString() + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) + throw new Error("scheduledFor must be an ISO date"); + return date.toISOString(); } export function enqueueTask(input: EnqueueTaskInput): QueuedTask { - const now = new Date().toISOString() - const priority = input.priority ?? "normal" - if (!isQueuedTaskPriority(priority)) throw new Error("Unsupported task priority") - if (input.targetDistrict && !isValidTaskDistrict(input.targetDistrict)) throw new Error("Unsupported target district") + const now = new Date().toISOString(); + const priority = input.priority ?? "normal"; + if (!isQueuedTaskPriority(priority)) + throw new Error("Unsupported task priority"); + if (input.targetDistrict && !isValidTaskDistrict(input.targetDistrict)) + throw new Error("Unsupported target district"); const task: QueuedTask = { id: input.id?.trim() || nextTaskId(), @@ -198,20 +230,23 @@ export function enqueueTask(input: EnqueueTaskInput): QueuedTask { targetDistrict: input.targetDistrict, targetCapability: input.targetCapability?.trim() || undefined, retryCount: 0, - maxRetries: Math.max(0, Math.floor(input.maxRetries ?? RETRY_BACKOFF_SECONDS.length)), + maxRetries: Math.max( + 0, + Math.floor(input.maxRetries ?? RETRY_BACKOFF_SECONDS.length), + ), createdAt: now, scheduledFor: normalizeScheduledFor(input.scheduledFor), status: "pending", updatedAt: now, - } + }; - if (queueState.tasks.has(task.id)) throw new Error("Task id already exists") - queueState.tasks.set(task.id, task) - return task + if (queueState.tasks.has(task.id)) throw new Error("Task id already exists"); + queueState.tasks.set(task.id, task); + return task; } export function getTask(id: string): QueuedTask | undefined { - return queueState.tasks.get(id) + return queueState.tasks.get(id); } function taskSortKey(task: QueuedTask): [number, number, number] { @@ -219,63 +254,102 @@ function taskSortKey(task: QueuedTask): [number, number, number] { PRIORITY_WEIGHT[task.priority], Date.parse(task.scheduledFor ?? task.createdAt), Date.parse(task.createdAt), - ] + ]; } function sortTasks(a: QueuedTask, b: QueuedTask): number { - const left = taskSortKey(a) - const right = taskSortKey(b) + const left = taskSortKey(a); + const right = taskSortKey(b); for (let i = 0; i < left.length; i += 1) { - if (left[i] !== right[i]) return left[i] - right[i] + if (left[i] !== right[i]) return left[i] - right[i]; } - return a.id.localeCompare(b.id) + return a.id.localeCompare(b.id); } -export function listTasks(filters: { agentId?: string; status?: QueuedTaskStatus; includeDeadLetter?: boolean } = {}): QueuedTask[] { - const now = Date.now() +export function listTasks( + filters: { + agentId?: string; + status?: QueuedTaskStatus; + includeDeadLetter?: boolean; + } = {}, +): QueuedTask[] { + const now = Date.now(); return [...queueState.tasks.values()] - .filter((task) => (filters.status ? task.status === filters.status : filters.includeDeadLetter || task.status !== "dead-letter")) - .filter((task) => !filters.agentId || task.targetAgentId === filters.agentId) - .filter((task) => task.status !== "pending" || !task.scheduledFor || Date.parse(task.scheduledFor) <= now || !filters.agentId) - .sort(sortTasks) + .filter((task) => + filters.status + ? task.status === filters.status + : filters.includeDeadLetter || task.status !== "dead-letter", + ) + .filter( + (task) => !filters.agentId || task.targetAgentId === filters.agentId, + ) + .filter( + (task) => + task.status !== "pending" || + !task.scheduledFor || + Date.parse(task.scheduledFor) <= now || + !filters.agentId, + ) + .sort(sortTasks); } export function listDeadLetterTasks(): QueuedTask[] { - return listTasks({ status: "dead-letter", includeDeadLetter: true }) + return listTasks({ status: "dead-letter", includeDeadLetter: true }); } -export function listDeadLetterEntries(options: { offset?: number; limit?: number } = {}): { - entries: DeadLetterTaskEntry[] - total: number - offset: number - limit: number +export function listDeadLetterEntries( + options: { offset?: number; limit?: number } = {}, +): { + entries: DeadLetterTaskEntry[]; + total: number; + offset: number; + limit: number; } { - const offset = Math.max(0, Math.floor(options.offset ?? 0)) - const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50))) - const entries = listDeadLetterTasks().map(toDeadLetterEntry) - return { entries: entries.slice(offset, offset + limit), total: entries.length, offset, limit } + const offset = Math.max(0, Math.floor(options.offset ?? 0)); + const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50))); + const entries = listDeadLetterTasks().map(toDeadLetterEntry); + return { + entries: entries.slice(offset, offset + limit), + total: entries.length, + offset, + limit, + }; } export function cancelTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "pending") throw new Error("Only pending tasks can be cancelled") - const updated = { ...task, status: "cancelled" as const, updatedAt: new Date().toISOString() } - queueState.tasks.set(id, updated) - return updated + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "pending") + throw new Error("Only pending tasks can be cancelled"); + const updated = { + ...task, + status: "cancelled" as const, + updatedAt: new Date().toISOString(), + }; + queueState.tasks.set(id, updated); + return updated; } export function failTask(id: string, error: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - const now = new Date() + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + const now = new Date(); if (task.retryCount >= task.maxRetries) { - const dead = { ...task, status: "dead-letter" as const, error, updatedAt: now.toISOString(), deadLetteredAt: now.toISOString() } - queueState.tasks.set(id, dead) - persistDeadLetterQueue() - return dead + const dead = { + ...task, + status: "dead-letter" as const, + error, + updatedAt: now.toISOString(), + deadLetteredAt: now.toISOString(), + }; + queueState.tasks.set(id, dead); + persistDeadLetterQueue(); + return dead; } - const backoffSeconds = RETRY_BACKOFF_SECONDS[Math.min(task.retryCount, RETRY_BACKOFF_SECONDS.length - 1)] + const backoffSeconds = + RETRY_BACKOFF_SECONDS[ + Math.min(task.retryCount, RETRY_BACKOFF_SECONDS.length - 1) + ]; const retry = { ...task, status: "pending" as const, @@ -283,15 +357,16 @@ export function failTask(id: string, error: string): QueuedTask { error, scheduledFor: new Date(now.getTime() + backoffSeconds * 1000).toISOString(), updatedAt: now.toISOString(), - } - queueState.tasks.set(id, retry) - return retry + }; + queueState.tasks.set(id, retry); + return retry; } export function retryDeadLetterTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "dead-letter") throw new Error("Only dead-letter tasks can be retried") + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "dead-letter") + throw new Error("Only dead-letter tasks can be retried"); const retry = { ...task, status: "pending" as const, @@ -299,17 +374,18 @@ export function retryDeadLetterTask(id: string): QueuedTask { scheduledFor: undefined, deadLetteredAt: undefined, updatedAt: new Date().toISOString(), - } - queueState.tasks.set(id, retry) - persistDeadLetterQueue() - return retry + }; + queueState.tasks.set(id, retry); + persistDeadLetterQueue(); + return retry; } export function discardDeadLetterTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "dead-letter") throw new Error("Only dead-letter tasks can be discarded") - queueState.tasks.delete(id) - persistDeadLetterQueue() - return task + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "dead-letter") + throw new Error("Only dead-letter tasks can be discarded"); + queueState.tasks.delete(id); + persistDeadLetterQueue(); + return task; } diff --git a/lib/agent-runtime/tools/index.ts b/lib/agent-runtime/tools/index.ts index f3ba76b5..f96561a8 100644 --- a/lib/agent-runtime/tools/index.ts +++ b/lib/agent-runtime/tools/index.ts @@ -1,4 +1,4 @@ -import type { DistrictId } from "@/lib/types" +import type { DistrictId } from "@/lib/types"; export type AgentToolName = | "read_file" @@ -16,20 +16,24 @@ export type AgentToolName = | "analyze_anomaly" | "search_papers" | "run_experiment" - | "visualize_data" + | "visualize_data"; export interface AgentToolDefinition { - name: AgentToolName - description: string + name: AgentToolName; + description: string; input_schema: { - type: "object" - properties: Record<string, unknown> - required?: string[] - } + type: "object"; + properties: Record<string, unknown>; + required?: string[]; + }; } -const textParam = (description: string) => ({ type: "string", description }) -const objectParam = (description: string) => ({ type: "object", description, additionalProperties: true }) +const textParam = (description: string) => ({ type: "string", description }); +const objectParam = (description: string) => ({ + type: "object", + description, + additionalProperties: true, +}); export const DISTRICT_TOOL_NAMES: Record<DistrictId, AgentToolName[]> = { "data-center": ["read_file", "write_file", "query_db", "compress_data"], @@ -37,27 +41,197 @@ export const DISTRICT_TOOL_NAMES: Record<DistrictId, AgentToolName[]> = { processing: ["run_inference", "batch_process", "tokenize"], defense: ["scan_threat", "update_firewall", "analyze_anomaly"], research: ["search_papers", "run_experiment", "visualize_data"], -} +}; export const TOOL_DEFINITIONS: Record<AgentToolName, AgentToolDefinition> = { - read_file: { name: "read_file", description: "Read a file from an approved workspace path.", input_schema: { type: "object", properties: { path: textParam("Workspace-relative file path") }, required: ["path"] } }, - write_file: { name: "write_file", description: "Write content to an approved workspace path.", input_schema: { type: "object", properties: { path: textParam("Workspace-relative file path"), content: textParam("File content") }, required: ["path", "content"] } }, - query_db: { name: "query_db", description: "Run a read-only database query against operational metadata.", input_schema: { type: "object", properties: { query: textParam("Read-only query or natural language request"), params: objectParam("Query parameters") }, required: ["query"] } }, - compress_data: { name: "compress_data", description: "Compress a named dataset or payload for storage transfer.", input_schema: { type: "object", properties: { source: textParam("Data source identifier"), codec: textParam("Compression codec") }, required: ["source"] } }, - send_message: { name: "send_message", description: "Send a message to another agent or channel.", input_schema: { type: "object", properties: { recipient: textParam("Agent, channel, or route"), message: textParam("Message body") }, required: ["recipient", "message"] } }, - route_request: { name: "route_request", description: "Route a request through the communications hub.", input_schema: { type: "object", properties: { destination: textParam("Destination route"), payload: objectParam("Payload to route") }, required: ["destination", "payload"] } }, - encrypt_payload: { name: "encrypt_payload", description: "Encrypt a payload for secure delivery.", input_schema: { type: "object", properties: { payload: objectParam("Payload to encrypt"), keyId: textParam("Key identifier") }, required: ["payload"] } }, - run_inference: { name: "run_inference", description: "Run model inference on a prompt or input payload.", input_schema: { type: "object", properties: { model: textParam("Model identifier"), input: textParam("Inference input") }, required: ["input"] } }, - batch_process: { name: "batch_process", description: "Process a batch of items.", input_schema: { type: "object", properties: { job: textParam("Batch job name"), items: { type: "array", items: {} } }, required: ["job", "items"] } }, - tokenize: { name: "tokenize", description: "Tokenize text for processing analysis.", input_schema: { type: "object", properties: { text: textParam("Text to tokenize") }, required: ["text"] } }, - scan_threat: { name: "scan_threat", description: "Scan an artifact or signal for threats.", input_schema: { type: "object", properties: { target: textParam("Target to scan"), depth: textParam("Scan depth") }, required: ["target"] } }, - update_firewall: { name: "update_firewall", description: "Prepare or apply a firewall policy update.", input_schema: { type: "object", properties: { rule: textParam("Firewall rule"), mode: textParam("dry-run or apply") }, required: ["rule"] } }, - analyze_anomaly: { name: "analyze_anomaly", description: "Analyze anomalous telemetry or behavior.", input_schema: { type: "object", properties: { signal: objectParam("Anomaly signal"), baseline: objectParam("Baseline data") }, required: ["signal"] } }, - search_papers: { name: "search_papers", description: "Search research literature for relevant papers.", input_schema: { type: "object", properties: { query: textParam("Research query"), limit: { type: "number", description: "Maximum results" } }, required: ["query"] } }, - run_experiment: { name: "run_experiment", description: "Run a structured research experiment.", input_schema: { type: "object", properties: { hypothesis: textParam("Hypothesis under test"), variables: objectParam("Experiment variables") }, required: ["hypothesis"] } }, - visualize_data: { name: "visualize_data", description: "Create a chart specification from data.", input_schema: { type: "object", properties: { data: objectParam("Data to visualize"), chartType: textParam("Chart type") }, required: ["data"] } }, -} + read_file: { + name: "read_file", + description: "Read a file from an approved workspace path.", + input_schema: { + type: "object", + properties: { path: textParam("Workspace-relative file path") }, + required: ["path"], + }, + }, + write_file: { + name: "write_file", + description: "Write content to an approved workspace path.", + input_schema: { + type: "object", + properties: { + path: textParam("Workspace-relative file path"), + content: textParam("File content"), + }, + required: ["path", "content"], + }, + }, + query_db: { + name: "query_db", + description: "Run a read-only database query against operational metadata.", + input_schema: { + type: "object", + properties: { + query: textParam("Read-only query or natural language request"), + params: objectParam("Query parameters"), + }, + required: ["query"], + }, + }, + compress_data: { + name: "compress_data", + description: "Compress a named dataset or payload for storage transfer.", + input_schema: { + type: "object", + properties: { + source: textParam("Data source identifier"), + codec: textParam("Compression codec"), + }, + required: ["source"], + }, + }, + send_message: { + name: "send_message", + description: "Send a message to another agent or channel.", + input_schema: { + type: "object", + properties: { + recipient: textParam("Agent, channel, or route"), + message: textParam("Message body"), + }, + required: ["recipient", "message"], + }, + }, + route_request: { + name: "route_request", + description: "Route a request through the communications hub.", + input_schema: { + type: "object", + properties: { + destination: textParam("Destination route"), + payload: objectParam("Payload to route"), + }, + required: ["destination", "payload"], + }, + }, + encrypt_payload: { + name: "encrypt_payload", + description: "Encrypt a payload for secure delivery.", + input_schema: { + type: "object", + properties: { + payload: objectParam("Payload to encrypt"), + keyId: textParam("Key identifier"), + }, + required: ["payload"], + }, + }, + run_inference: { + name: "run_inference", + description: "Run model inference on a prompt or input payload.", + input_schema: { + type: "object", + properties: { + model: textParam("Model identifier"), + input: textParam("Inference input"), + }, + required: ["input"], + }, + }, + batch_process: { + name: "batch_process", + description: "Process a batch of items.", + input_schema: { + type: "object", + properties: { + job: textParam("Batch job name"), + items: { type: "array", items: {} }, + }, + required: ["job", "items"], + }, + }, + tokenize: { + name: "tokenize", + description: "Tokenize text for processing analysis.", + input_schema: { + type: "object", + properties: { text: textParam("Text to tokenize") }, + required: ["text"], + }, + }, + scan_threat: { + name: "scan_threat", + description: "Scan an artifact or signal for threats.", + input_schema: { + type: "object", + properties: { + target: textParam("Target to scan"), + depth: textParam("Scan depth"), + }, + required: ["target"], + }, + }, + update_firewall: { + name: "update_firewall", + description: "Prepare or apply a firewall policy update.", + input_schema: { + type: "object", + properties: { + rule: textParam("Firewall rule"), + mode: textParam("dry-run or apply"), + }, + required: ["rule"], + }, + }, + analyze_anomaly: { + name: "analyze_anomaly", + description: "Analyze anomalous telemetry or behavior.", + input_schema: { + type: "object", + properties: { + signal: objectParam("Anomaly signal"), + baseline: objectParam("Baseline data"), + }, + required: ["signal"], + }, + }, + search_papers: { + name: "search_papers", + description: "Search research literature for relevant papers.", + input_schema: { + type: "object", + properties: { + query: textParam("Research query"), + limit: { type: "number", description: "Maximum results" }, + }, + required: ["query"], + }, + }, + run_experiment: { + name: "run_experiment", + description: "Run a structured research experiment.", + input_schema: { + type: "object", + properties: { + hypothesis: textParam("Hypothesis under test"), + variables: objectParam("Experiment variables"), + }, + required: ["hypothesis"], + }, + }, + visualize_data: { + name: "visualize_data", + description: "Create a chart specification from data.", + input_schema: { + type: "object", + properties: { + data: objectParam("Data to visualize"), + chartType: textParam("Chart type"), + }, + required: ["data"], + }, + }, +}; export function getAgentTools(district: DistrictId): AgentToolDefinition[] { - return DISTRICT_TOOL_NAMES[district].map((name) => TOOL_DEFINITIONS[name]) + return DISTRICT_TOOL_NAMES[district].map((name) => TOOL_DEFINITIONS[name]); } diff --git a/lib/agent-runtime/types.ts b/lib/agent-runtime/types.ts index 69a252d0..c27e23b1 100644 --- a/lib/agent-runtime/types.ts +++ b/lib/agent-runtime/types.ts @@ -1,58 +1,68 @@ -import type { AgentStatus, DistrictId, MoltbotAgent } from "@/lib/types" -import type { AgentMessage } from "@/lib/agent-runtime/messaging" +import type { AgentStatus, DistrictId, MoltbotAgent } from "@/lib/types"; +import type { AgentMessage } from "@/lib/agent-runtime/messaging"; -export type TaskStatus = "queued" | "running" | "completed" | "failed" +export type TaskStatus = "queued" | "running" | "completed" | "failed"; export interface Task { - id: string - title: string - description?: string - payload?: unknown - district?: DistrictId - createdAt?: string + id: string; + title: string; + description?: string; + payload?: unknown; + district?: DistrictId; + createdAt?: string; } export interface TaskResult { - taskId: string - agentId: string - status: Extract<TaskStatus, "completed" | "failed"> - summary: string - output?: unknown - error?: string - startedAt: string - completedAt: string - durationMs: number + taskId: string; + agentId: string; + status: Extract<TaskStatus, "completed" | "failed">; + summary: string; + output?: unknown; + error?: string; + startedAt: string; + completedAt: string; + durationMs: number; } -export type TaskHandler = (task: Task, agent: AgentRuntimeContext) => Promise<Partial<TaskResult> | string | void> | Partial<TaskResult> | string | void -export type MessageHandler = (message: AgentMessage, agent: AgentRuntimeContext) => Promise<void> | void +export type TaskHandler = ( + task: Task, + agent: AgentRuntimeContext, +) => + | Promise<Partial<TaskResult> | string | void> + | Partial<TaskResult> + | string + | void; +export type MessageHandler = ( + message: AgentMessage, + agent: AgentRuntimeContext, +) => Promise<void> | void; export interface AgentMetrics { - tasksCompleted: number - tasksFailed: number - messagesSent: number - messagesReceived: number - startedAt: string | null - stoppedAt: string | null - lastHeartbeat: string | null - uptimeMs: number - averageTaskDurationMs: number + tasksCompleted: number; + tasksFailed: number; + messagesSent: number; + messagesReceived: number; + startedAt: string | null; + stoppedAt: string | null; + lastHeartbeat: string | null; + uptimeMs: number; + averageTaskDurationMs: number; } export interface AgentConfig extends Partial<Omit<MoltbotAgent, "status">> { - id: string - name: string - model: string - status?: AgentStatus - heartbeatIntervalMs?: number - offlineAfterMs?: number + id: string; + name: string; + model: string; + status?: AgentStatus; + heartbeatIntervalMs?: number; + offlineAfterMs?: number; } export interface AgentRuntimeContext { - id: string - config: AgentConfig - getStatus(): AgentStatus - getMetrics(): AgentMetrics + id: string; + config: AgentConfig; + getStatus(): AgentStatus; + getMetrics(): AgentMetrics; } -export { AgentMessage } +export { AgentMessage }; diff --git a/lib/protocols/x402-receipt-store.ts b/lib/protocols/x402-receipt-store.ts index b7672b1b..f375eadd 100644 --- a/lib/protocols/x402-receipt-store.ts +++ b/lib/protocols/x402-receipt-store.ts @@ -1,50 +1,59 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { cwd } from 'node:process' -import type { SettlementChain, X402ExplorerReceipt } from '@/lib/protocols/x402' +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { cwd } from "node:process"; +import type { + SettlementChain, + X402ExplorerReceipt, +} from "@/lib/protocols/x402"; export interface X402ReceiptQuery { - agent?: string - q?: string - service?: string - chain?: SettlementChain | 'all' - page?: number - pageSize?: number + agent?: string; + q?: string; + service?: string; + chain?: SettlementChain | "all"; + page?: number; + pageSize?: number; } export interface X402ReceiptPage { - receipts: X402ExplorerReceipt[] - page: number - pageSize: number - total: number - totalPages: number + receipts: X402ExplorerReceipt[]; + page: number; + pageSize: number; + total: number; + totalPages: number; stats: { - totalPayments: number - totalUsd: number - uniqueAgents: number - services: number - } + totalPayments: number; + totalUsd: number; + uniqueAgents: number; + services: number; + }; } -const DEFAULT_DB_PATH = join(cwd(), '.data', 'x402-receipts.json') -const DB_PATH = process.env.X402_RECEIPT_DB_PATH || DEFAULT_DB_PATH +const DEFAULT_DB_PATH = join(cwd(), ".data", "x402-receipts.json"); +const DB_PATH = process.env.X402_RECEIPT_DB_PATH || DEFAULT_DB_PATH; function ensureDb(): void { - const dir = dirname(DB_PATH) + const dir = dirname(DB_PATH); if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) + mkdirSync(dir, { recursive: true }); } if (!existsSync(DB_PATH)) { - writeFileSync(DB_PATH, '[]\n', 'utf8') + writeFileSync(DB_PATH, "[]\n", "utf8"); } } function readReceipts(): X402ExplorerReceipt[] { - ensureDb() - const raw = readFileSync(DB_PATH, 'utf8').trim() - if (!raw) return [] - const parsed = JSON.parse(raw) as X402ExplorerReceipt[] - return Array.isArray(parsed) ? parsed : [] + ensureDb(); + const raw = readFileSync(DB_PATH, "utf8").trim(); + if (!raw) return []; + const parsed = JSON.parse(raw) as X402ExplorerReceipt[]; + return Array.isArray(parsed) ? parsed : []; } function writeReceipts(receipts: X402ExplorerReceipt[]): void { @@ -62,30 +71,49 @@ function writeReceipts(receipts: X402ExplorerReceipt[]): void { } } -export function saveX402Receipt(receipt: X402ExplorerReceipt): X402ExplorerReceipt { - const receipts = readReceipts() - const next = [receipt, ...receipts.filter((item) => item.id !== receipt.id)] - writeReceipts(next) - return receipt +export function saveX402Receipt( + receipt: X402ExplorerReceipt, +): X402ExplorerReceipt { + const receipts = readReceipts(); + const next = [receipt, ...receipts.filter((item) => item.id !== receipt.id)]; + writeReceipts(next); + return receipt; } -export function getX402Receipt(receiptId: string): X402ExplorerReceipt | undefined { - return readReceipts().find((receipt) => receipt.id === receiptId) +export function getX402Receipt( + receiptId: string, +): X402ExplorerReceipt | undefined { + return readReceipts().find((receipt) => receipt.id === receiptId); } -export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPage { - const pageSize = Math.max(1, Math.min(50, Math.floor(filters.pageSize ?? 50))) - const page = Math.max(1, Math.floor(filters.page ?? 1)) - const q = (filters.q || '').trim().toLowerCase() - const agent = (filters.agent || '').trim().toLowerCase() - const service = (filters.service || '').trim().toLowerCase() - const chain = filters.chain && filters.chain !== 'all' ? filters.chain : null - const allReceipts = readReceipts() +export function listX402Receipts( + filters: X402ReceiptQuery = {}, +): X402ReceiptPage { + const pageSize = Math.max( + 1, + Math.min(50, Math.floor(filters.pageSize ?? 50)), + ); + const page = Math.max(1, Math.floor(filters.page ?? 1)); + const q = (filters.q || "").trim().toLowerCase(); + const agent = (filters.agent || "").trim().toLowerCase(); + const service = (filters.service || "").trim().toLowerCase(); + const chain = filters.chain && filters.chain !== "all" ? filters.chain : null; + const allReceipts = readReceipts(); const filtered = allReceipts.filter((receipt) => { - if (chain && receipt.chain !== chain) return false - if (agent && receipt.agentId.toLowerCase() !== agent && receipt.agent.toLowerCase() !== agent) return false - if (service && receipt.serviceId.toLowerCase() !== service && receipt.service.toLowerCase() !== service) return false + if (chain && receipt.chain !== chain) return false; + if ( + agent && + receipt.agentId.toLowerCase() !== agent && + receipt.agent.toLowerCase() !== agent + ) + return false; + if ( + service && + receipt.serviceId.toLowerCase() !== service && + receipt.service.toLowerCase() !== service + ) + return false; if (q) { const haystack = [ receipt.id, @@ -97,15 +125,17 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag receipt.txHash, receipt.chain, receipt.amount, - ].join(' ').toLowerCase() - if (!haystack.includes(q)) return false + ] + .join(" ") + .toLowerCase(); + if (!haystack.includes(q)) return false; } - return true - }) + return true; + }); - const total = filtered.length - const start = (page - 1) * pageSize - const receipts = filtered.slice(start, start + pageSize) + const total = filtered.length; + const start = (page - 1) * pageSize; + const receipts = filtered.slice(start, start + pageSize); return { receipts, @@ -115,13 +145,17 @@ export function listX402Receipts(filters: X402ReceiptQuery = {}): X402ReceiptPag totalPages: Math.max(1, Math.ceil(total / pageSize)), stats: { totalPayments: allReceipts.length, - totalUsd: Number(allReceipts.reduce((sum, receipt) => sum + receipt.amountUsd, 0).toFixed(6)), + totalUsd: Number( + allReceipts + .reduce((sum, receipt) => sum + receipt.amountUsd, 0) + .toFixed(6), + ), uniqueAgents: new Set(allReceipts.map((receipt) => receipt.agentId)).size, services: new Set(allReceipts.map((receipt) => receipt.service)).size, }, - } + }; } export function resetX402ReceiptStoreForTests(): void { - writeReceipts([]) + writeReceipts([]); } diff --git a/lib/protocols/x402.ts b/lib/protocols/x402.ts index ad0eed4e..d72870de 100644 --- a/lib/protocols/x402.ts +++ b/lib/protocols/x402.ts @@ -1,12 +1,14 @@ -import { StrKey } from '@stellar/stellar-sdk' -import { verifyEvmPayment, type EvmSettlementChain } from '@/lib/evm-utils' +import { randomBytes } from "node:crypto"; +import { StrKey } from "@stellar/stellar-sdk"; +import { verifyEvmPayment, type EvmSettlementChain } from "@/lib/evm-utils"; -import { listX402Receipts, saveX402Receipt, type X402ReceiptQuery } from '@/lib/protocols/x402-receipt-store' -import type { ReputationAttestation, ReputationGateRequirement } from '@/lib/reputation/attestation' -import { checkReputationGate } from '@/lib/reputation/attestation' +import { listX402Receipts, saveX402Receipt, type X402ReceiptQuery } from "@/lib/protocols/x402-receipt-store"; +import type { ReputationAttestation, ReputationGateRequirement } from "@/lib/reputation/attestation"; +import { checkReputationGate } from "@/lib/reputation/attestation"; -export type SettlementChain = 'bnb' | 'base' | 'stellar' +export type SettlementChain = "bnb" | "base" | "stellar"; +type ChainAsset = "XLM" | "BNB" | "ETH"; const EXPLORER_MAINNET: Record<SettlementChain, string> = { bnb: 'https://bscscan.com/tx', base: 'https://basescan.org/tx', @@ -25,172 +27,241 @@ export function getExplorerUrl(chain: SettlementChain, txHash: string): string { return `${base}/${txHash}` } -type ChainAsset = 'XLM' | 'BNB' | 'ETH' - export interface X402QuoteRequest { - serviceId: string - chain?: SettlementChain - payer: string - units: number - unitPriceUsd: number - ttlSeconds?: number - reputationGate?: ReputationGateRequirement - attestation?: ReputationAttestation + serviceId: string; + chain?: SettlementChain; + payer: string; + units: number; + unitPriceUsd: number; + ttlSeconds?: number; + reputationGate?: ReputationGateRequirement; + attestation?: ReputationAttestation; } export interface X402QuoteOption { - chain: SettlementChain - amount: string - amountUnits: string - address: string + chain: SettlementChain; + amount: string; + amountUnits: string; + address: string; } export interface X402Quote { - code: 402 - quoteId: string - service: string - serviceId: string - chain: SettlementChain - payer: string - amountUsd: number - amountUnits: string - address: string - options: X402QuoteOption[] - expiresAt: string - paymentRef: string - memo: string + code: 402; + quoteId: string; + service: string; + serviceId: string; + chain: SettlementChain; + payer: string; + amountUsd: number; + amountUnits: string; + address: string; + options: X402QuoteOption[]; + expiresAt: string; + paymentRef: string; + memo: string; } export interface X402Settlement { - quoteId?: string - paymentRef?: string - chain: SettlementChain - txHash: string - paidBy?: string - agentId?: string + quoteId?: string; + paymentRef?: string; + chain: SettlementChain; + txHash: string; + paidBy?: string; + agentId?: string; } export interface X402Receipt { - accepted: boolean - quoteId?: string - paymentRef: string - settledAt: string - txHash: string - chain: SettlementChain - amountUsd?: number - amountUnits?: string - explorerUrl?: string + accepted: boolean; + quoteId?: string; + paymentRef: string; + settledAt: string; + txHash: string; + chain: SettlementChain; + amountUsd?: number; + amountUnits?: string; + explorerUrl?: string; } export interface X402ExplorerReceipt extends X402Receipt { - id: string - agentId: string - service: string - amount: string - serviceId: string - agent: string - amountUsd: number - amountUnits: string - passportVerified: boolean - reputationTier: string -} - -const CHAIN_DECIMALS: Record<SettlementChain, number> = { bnb: 18, base: 18, stellar: 7 } -const CHAIN_ASSET: Record<SettlementChain, ChainAsset> = { bnb: 'BNB', base: 'ETH', stellar: 'XLM' } -const FALLBACK_USD: Record<SettlementChain, number> = { stellar: 0.1, bnb: 550, base: 3000 } -const COINGECKO_IDS: Record<SettlementChain, string> = { stellar: 'stellar', bnb: 'binancecoin', base: 'ethereum' } + id: string; + agentId: string; + service: string; + amount: string; + serviceId: string; + agent: string; + amountUsd: number; + amountUnits: string; + passportVerified: boolean; + reputationTier: string; +} + +const CHAIN_DECIMALS: Record<SettlementChain, number> = { + bnb: 18, + base: 18, + stellar: 7, +}; +const CHAIN_ASSET: Record<SettlementChain, ChainAsset> = { + bnb: "BNB", + base: "ETH", + stellar: "XLM", +}; +const FALLBACK_USD: Record<SettlementChain, number> = { + stellar: 0.1, + bnb: 550, + base: 3000, +}; +const COINGECKO_IDS: Record<SettlementChain, string> = { + stellar: "stellar", + bnb: "binancecoin", + base: "ethereum", +}; const DEFAULT_ADDRESSES: Record<SettlementChain, string> = { - stellar: process.env.X402_STELLAR_ADDRESS || 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - bnb: process.env.X402_BNB_ADDRESS || process.env.X402_EVM_ADDRESS || '0x0000000000000000000000000000000000000000', - base: process.env.X402_BASE_ADDRESS || process.env.X402_EVM_ADDRESS || '0x0000000000000000000000000000000000000000', -} - -let cachedRates: { rates: Record<SettlementChain, number>; expiresAt: number } | null = null + stellar: + process.env.X402_STELLAR_ADDRESS || + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + bnb: + process.env.X402_BNB_ADDRESS || + process.env.X402_EVM_ADDRESS || + "0x0000000000000000000000000000000000000000", + base: + process.env.X402_BASE_ADDRESS || + process.env.X402_EVM_ADDRESS || + "0x0000000000000000000000000000000000000000", +}; + +let cachedRates: { + rates: Record<SettlementChain, number>; + expiresAt: number; +} | null = null; function parseUnits(value: number, decimals: number): string { - const fixed = value.toFixed(decimals) - return fixed.replace('.', '').replace(/^0+(?=\d)/, '') + const fixed = value.toFixed(decimals); + return fixed.replace(".", "").replace(/^0+(?=\d)/, ""); } function formatNativeAmount(value: number): string { - return value.toLocaleString('en-US', { maximumFractionDigits: 8, minimumFractionDigits: 0, useGrouping: false }) + return value.toLocaleString("en-US", { + maximumFractionDigits: 8, + minimumFractionDigits: 0, + useGrouping: false, + }); } -type QuoteRegistry = Map<string, X402Quote> +type QuoteRegistry = Map<string, X402Quote>; const globalState = globalThis as typeof globalThis & { - __x402QuoteRegistry__?: QuoteRegistry - __x402SubscriptionRegistry__?: SubscriptionRegistry -} + __x402QuoteRegistry__?: QuoteRegistry; + __x402SubscriptionRegistry__?: SubscriptionRegistry; +}; -const quoteRegistry: QuoteRegistry = globalState.__x402QuoteRegistry__ ?? new Map() -if (!globalState.__x402QuoteRegistry__) globalState.__x402QuoteRegistry__ = quoteRegistry -export interface X402SettlementResult { ok: boolean; receipt?: X402Receipt; error?: string } +const quoteRegistry: QuoteRegistry = (globalState.__x402QuoteRegistry__ ??= new Map()); +export interface X402SettlementResult { + ok: boolean; + receipt?: X402Receipt; + error?: string; +} -export function peekX402Quote(paymentRef: string): X402Quote | undefined { return quoteRegistry.get(paymentRef) } +export function peekX402Quote(paymentRef: string): X402Quote | undefined { + return quoteRegistry.get(paymentRef); +} -async function refreshNativeUsdRates(fetcher: typeof fetch = fetch): Promise<Record<SettlementChain, number>> { - const now = Date.now() - if (cachedRates && cachedRates.expiresAt > now) return cachedRates.rates +async function refreshNativeUsdRates( + fetcher: typeof fetch = fetch, +): Promise<Record<SettlementChain, number>> { + const now = Date.now(); + if (cachedRates && cachedRates.expiresAt > now) return cachedRates.rates; try { - const ids = Object.values(COINGECKO_IDS).join(',') - const response = await fetcher(`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`, { headers: { accept: 'application/json' }, next: { revalidate: 30 } }) - if (!response.ok) throw new Error('CoinGecko unavailable') - const payload = await response.json() as Record<string, { usd?: number }> - const rates = { ...FALLBACK_USD } + const ids = Object.values(COINGECKO_IDS).join(","); + const response = await fetcher( + `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`, + { headers: { accept: "application/json" }, next: { revalidate: 30 } }, + ); + if (!response.ok) throw new Error("CoinGecko unavailable"); + const payload = (await response.json()) as Record<string, { usd?: number }>; + const rates = { ...FALLBACK_USD }; for (const chain of Object.keys(COINGECKO_IDS) as SettlementChain[]) { - const usd = Number(payload[COINGECKO_IDS[chain]]?.usd) + const usd = Number(payload[COINGECKO_IDS[chain]]?.usd); if (Number.isFinite(usd) && usd > 0) { - const fallback = FALLBACK_USD[chain] - const deviation = Math.abs(usd - fallback) / fallback - if (deviation <= 0.20) { - rates[chain] = usd + const fallback = FALLBACK_USD[chain]; + const deviation = Math.abs(usd - fallback) / fallback; + if (deviation <= 0.2) { + rates[chain] = usd; } } } - cachedRates = { rates, expiresAt: now + 30_000 } - return rates + cachedRates = { rates, expiresAt: now + 30_000 }; + return rates; } catch { - cachedRates = { rates: FALLBACK_USD, expiresAt: now + 30_000 } - return FALLBACK_USD + cachedRates = { rates: FALLBACK_USD, expiresAt: now + 30_000 }; + return FALLBACK_USD; } } export function createX402Quote(input: X402QuoteRequest): X402Quote { - const ttlSeconds = input.ttlSeconds ?? 300 + const ttlSeconds = input.ttlSeconds ?? 300; if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { - throw new Error('ttlSeconds must be > 0') + throw new Error("ttlSeconds must be > 0"); } if (!Number.isFinite(input.units) || input.units <= 0) { - throw new Error('units must be > 0') + throw new Error("units must be > 0"); } if (!Number.isFinite(input.unitPriceUsd) || input.unitPriceUsd <= 0) { - throw new Error('unitPriceUsd must be > 0') + throw new Error("unitPriceUsd must be > 0"); } - const reputationGate = checkReputationGate(input.reputationGate, input.attestation) + const reputationGate = checkReputationGate( + input.reputationGate, + input.attestation, + ); if (!reputationGate.ok) { - throw new Error(reputationGate.error || 'Reputation too low for this service') + throw new Error( + reputationGate.error || "Reputation too low for this service", + ); } - const amountUsd = Number((input.units * input.unitPriceUsd).toFixed(6)) - const rates = cachedRates?.rates ?? FALLBACK_USD - void refreshNativeUsdRates() - const options = (['stellar', 'bnb', 'base'] as SettlementChain[]).map((chain) => { - const nativeAmount = amountUsd / rates[chain] - return { chain, amount: `${formatNativeAmount(nativeAmount)} ${CHAIN_ASSET[chain]}`, amountUnits: parseUnits(nativeAmount, CHAIN_DECIMALS[chain]), address: DEFAULT_ADDRESSES[chain] } - }) - const preferredChain = input.chain ?? 'bnb' - const preferred = options.find((option) => option.chain === preferredChain) ?? options[0] - const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString() - const quoteId = `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` - const paymentRef = `${input.serviceId}:${preferred.chain}:${Date.now()}` - const quote: X402Quote = { code: 402, quoteId, service: input.serviceId, serviceId: input.serviceId, chain: preferred.chain, payer: input.payer, amountUsd, amountUnits: preferred.amountUnits, address: preferred.address, options, expiresAt, paymentRef, memo: `x402/${input.serviceId}/${quoteId}` } - quoteRegistry.set(paymentRef, quote) - quoteRegistry.set(quoteId, quote) - return quote + const amountUsd = Number((input.units * input.unitPriceUsd).toFixed(6)); + const rates = cachedRates?.rates ?? FALLBACK_USD; + void refreshNativeUsdRates(); + const options = (["stellar", "bnb", "base"] as SettlementChain[]).map( + (chain) => { + const nativeAmount = amountUsd / rates[chain]; + return { + chain, + amount: `${formatNativeAmount(nativeAmount)} ${CHAIN_ASSET[chain]}`, + amountUnits: parseUnits(nativeAmount, CHAIN_DECIMALS[chain]), + address: DEFAULT_ADDRESSES[chain], + }; + }, + ); + const preferredChain = input.chain ?? "bnb"; + const preferred = + options.find((option) => option.chain === preferredChain) ?? options[0]; + const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(); + const randSuffix = globalThis.crypto?.randomUUID() + ? globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, 6) + : randomBytes(4).toString("hex").slice(0, 6); + const quoteId = `q_${Date.now().toString(36)}_${randSuffix}`; + const paymentRef = `${input.serviceId}:${preferred.chain}:${Date.now()}`; + const quote: X402Quote = { + code: 402, + quoteId, + service: input.serviceId, + serviceId: input.serviceId, + chain: preferred.chain, + payer: input.payer, + amountUsd, + amountUnits: preferred.amountUnits, + address: preferred.address, + options, + expiresAt, + paymentRef, + memo: `x402/${input.serviceId}/${quoteId}`, + }; + quoteRegistry.set(paymentRef, quote); + quoteRegistry.set(quoteId, quote); + return quote; } export async function verifyX402Settlement(input: X402Settlement, quote?: X402Quote): Promise<X402Receipt> { @@ -212,44 +283,69 @@ export async function verifyX402Settlement(input: X402Settlement, quote?: X402Qu } export function listX402ExplorerReceipts(filters: X402ReceiptQuery = {}) { - return listX402Receipts(filters) + return listX402Receipts(filters); +} + +function isValidPayer(payer: string, chain: SettlementChain, quotePayer: string): boolean { + if (!payer || quotePayer === "anonymous") return true; + const isStellarPayer = chain === "stellar" && StrKey.isValidEd25519PublicKey(payer); + const isEvmPayer = chain !== "stellar" && /^0x[a-fA-F0-9]{40}$/.test(payer); + return isStellarPayer || isEvmPayer || payer === quotePayer; +} + +function isValidTxHash(txHash: string, chain: SettlementChain): boolean { + if (chain === "stellar") { + return ( + /^0x[a-fA-F0-9]{64}$/.test(txHash) || + /^[a-fA-F0-9]{64}$/.test(txHash) || + /^[A-Z0-9]{64}$/.test(txHash) + ); + } + return /^0x[a-fA-F0-9]{64}$/.test(txHash); } export function settleX402(input: X402Settlement): X402SettlementResult { - const paymentRef = input.paymentRef || input.quoteId || '' - const quote = quoteRegistry.get(paymentRef) - if (!quote) return { ok: false, error: 'Quote not found for paymentRef' } - - const isQuoteIdSettlement = Boolean(input.quoteId) && !input.paymentRef - if (!isQuoteIdSettlement && quote.chain !== input.chain) return { ok: false, error: 'Settlement chain does not match quote chain' } - const option = quote.options.find((item) => item.chain === input.chain) - if (!option) return { ok: false, error: 'Settlement chain is not available for quote' } - - const isExpired = Date.now() > new Date(quote.expiresAt).getTime() - if (isExpired) { - quoteRegistry.delete(paymentRef) - quoteRegistry.delete(quote.quoteId) - return { ok: false, error: 'Quote expired' } + const paymentRef = input.paymentRef || input.quoteId || ""; + const quote = quoteRegistry.get(paymentRef); + if (!quote) return { ok: false, error: "Quote not found for paymentRef" }; + + const isQuoteIdSettlement = Boolean(input.quoteId) && !input.paymentRef; + if (!isQuoteIdSettlement && quote.chain !== input.chain) + return { ok: false, error: "Settlement chain does not match quote chain" }; + const option = quote.options.find((item) => item.chain === input.chain); + if (!option) + return { ok: false, error: "Settlement chain is not available for quote" }; + + if (Date.now() > new Date(quote.expiresAt).getTime()) { + quoteRegistry.delete(paymentRef); + quoteRegistry.delete(quote.quoteId); + return { ok: false, error: "Quote expired" }; } - const payer = input.paidBy || input.agentId || '' - if (payer && quote.payer !== 'anonymous') { - const isStellarPayer = input.chain === 'stellar' && StrKey.isValidEd25519PublicKey(payer) - const isEvmPayer = input.chain !== 'stellar' && /^0x[a-fA-F0-9]{40}$/.test(payer) - if (!isStellarPayer && !isEvmPayer && payer !== quote.payer) return { ok: false, error: 'paidBy does not match quote payer' } + const payer = input.paidBy || input.agentId || ""; + if (!isValidPayer(payer, input.chain, quote.payer)) { + return { ok: false, error: "paidBy does not match quote payer" }; } - const txLooksValid = input.chain === 'stellar' ? /^0x[a-fA-F0-9]{64}$/.test(input.txHash) || /^[a-fA-F0-9]{64}$/.test(input.txHash) || /^[A-Z0-9]{64}$/.test(input.txHash) : /^0x[a-fA-F0-9]{64}$/.test(input.txHash) - const receipt: X402Receipt = { accepted: txLooksValid, quoteId: quote.quoteId, paymentRef, settledAt: new Date().toISOString(), txHash: input.txHash, chain: input.chain } - if (!receipt.accepted) return { ok: false, error: 'Invalid tx hash format' } + if (!isValidTxHash(input.txHash, input.chain)) { + return { ok: false, error: "Invalid tx hash format" }; + } - receipt.amountUsd = quote.amountUsd - receipt.amountUnits = option.amountUnits - receipt.explorerUrl = getExplorerUrl(input.chain, input.txHash) + const receipt: X402Receipt = { + accepted: true, + quoteId: quote.quoteId, + paymentRef, + settledAt: new Date().toISOString(), + txHash: input.txHash, + chain: input.chain, + amountUsd: quote.amountUsd, + amountUnits: option.amountUnits, + explorerUrl: getExplorerUrl(input.chain, input.txHash), + }; const storedReceipt = saveX402Receipt({ ...receipt, - id: `rcpt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + id: `rcpt_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`, agentId: quote.payer, service: quote.serviceId, amount: `${quote.amountUsd} USD`, @@ -258,114 +354,126 @@ export function settleX402(input: X402Settlement): X402SettlementResult { amountUsd: quote.amountUsd, amountUnits: option.amountUnits, passportVerified: true, - reputationTier: quote.amountUsd >= 1 ? 'gold' : 'standard', - }) + reputationTier: quote.amountUsd >= 1 ? "gold" : "standard", + }); - quoteRegistry.delete(paymentRef) - quoteRegistry.delete(quote.quoteId) - return { ok: true, receipt: storedReceipt } + quoteRegistry.delete(paymentRef); + quoteRegistry.delete(quote.quoteId); + return { ok: true, receipt: storedReceipt }; } -export type X402SubscriptionPlan = 'starter' | 'growth' | 'pro' | 'custom' | 'monthly' -export type X402SubscriptionStatus = 'active' | 'grace' | 'paused' +export type X402SubscriptionPlan = + "starter" | "growth" | "pro" | "custom" | "monthly"; +export type X402SubscriptionStatus = "active" | "grace" | "paused"; export interface X402SubscriptionRequest { - serviceId: string - agentId: string - plan: X402SubscriptionPlan - callsPerMonth?: number - pricePerMonth?: string - walletBalanceXlm?: number - now?: Date + serviceId: string; + agentId: string; + plan: X402SubscriptionPlan; + callsPerMonth?: number; + pricePerMonth?: string; + walletBalanceXlm?: number; + now?: Date; } export interface X402Subscription { - id: string - serviceId: string - agentId: string - plan: X402SubscriptionPlan - callsPerMonth: number | null - callsUsed: number - pricePerMonth: string - status: X402SubscriptionStatus - active: boolean - createdAt: string - renewsAt: string - graceEndsAt?: string - pausedAt?: string - lastChargedAt: string - billingEvents: X402SubscriptionBillingEvent[] + id: string; + serviceId: string; + agentId: string; + plan: X402SubscriptionPlan; + callsPerMonth: number | null; + callsUsed: number; + pricePerMonth: string; + status: X402SubscriptionStatus; + active: boolean; + createdAt: string; + renewsAt: string; + graceEndsAt?: string; + pausedAt?: string; + lastChargedAt: string; + billingEvents: X402SubscriptionBillingEvent[]; } export interface X402SubscriptionBillingEvent { - id: string - type: 'initial_charge' | 'renewal' | 'renewal_failed' - amount: string - at: string - note: string + id: string; + type: "initial_charge" | "renewal" | "renewal_failed"; + amount: string; + at: string; + note: string; } export interface X402SubscriptionAccess { - active: boolean - callsRemaining: number | null - renewsAt: string - status: X402SubscriptionStatus | 'missing' | 'exhausted' - graceEndsAt?: string - subscription?: X402Subscription + active: boolean; + callsRemaining: number | null; + renewsAt: string; + status: X402SubscriptionStatus | "missing" | "exhausted"; + graceEndsAt?: string; + subscription?: X402Subscription; } -const PLAN_DEFAULTS: Record<X402SubscriptionPlan, { pricePerMonth: string; callsPerMonth: number | null }> = { - starter: { pricePerMonth: '1 XLM', callsPerMonth: 100 }, - growth: { pricePerMonth: '5 XLM', callsPerMonth: 1000 }, - pro: { pricePerMonth: '20 XLM', callsPerMonth: 10000 }, - custom: { pricePerMonth: 'Custom', callsPerMonth: null }, - monthly: { pricePerMonth: '5 XLM', callsPerMonth: 1000 }, -} +const PLAN_DEFAULTS: Record< + X402SubscriptionPlan, + { pricePerMonth: string; callsPerMonth: number | null } +> = { + starter: { pricePerMonth: "1 XLM", callsPerMonth: 100 }, + growth: { pricePerMonth: "5 XLM", callsPerMonth: 1000 }, + pro: { pricePerMonth: "20 XLM", callsPerMonth: 10000 }, + custom: { pricePerMonth: "Custom", callsPerMonth: null }, + monthly: { pricePerMonth: "5 XLM", callsPerMonth: 1000 }, +}; -const MS_PER_DAY = 24 * 60 * 60 * 1000 -const BILLING_CYCLE_MS = 30 * MS_PER_DAY -const GRACE_PERIOD_MS = MS_PER_DAY +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const BILLING_CYCLE_MS = 30 * MS_PER_DAY; +const GRACE_PERIOD_MS = MS_PER_DAY; -type SubscriptionRegistry = Map<string, X402Subscription> +type SubscriptionRegistry = Map<string, X402Subscription>; -const subscriptionRegistry: SubscriptionRegistry = globalState.__x402SubscriptionRegistry__ ?? new Map() -if (!globalState.__x402SubscriptionRegistry__) { - globalState.__x402SubscriptionRegistry__ = subscriptionRegistry -} +const subscriptionRegistry: SubscriptionRegistry = (globalState.__x402SubscriptionRegistry__ ??= new Map()); function subscriptionKey(agentId: string, serviceId: string) { - return `${agentId}:${serviceId}` + return `${agentId}:${serviceId}`; } +const XLM_PRICE_PATTERN = /^(\d+(?:\.\d+)?)\s*XLM$/i; + function parseXlmAmount(price: string) { - const match = price.trim().match(/^([0-9]+(?:\.[0-9]+)?)\s*XLM$/i) - return match ? Number(match[1]) : 0 + const match = XLM_PRICE_PATTERN.exec(price.trim()); + return match ? Number(match[1]) : 0; } function assertSubscriptionInput(serviceId: string, agentId: string) { - if (!serviceId.trim()) throw new Error('serviceId is required') - if (!agentId.trim()) throw new Error('agentId is required') -} - -export function createX402Subscription(input: X402SubscriptionRequest): X402Subscription { - const serviceId = input.serviceId.trim() - const agentId = input.agentId.trim() - assertSubscriptionInput(serviceId, agentId) - - const defaults = PLAN_DEFAULTS[input.plan] ?? PLAN_DEFAULTS.monthly - const callsPerMonth = input.callsPerMonth ?? defaults.callsPerMonth - if (callsPerMonth !== null && (!Number.isFinite(callsPerMonth) || callsPerMonth <= 0)) { - throw new Error('callsPerMonth must be > 0') + if (!serviceId.trim()) throw new Error("serviceId is required"); + if (!agentId.trim()) throw new Error("agentId is required"); +} + +export function createX402Subscription( + input: X402SubscriptionRequest, +): X402Subscription { + const serviceId = input.serviceId.trim(); + const agentId = input.agentId.trim(); + assertSubscriptionInput(serviceId, agentId); + + const defaults = PLAN_DEFAULTS[input.plan] ?? PLAN_DEFAULTS.monthly; + const callsPerMonth = input.callsPerMonth ?? defaults.callsPerMonth; + if ( + callsPerMonth !== null && + (!Number.isFinite(callsPerMonth) || callsPerMonth <= 0) + ) { + throw new Error("callsPerMonth must be > 0"); } - const pricePerMonth = (input.pricePerMonth || defaults.pricePerMonth).trim() - const requiredXlm = parseXlmAmount(pricePerMonth) - if (requiredXlm > 0 && input.walletBalanceXlm !== undefined && input.walletBalanceXlm < requiredXlm) { - throw new Error('insufficient wallet balance for first month') + const pricePerMonth = (input.pricePerMonth || defaults.pricePerMonth).trim(); + const requiredXlm = parseXlmAmount(pricePerMonth); + if ( + requiredXlm > 0 && + input.walletBalanceXlm !== undefined && + input.walletBalanceXlm < requiredXlm + ) { + throw new Error("insufficient wallet balance for first month"); } - const now = input.now ?? new Date() - const chargedAt = now.toISOString() + const now = input.now ?? new Date(); + const chargedAt = now.toISOString(); const subscription: X402Subscription = { id: `sub_${Date.now().toString(36)}_${subscriptionRegistry.size + 1}`, serviceId, @@ -374,117 +482,190 @@ export function createX402Subscription(input: X402SubscriptionRequest): X402Subs callsPerMonth, callsUsed: 0, pricePerMonth, - status: 'active', + status: "active", active: true, createdAt: chargedAt, renewsAt: new Date(now.getTime() + BILLING_CYCLE_MS).toISOString(), lastChargedAt: chargedAt, - billingEvents: [{ - id: `bill_${Date.now().toString(36)}_1`, - type: 'initial_charge', - amount: pricePerMonth, - at: chargedAt, - note: 'First subscription month deducted from agent Stellar wallet', - }], + billingEvents: [ + { + id: `bill_${Date.now().toString(36)}_1`, + type: "initial_charge", + amount: pricePerMonth, + at: chargedAt, + note: "First subscription month deducted from agent Stellar wallet", + }, + ], + }; + + subscriptionRegistry.set(subscriptionKey(agentId, serviceId), subscription); + return subscription; +} + +function updateSubscriptionForFailedRenewal( + subscription: X402Subscription, + now: Date, +) { + const graceEndTime = + new Date(subscription.renewsAt).getTime() + GRACE_PERIOD_MS; + const isGrace = now.getTime() <= graceEndTime; + + subscription.status = isGrace ? "grace" : "paused"; + subscription.active = isGrace; + subscription.graceEndsAt = new Date(graceEndTime).toISOString(); + if (!isGrace) { + subscription.pausedAt = now.toISOString(); } - - subscriptionRegistry.set(subscriptionKey(agentId, serviceId), subscription) - return subscription -} - -export function renewX402Subscriptions(now: Date = new Date(), balances: Record<string, number> = {}) { - const renewed: X402Subscription[] = [] - const paused: X402Subscription[] = [] + subscription.billingEvents.unshift({ + id: `bill_${Date.now().toString(36)}_${subscription.billingEvents.length + 1}`, + type: "renewal_failed", + amount: subscription.pricePerMonth, + at: now.toISOString(), + note: "Insufficient Stellar wallet balance; subscription entered grace/paused state", + }); +} + +function updateSubscriptionForSuccessRenewal( + subscription: X402Subscription, + now: Date, +) { + subscription.status = "active"; + subscription.active = true; + subscription.callsUsed = 0; + subscription.renewsAt = new Date( + now.getTime() + BILLING_CYCLE_MS, + ).toISOString(); + subscription.lastChargedAt = now.toISOString(); + delete subscription.graceEndsAt; + delete subscription.pausedAt; + subscription.billingEvents.unshift({ + id: `bill_${Date.now().toString(36)}_${subscription.billingEvents.length + 1}`, + type: "renewal", + amount: subscription.pricePerMonth, + at: now.toISOString(), + note: "Monthly renewal deducted from agent Stellar wallet", + }); +} + +export function renewX402Subscriptions( + now: Date = new Date(), + balances: Record<string, number> = {}, +) { + const renewed: X402Subscription[] = []; + const paused: X402Subscription[] = []; for (const subscription of subscriptionRegistry.values()) { - if (now.getTime() < new Date(subscription.renewsAt).getTime()) continue + if (now.getTime() < new Date(subscription.renewsAt).getTime()) continue; - const requiredXlm = parseXlmAmount(subscription.pricePerMonth) - const balance = balances[subscription.agentId] ?? 0 + const requiredXlm = parseXlmAmount(subscription.pricePerMonth); + const balance = balances[subscription.agentId] ?? 0; if (requiredXlm > 0 && balance < requiredXlm) { - const graceEndTime = new Date(subscription.renewsAt).getTime() + GRACE_PERIOD_MS - subscription.status = now.getTime() <= graceEndTime ? 'grace' : 'paused' - subscription.active = subscription.status === 'grace' - subscription.graceEndsAt = new Date(graceEndTime).toISOString() - if (subscription.status === 'paused') subscription.pausedAt = now.toISOString() - subscription.billingEvents.unshift({ - id: `bill_${Date.now().toString(36)}_${subscription.billingEvents.length + 1}`, - type: 'renewal_failed', - amount: subscription.pricePerMonth, - at: now.toISOString(), - note: 'Insufficient Stellar wallet balance; subscription entered grace/paused state', - }) - paused.push(subscription) - continue + updateSubscriptionForFailedRenewal(subscription, now); + paused.push(subscription); + } else { + updateSubscriptionForSuccessRenewal(subscription, now); + renewed.push(subscription); } - - subscription.status = 'active' - subscription.active = true - subscription.callsUsed = 0 - subscription.renewsAt = new Date(now.getTime() + BILLING_CYCLE_MS).toISOString() - subscription.lastChargedAt = now.toISOString() - delete subscription.graceEndsAt - delete subscription.pausedAt - subscription.billingEvents.unshift({ - id: `bill_${Date.now().toString(36)}_${subscription.billingEvents.length + 1}`, - type: 'renewal', - amount: subscription.pricePerMonth, - at: now.toISOString(), - note: 'Monthly renewal deducted from agent Stellar wallet', - }) - renewed.push(subscription) } - return { renewed, paused } + return { renewed, paused }; } -export function checkX402Subscription(agentId: string, serviceId: string, options: { consumeCall?: boolean } = {}): X402SubscriptionAccess { - const subscription = subscriptionRegistry.get(subscriptionKey(agentId.trim(), serviceId.trim())) - if (!subscription) return { active: false, callsRemaining: 0, renewsAt: '', status: 'missing' } +export function checkX402Subscription( + agentId: string, + serviceId: string, + options: { consumeCall?: boolean; now?: Date } = {}, +): X402SubscriptionAccess { + const subscription = subscriptionRegistry.get( + subscriptionKey(agentId.trim(), serviceId.trim()), + ); + if (!subscription) + return { + active: false, + callsRemaining: 0, + renewsAt: "", + status: "missing", + }; + if (!subscription.active) { - return { active: false, callsRemaining: Math.max(0, (subscription.callsPerMonth ?? 0) - subscription.callsUsed), renewsAt: subscription.renewsAt, status: subscription.status, graceEndsAt: subscription.graceEndsAt, subscription } + return { + active: false, + callsRemaining: Math.max( + 0, + (subscription.callsPerMonth ?? 0) - subscription.callsUsed, + ), + renewsAt: subscription.renewsAt, + status: subscription.status, + graceEndsAt: subscription.graceEndsAt, + subscription, + }; } - const monthlyCallLimit = subscription.callsPerMonth - const unlimited = monthlyCallLimit === null - const callsRemaining = unlimited ? null : Math.max(0, monthlyCallLimit - subscription.callsUsed) + const monthlyCallLimit = subscription.callsPerMonth; + const unlimited = monthlyCallLimit === null; + const callsRemaining = unlimited + ? null + : Math.max(0, monthlyCallLimit - subscription.callsUsed); if (callsRemaining === 0) { - return { active: false, callsRemaining: 0, renewsAt: subscription.renewsAt, status: 'exhausted', subscription } + return { + active: false, + callsRemaining: 0, + renewsAt: subscription.renewsAt, + status: "exhausted", + subscription, + }; } - if (options.consumeCall && monthlyCallLimit !== null) subscription.callsUsed += 1 + if (options.consumeCall && monthlyCallLimit !== null) + subscription.callsUsed += 1; return { active: true, - callsRemaining: monthlyCallLimit === null ? null : Math.max(0, monthlyCallLimit - subscription.callsUsed), + callsRemaining: + monthlyCallLimit === null + ? null + : Math.max(0, monthlyCallLimit - subscription.callsUsed), renewsAt: subscription.renewsAt, status: subscription.status, graceEndsAt: subscription.graceEndsAt, subscription, - } + }; } -export function getX402SubscriptionById(subscriptionId: string): X402Subscription | undefined { - const id = subscriptionId.trim() - if (!id) return undefined - return Array.from(subscriptionRegistry.values()).find((subscription) => subscription.id === id) +export function getX402SubscriptionById( + subscriptionId: string, +): X402Subscription | undefined { + const id = subscriptionId.trim(); + if (!id) return undefined; + return Array.from(subscriptionRegistry.values()).find( + (subscription) => subscription.id === id, + ); } export function listX402Subscriptions() { - const subscriptions = Array.from(subscriptionRegistry.values()).sort((a, b) => a.renewsAt.localeCompare(b.renewsAt)) - const active = subscriptions.filter((subscription) => subscription.active) - const mrrXlm = active.reduce((sum, subscription) => sum + parseXlmAmount(subscription.pricePerMonth), 0) + const subscriptions = Array.from(subscriptionRegistry.values()).sort((a, b) => + a.renewsAt.localeCompare(b.renewsAt), + ); + const active = subscriptions.filter((subscription) => subscription.active); + const mrrXlm = active.reduce( + (sum, subscription) => sum + parseXlmAmount(subscription.pricePerMonth), + 0, + ); return { subscriptions, stats: { active: active.length, - paused: subscriptions.filter((subscription) => subscription.status === 'paused').length, - grace: subscriptions.filter((subscription) => subscription.status === 'grace').length, + paused: subscriptions.filter( + (subscription) => subscription.status === "paused", + ).length, + grace: subscriptions.filter( + (subscription) => subscription.status === "grace", + ).length, mrrXlm, }, - } + }; } export function resetX402SubscriptionsForTests() { - subscriptionRegistry.clear() + subscriptionRegistry.clear(); } diff --git a/package.json b/package.json index fa3e31a3..0c12ac67 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "workspaces": [ "packages/*" ], + "bin": { + "open-stellar": "./bin/open-stellar.js" + }, "scripts": { "dev": "next dev --webpack", "build": "next build --webpack",