diff --git a/__tests__/api/agents/badges.test.ts b/__tests__/api/agents/badges.test.ts index bf382712..34c2a527 100644 --- a/__tests__/api/agents/badges.test.ts +++ b/__tests__/api/agents/badges.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect, beforeEach } from "vitest" +import { beforeEach, describe, expect, it } from "vitest" + import { GET } from "@/app/api/agents/[id]/badges/route" -import { registerAgent, resetAgentRegistryForTests } from "@/lib/agent-registry" +import { awardXP, resetAgentXpDb } from "@/lib/gamification/xp" +import { registerAgent, resetAgentRegistryForTests, getRegisteredAgent } from "@/lib/agent-registry" import { upsertReputationMetrics, resetReputationStoreForTests } from "@/lib/reputation/reputation-store" +import { markQuestClaimed, resetQuestCompletions } from "@/lib/gamification/quest-completions" +import { resetBadgeStoreForTests } from "@/lib/agents/badges" const minAgent = (agentId: string) => ({ agentId, @@ -16,117 +20,79 @@ const minAgent = (agentId: string) => ({ beforeEach(() => { resetAgentRegistryForTests() resetReputationStoreForTests() + resetQuestCompletions() + resetAgentXpDb() + resetBadgeStoreForTests() }) describe("GET /api/agents/:id/badges", () => { it("returns 404 for unknown agent", async () => { - const res = await GET( - new Request("http://localhost/api/agents/ghost/badges"), - { params: Promise.resolve({ id: "ghost" }) }, - ) + const res = await GET(new Request("http://localhost/api/agents/ghost/badges"), { + params: Promise.resolve({ id: "ghost" }), + }) + expect(res.status).toBe(404) - const json = await res.json() - expect(json.ok).toBe(false) - expect(json.error).toBe("agent not found") + expect(await res.json()).toEqual({ ok: false, error: "agent not found" }) }) - it("returns empty badge list for agent with no badges", async () => { + it("returns the new badge API shape", async () => { registerAgent(minAgent("bot-empty")) - const res = await GET( - new Request("http://localhost/api/agents/bot-empty/badges"), - { params: Promise.resolve({ id: "bot-empty" }) }, - ) - expect(res.status).toBe(200) - const json = await res.json() - expect(json).toEqual({ agentId: "bot-empty", badges: [], total: 0 }) - }) - it("returns badges sorted by earnedAt descending", async () => { - registerAgent(minAgent("bot-sorted")) - upsertReputationMetrics("bot-sorted", { - badges: [ - { id: "first-quest", rarity: "common", awardedAt: "2026-05-01T00:00:00.000Z" }, - { id: "rare-taskmaster", rarity: "rare", awardedAt: "2026-06-01T00:00:00.000Z" }, - ], + const res = await GET(new Request("http://localhost/api/agents/bot-empty/badges"), { + params: Promise.resolve({ id: "bot-empty" }), }) - const res = await GET( - new Request("http://localhost/api/agents/bot-sorted/badges"), - { params: Promise.resolve({ id: "bot-sorted" }) }, - ) + expect(res.status).toBe(200) - const json = await res.json() - expect(json.total).toBe(2) - expect(json.badges[0].badgeId).toBe("rare-taskmaster") - expect(json.badges[0].earnedAt).toBe("2026-06-01T00:00:00.000Z") - expect(json.badges[0].rarity).toBe("rare") - expect(json.badges[0].xpValue).toBeGreaterThan(0) - expect(json.badges[0].name).toBe("Rare Taskmaster") - expect(json.badges[1].badgeId).toBe("first-quest") + expect(await res.json()).toEqual({ badges: [], count: 0 }) }) - it("returns catalog metadata for known badge ids", async () => { - registerAgent(minAgent("bot-meta")) - upsertReputationMetrics("bot-meta", { - badges: [{ id: "zk-certified", rarity: "epic", awardedAt: "2026-06-01T00:00:00.000Z" }], + it("awards and returns badges automatically when conditions are met", async () => { + registerAgent(minAgent("bot-leveler")) + upsertReputationMetrics("bot-leveler", { tasksCompleted: 1 }) + awardXP("bot-leveler", 4_000, "task.completed") + + const res = await GET(new Request("http://localhost/api/agents/bot-leveler/badges"), { + params: Promise.resolve({ id: "bot-leveler" }), }) - const res = await GET( - new Request("http://localhost/api/agents/bot-meta/badges"), - { params: Promise.resolve({ id: "bot-meta" }) }, - ) const json = await res.json() - expect(json.badges[0]).toMatchObject({ - badgeId: "zk-certified", - name: "ZK Certified", - description: "Minted first ZK passport for permanent identity.", - rarity: "epic", - xpValue: 100, - }) + + expect(json.count).toBeGreaterThanOrEqual(2) + expect(json.badges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "first_task", name: "First Task" }), + expect.objectContaining({ type: "level_10", name: "Level 10" }), + ]), + ) }) - it("returns fallback metadata for unknown badge ids", async () => { - registerAgent(minAgent("bot-unknown-badge")) - upsertReputationMetrics("bot-unknown-badge", { - badges: [{ id: "mystery-award-12345", rarity: "common", awardedAt: "2026-06-01T00:00:00.000Z" }], + it("awards veteran on read once the registration date is old enough", async () => { + registerAgent(minAgent("bot-veteran")) + const agent = getRegisteredAgent("bot-veteran") + agent!.registeredAt = "2026-01-01T00:00:00.000Z" + + const res = await GET(new Request("http://localhost/api/agents/bot-veteran/badges"), { + params: Promise.resolve({ id: "bot-veteran" }), }) - const res = await GET( - new Request("http://localhost/api/agents/bot-unknown-badge/badges"), - { params: Promise.resolve({ id: "bot-unknown-badge" }) }, - ) const json = await res.json() - expect(json.total).toBe(1) - expect(json.badges[0].badgeId).toBe("mystery-award-12345") - expect(json.badges[0].name).toBe("mystery-award-12345") - expect(json.badges[0].xpValue).toBe(0) + + expect(json.badges).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "veteran" })]), + ) }) - it("filters badges by rarity", async () => { - registerAgent(minAgent("bot-rarity")) - upsertReputationMetrics("bot-rarity", { - badges: [ - { id: "first-quest", rarity: "common", awardedAt: "2026-05-01T00:00:00.000Z" }, - { id: "rare-taskmaster", rarity: "rare", awardedAt: "2026-06-01T00:00:00.000Z" }, - ], + it("awards quest master after five quest completions", async () => { + registerAgent(minAgent("bot-quester")) + for (let i = 0; i < 5; i += 1) { + markQuestClaimed(`quest-${i}`, "bot-quester") + } + + const res = await GET(new Request("http://localhost/api/agents/bot-quester/badges"), { + params: Promise.resolve({ id: "bot-quester" }), }) - const res = await GET( - new Request("http://localhost/api/agents/bot-rarity/badges?rarity=common"), - { params: Promise.resolve({ id: "bot-rarity" }) }, - ) - expect(res.status).toBe(200) const json = await res.json() - expect(json.total).toBe(1) - expect(json.badges[0].rarity).toBe("common") - }) - it("returns 400 for invalid rarity", async () => { - registerAgent(minAgent("bot-bad-rarity")) - const res = await GET( - new Request("http://localhost/api/agents/bot-bad-rarity/badges?rarity=mythic"), - { params: Promise.resolve({ id: "bot-bad-rarity" }) }, + expect(json.badges).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "quest_master" })]), ) - expect(res.status).toBe(400) - const json = await res.json() - expect(json.ok).toBe(false) - expect(json.error).toContain("rarity") - expect(json.error).toContain("mythic") }) }) diff --git a/__tests__/api/badges/catalog.test.ts b/__tests__/api/badges/catalog.test.ts index 60fdc0a9..6bbc5778 100644 --- a/__tests__/api/badges/catalog.test.ts +++ b/__tests__/api/badges/catalog.test.ts @@ -1,116 +1,55 @@ -import { describe, it, expect, beforeEach } from "vitest" +import { beforeEach, describe, expect, it } from "vitest" + import { GET } from "@/app/api/badges/route" +import { awardXP, resetAgentXpDb } from "@/lib/gamification/xp" +import { checkAndAwardBadges, resetBadgeStoreForTests } from "@/lib/agents/badges" import { registerAgent, resetAgentRegistryForTests } from "@/lib/agent-registry" -import { upsertReputationMetrics, resetReputationStoreForTests } from "@/lib/reputation/reputation-store" -import { BADGE_CATALOG, getBadgeCatalogEntry, BADGE_RARITY_VALUES } from "@/lib/gamification/badge-catalog" - -describe("badge-catalog module", () => { - it("exports all five rarity tiers in order", () => { - expect(BADGE_RARITY_VALUES).toEqual(["common", "uncommon", "rare", "epic", "legendary"]) - }) - - it("getBadgeCatalogEntry returns entry for known id", () => { - const entry = getBadgeCatalogEntry("iron-badge-progress") - expect(entry).toBeDefined() - expect(entry!.badgeId).toBe("iron-badge-progress") - expect(entry!.xpValue).toBeGreaterThan(0) - expect(entry!.rarity).toBe("common") - }) - - it("getBadgeCatalogEntry returns undefined for unknown id", () => { - expect(getBadgeCatalogEntry("does-not-exist")).toBeUndefined() - }) - - it("catalog has at least 5 entries", () => { - expect(BADGE_CATALOG.length).toBeGreaterThanOrEqual(5) - }) +import { resetReputationStoreForTests, upsertReputationMetrics } from "@/lib/reputation/reputation-store" + +const makeAgent = (agentId: string) => ({ + agentId, + model: "test", + district: "defense" as const, + capabilities: [], + status: "active" as const, + endpoint: "http://example.test", + x402: { accepts: false }, +}) - it("all catalog entries have required fields", () => { - for (const entry of BADGE_CATALOG) { - expect(entry.badgeId).toBeTruthy() - expect(entry.name).toBeTruthy() - expect(entry.description).toBeTruthy() - expect(BADGE_RARITY_VALUES as readonly string[]).toContain(entry.rarity) - expect(entry.xpValue).toBeGreaterThan(0) - } - }) +beforeEach(() => { + resetAgentRegistryForTests() + resetBadgeStoreForTests() + resetAgentXpDb() + resetReputationStoreForTests() }) describe("GET /api/badges", () => { - beforeEach(() => { - resetAgentRegistryForTests() - resetReputationStoreForTests() - }) - - it("returns full catalog", async () => { - const res = await GET(new Request("http://localhost/api/badges")) - expect(res.status).toBe(200) - const json = await res.json() - expect(Array.isArray(json)).toBe(true) - expect(json.length).toBe(BADGE_CATALOG.length) - const entry = json[0] - expect(entry).toHaveProperty("badgeId") - expect(entry).toHaveProperty("name") - expect(entry).toHaveProperty("description") - expect(entry).toHaveProperty("rarity") - expect(entry).toHaveProperty("xpValue") - expect(entry).toHaveProperty("earnedByCount") - }) - - it("earnedByCount is 0 when no agents have earned the badge", async () => { - const res = await GET(new Request("http://localhost/api/badges")) + it("returns the five implemented badge types", async () => { + const res = await GET() const json = await res.json() - for (const entry of json) { - expect(entry.earnedByCount).toBe(0) - } - }) - - it("earnedByCount counts distinct agents with that badge", async () => { - registerAgent({ agentId: "agent-a", model: "m", district: "defense" as const, capabilities: [], status: "active" as const, endpoint: "http://x", x402: { accepts: false } }) - registerAgent({ agentId: "agent-b", model: "m", district: "defense" as const, capabilities: [], status: "active" as const, endpoint: "http://x", x402: { accepts: false } }) - upsertReputationMetrics("agent-a", { - badges: [{ id: "first-quest", rarity: "common", awardedAt: "2026-05-01T00:00:00.000Z" }], - }) - upsertReputationMetrics("agent-b", { - badges: [{ id: "first-quest", rarity: "common", awardedAt: "2026-05-02T00:00:00.000Z" }], - }) - const res = await GET(new Request("http://localhost/api/badges")) - const json = await res.json() - const fq = json.find((e: { badgeId: string }) => e.badgeId === "first-quest") - expect(fq).toBeDefined() - expect(fq.earnedByCount).toBe(2) + expect(json).toHaveLength(5) + expect(json.map((badge: { type: string }) => badge.type).sort()).toEqual([ + "first_task", + "level_10", + "quest_master", + "top_earner", + "veteran", + ]) }) - it("earnedByCount counts each agent once even if they have duplicate badge ids", async () => { - registerAgent({ agentId: "agent-dup", model: "m", district: "defense" as const, capabilities: [], status: "active" as const, endpoint: "http://x", x402: { accepts: false } }) - upsertReputationMetrics("agent-dup", { - badges: [ - { id: "first-quest", rarity: "common", awardedAt: "2026-05-01T00:00:00.000Z" }, - { id: "first-quest", rarity: "common", awardedAt: "2026-05-03T00:00:00.000Z" }, - ], - }) - - const res = await GET(new Request("http://localhost/api/badges")) - const json = await res.json() - const fq = json.find((e: { badgeId: string }) => e.badgeId === "first-quest") - expect(fq.earnedByCount).toBe(1) - }) + it("includes earnedByCount from the file-backed badge store", async () => { + registerAgent(makeAgent("agent-counted")) + upsertReputationMetrics("agent-counted", { tasksCompleted: 1 }) + awardXP("agent-counted", 4_000, "task.completed") + checkAndAwardBadges("agent-counted") - it("filters catalog by rarity", async () => { - const res = await GET(new Request("http://localhost/api/badges?rarity=common")) - expect(res.status).toBe(200) + const res = await GET() const json = await res.json() - expect(json.length).toBeGreaterThan(0) - expect(json.every((e: { rarity: string }) => e.rarity === "common")).toBe(true) - }) - it("returns 400 for invalid rarity", async () => { - const res = await GET(new Request("http://localhost/api/badges?rarity=mythic")) - expect(res.status).toBe(400) - const json = await res.json() - expect(json.ok).toBe(false) - expect(json.error).toContain("rarity") - expect(json.error).toContain("mythic") + const firstTask = json.find((badge: { type: string }) => badge.type === "first_task") + const levelTen = json.find((badge: { type: string }) => badge.type === "level_10") + expect(firstTask.earnedByCount).toBe(1) + expect(levelTen.earnedByCount).toBe(1) }) }) diff --git a/__tests__/api/registry/registry.test.ts b/__tests__/api/registry/registry.test.ts index 6ff6ccc3..f2ba09f8 100644 --- a/__tests__/api/registry/registry.test.ts +++ b/__tests__/api/registry/registry.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it } from "vitest" +import { DELETE as deleteRegistryAgent, PATCH as patchRegistryAgent } from "@/app/api/registry/[id]/route" import { GET } from "@/app/api/registry/route" import { POST } from "@/app/api/agents/route" import { resetAgentRegistryForTests } from "@/lib/agent-registry" +import { resetAgentXpDb } from "@/lib/gamification/xp" +import { resetReputationStoreForTests } from "@/lib/reputation/reputation-store" + +const ADMIN_TOKEN = "test-admin-token" const agentA = { agentId: "alpha-1", @@ -25,25 +30,42 @@ const agentB = { beforeEach(() => { resetAgentRegistryForTests() + resetAgentXpDb() + resetReputationStoreForTests() + process.env.ADMIN_TOKEN = ADMIN_TOKEN }) +function agentContext(id: string) { + return { params: Promise.resolve({ id }) } +} + +function adminHeaders(extra: HeadersInit = {}): HeadersInit { + return { + "x-admin-token": ADMIN_TOKEN, + ...extra, + } +} + describe("GET /api/registry", () => { it("returns all agents when no capability filter is given", async () => { await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentB) })) - const res = await GET(new Request("http://localhost/api/registry")) + const res = await GET(new Request("http://localhost/api/registry", { headers: adminHeaders() })) expect(res.status).toBe(200) const body = await res.json() expect(body.ok).toBe(true) expect(body.agents).toHaveLength(2) + expect(body.agents[0]).toHaveProperty("xp") + expect(body.agents[0]).toHaveProperty("tasksCompleted") + expect(body.agents[0]).toHaveProperty("lastSeen") }) it("filters agents by capability (exact match, case-insensitive)", async () => { await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentB) })) - const res = await GET(new Request("http://localhost/api/registry?capability=Payment")) + const res = await GET(new Request("http://localhost/api/registry?capability=Payment", { headers: adminHeaders() })) const body = await res.json() expect(body.agents).toHaveLength(1) expect(body.agents[0].agentId).toBe("alpha-1") @@ -53,7 +75,7 @@ describe("GET /api/registry", () => { await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentB) })) - const res = await GET(new Request("http://localhost/api/registry?capability=ANALYTICS")) + const res = await GET(new Request("http://localhost/api/registry?capability=ANALYTICS", { headers: adminHeaders() })) const body = await res.json() expect(body.agents).toHaveLength(2) }) @@ -61,14 +83,87 @@ describe("GET /api/registry", () => { it("returns empty array when no agents match", async () => { await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) - const res = await GET(new Request("http://localhost/api/registry?capability=unknown")) + const res = await GET(new Request("http://localhost/api/registry?capability=unknown", { headers: adminHeaders() })) const body = await res.json() expect(body.agents).toHaveLength(0) }) it("returns empty array when registry is empty", async () => { - const res = await GET(new Request("http://localhost/api/registry")) + const res = await GET(new Request("http://localhost/api/registry", { headers: adminHeaders() })) const body = await res.json() expect(body.agents).toHaveLength(0) }) + + it("forces an agent offline via PATCH /api/registry/[id]", async () => { + await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) + + const res = await patchRegistryAgent( + new Request("http://localhost/api/registry/alpha-1", { + method: "PATCH", + headers: adminHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ status: "offline" }), + }), + agentContext("alpha-1"), + ) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.agent.status).toBe("offline") + }) + + it("deregisters an agent via DELETE /api/registry/[id]", async () => { + await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) + + const res = await deleteRegistryAgent( + new Request("http://localhost/api/registry/alpha-1", { + method: "DELETE", + headers: adminHeaders(), + }), + agentContext("alpha-1"), + ) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.agent.agentId).toBe("alpha-1") + + const list = await GET(new Request("http://localhost/api/registry", { headers: adminHeaders() })) + expect((await list.json()).agents).toHaveLength(0) + }) + + it("returns 403 when listing agents without a valid admin token", async () => { + await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) + + const res = await GET(new Request("http://localhost/api/registry")) + + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }) + }) + + it("returns 403 when forcing an agent offline without a valid admin token", async () => { + await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) + + const res = await patchRegistryAgent( + new Request("http://localhost/api/registry/alpha-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "offline" }), + }), + agentContext("alpha-1"), + ) + + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }) + }) + + it("returns 403 when deregistering without a valid admin token", async () => { + await POST(new Request("http://localhost/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(agentA) })) + + const res = await deleteRegistryAgent( + new Request("http://localhost/api/registry/alpha-1", { method: "DELETE" }), + agentContext("alpha-1"), + ) + + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }) + }) }) diff --git a/app/admin/agents/page.tsx b/app/admin/agents/page.tsx new file mode 100644 index 00000000..8c72cb94 --- /dev/null +++ b/app/admin/agents/page.tsx @@ -0,0 +1,16 @@ +import { headers } from "next/headers" +import { forbidden } from "next/navigation" + +import { AdminAgentsClient } from "@/components/admin/admin-agents-client" +import { getProvidedAdminToken, hasValidAdminToken } from "@/lib/admin-token" + +export default async function AdminAgentsPage() { + const headerStore = await headers() + const providedToken = getProvidedAdminToken(headerStore) + + if (!hasValidAdminToken(headerStore) || !providedToken) { + forbidden() + } + + return +} diff --git a/app/agent-functions/[id]/route.ts b/app/agent-functions/[id]/route.ts index 4929571e..ce3fd71a 100644 --- a/app/agent-functions/[id]/route.ts +++ b/app/agent-functions/[id]/route.ts @@ -3,6 +3,7 @@ import { getCloudAgentConfig, provisionCloudAgent, updateCloudAgentResult } from import { recordAgentHeartbeat, HEARTBEAT_INTERVAL_MS } from "@/lib/agents/agent-health-store" import { getAgentHealthSummary, recordAgentExecutionError, recordAgentInvocation } from "@/lib/agents/agent-error-store" import { publishSystemEvent } from "@/lib/events/system-events" +import { recordTaskCompletion } from "@/lib/reputation/reputation-store" import { isAuthorized } from "@/lib/auth" export const runtime = "nodejs" @@ -56,11 +57,11 @@ export async function POST(req: Request, context: RouteContext) { publishSystemEvent({ type: "task.started", agentId: config.id, task: { id: taskId, title: task, district: config.district } }) const started = Date.now() - try { - const summary = await reasonAboutTask(task, config.model) - updateCloudAgentResult(config.id, summary) - recordAgentHeartbeat(config.id, { status: getAgentHealthSummary(config.id).degraded ? "degraded" : "active", cpu: 8, memory: 24, currentTask: summary, autoRestart: true }) - publishSystemEvent({ type: "task.completed", agentId: config.id, taskId, result: { summary, durationMs: Date.now() - started } }) + const summary = await reasonAboutTask(task, config.model) + updateCloudAgentResult(config.id, summary) + recordAgentHeartbeat(config.id, { status: "active", cpu: 8, memory: 24, currentTask: summary, autoRestart: true }) + recordTaskCompletion(config.id) + publishSystemEvent({ type: "task.completed", agentId: config.id, taskId, result: { summary, durationMs: Date.now() - started } }) return NextResponse.json({ ok: true, agentId: config.id, taskId, result: { summary } }, { headers: { "Cache-Control": "no-store" } }) } catch (error) { diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index a521fc89..1af8e7e4 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next" import Link from "next/link" import { notFound } from "next/navigation" +import { CheckCircle2, ScrollText, Shield, Sparkles, Trophy } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { buildSevenDayXpSnapshots, getLevelProgress, type XpHistoryEventLike } from "@/lib/agents/xp-history-chart" -import { getXpToNextLevel } from "@/lib/gamification/xp" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { AGENT_OG_SIZE, @@ -21,6 +21,30 @@ type AgentPageProps = { params: Promise<{ id: string }> } +type AgentBadge = { + type: string + name: string + description: string + icon: string + awardedAt: string +} + +function badgeVisual(type: string) { + if (type === "first_task") { + return { icon: CheckCircle2, className: "text-emerald-300" } + } + if (type === "quest_master") { + return { icon: ScrollText, className: "text-cyan-300" } + } + if (type === "level_10") { + return { icon: Sparkles, className: "text-amber-300" } + } + if (type === "veteran") { + return { icon: Shield, className: "text-violet-300" } + } + return { icon: Trophy, className: "text-rose-300" } +} + function getBaseUrl(): string { if (process.env.NEXT_PUBLIC_APP_URL) { return process.env.NEXT_PUBLIC_APP_URL @@ -85,12 +109,12 @@ export default async function AgentPage({ params }: AgentPageProps) { const { id } = await params // Data loading as required by acceptance criteria - const [metaRes, healthRes, repRes, questRes, xpHistoryRes] = await Promise.all([ + const [metaRes, healthRes, repRes, questRes, badgesRes] = await Promise.all([ fetch(absoluteUrl(`/api/agents/${id}`), { cache: 'no-store' }), fetch(absoluteUrl(`/api/agents/${id}/health`), { cache: 'no-store' }), fetch(absoluteUrl(`/api/protocol/reputation?actorId=${id}`), { cache: 'no-store' }), fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' }), - fetch(absoluteUrl(`/api/agents/${id}/xp/history?pageSize=100`), { cache: 'no-store' }), + fetch(absoluteUrl(`/api/agents/${id}/badges`), { cache: 'no-store' }), ]) const localAgent = findAgentByLookup(id) @@ -124,15 +148,29 @@ export default async function AgentPage({ params }: AgentPageProps) { // Parse Reputation let repScore = 0 - let badges: any[] = [] + let reputationBadges: any[] = [] let infractions = 0 if (repRes.ok) { const data = await repRes.json() repScore = data.reputation?.score || 0 - badges = data.reputation?.badges || [] + reputationBadges = data.reputation?.badges || [] infractions = data.reputation?.history?.filter((h: any) => h.delta < 0).length || 0 } + let badges: AgentBadge[] = [] + if (badgesRes.ok) { + const data = await badgesRes.json() + badges = data.badges || [] + } else if (reputationBadges.length > 0) { + badges = reputationBadges.map((badge: any) => ({ + type: badge.id, + name: badge.id, + description: "", + icon: "badge", + awardedAt: badge.awardedAt, + })) + } + // Parse Quests let quests: any[] = [] if (questRes.ok) { @@ -271,8 +309,8 @@ export default async function AgentPage({ params }: AgentPageProps) {
- {capabilities.length > 0 ? capabilities.map((cap, i) => ( - + {capabilities.length > 0 ? capabilities.map((cap) => ( + {cap} )) : ( @@ -289,11 +327,36 @@ export default async function AgentPage({ params }: AgentPageProps) {
- {badges.length > 0 ? badges.map((badge, i) => ( -
- {badge.name} -
- )) : ( + {badges.length > 0 ? badges.map((badge) => { + const visual = badgeVisual(badge.type) + const Icon = visual.icon + + return ( + + +
+ + + + {badge.name} +
+
+ +
+

{badge.name}

+

{badge.description}

+

+ Awarded {new Date(badge.awardedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+
+
+ ) + }) : (
No badges earned yet
@@ -306,8 +369,8 @@ export default async function AgentPage({ params }: AgentPageProps) { {/* Active Quests (Sidebar) */}

Active Quests

- {quests.length > 0 ? quests.slice(0, 3).map((quest, i) => ( - + {quests.length > 0 ? quests.slice(0, 3).map((quest) => ( +
@@ -335,44 +398,3 @@ export default async function AgentPage({ params }: AgentPageProps) { ) } - - - -function XpSparkline({ snapshots }: { snapshots: ReturnType }) { - const width = 560 - const height = 120 - const padding = 12 - const maxXp = Math.max(1, ...snapshots.map((point) => point.xp)) - const step = snapshots.length > 1 ? (width - padding * 2) / (snapshots.length - 1) : 0 - const points = snapshots.map((point, index) => { - const x = padding + index * step - const y = height - padding - (point.xp / maxXp) * (height - padding * 2) - return { ...point, x, y } - }) - const path = points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`).join(" ") - const areaPath = `${path} L${width - padding},${height - padding} L${padding},${height - padding} Z` - - return ( -
- - - - {points.map((point) => ( - - - {`${point.label}: ${point.xp} XP`} - - - ))} - -
- {snapshots.map((point) => ( -
-
{point.label}
-
{point.xp}
-
- ))} -
-
- ) -} diff --git a/app/api/agents/[id]/badges/route.ts b/app/api/agents/[id]/badges/route.ts index 45e02e19..1824b1fb 100644 --- a/app/api/agents/[id]/badges/route.ts +++ b/app/api/agents/[id]/badges/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server" + +import { checkAndAwardBadges, getAgentBadges } from "@/lib/agents/badges" import { getRegisteredAgent } from "@/lib/agent-registry" -import { getReputation } from "@/lib/reputation/reputation-store" -import { getBadgeCatalogEntry, BADGE_RARITY_VALUES, type BadgeRarity } from "@/lib/gamification/badge-catalog" interface RouteContext { params: Promise<{ id: string }> } -export async function GET(req: Request, context: RouteContext) { +export async function GET(_req: Request, context: RouteContext) { const { id } = await context.params const agentId = decodeURIComponent(id) @@ -16,32 +16,11 @@ export async function GET(req: Request, context: RouteContext) { return NextResponse.json({ ok: false, error: "agent not found" }, { status: 404 }) } - const rarityFilter = new URL(req.url).searchParams.get("rarity") - if (rarityFilter !== null && !(BADGE_RARITY_VALUES as readonly string[]).includes(rarityFilter)) { - return NextResponse.json( - { ok: false, error: `invalid rarity "${rarityFilter}"; must be one of: ${BADGE_RARITY_VALUES.join(", ")}` }, - { status: 400 }, - ) - } - - const { metrics } = getReputation(agentId) - let badges = (metrics.badges ?? []) - .map((b) => { - const catalog = getBadgeCatalogEntry(b.id) - return { - badgeId: b.id, - name: catalog?.name ?? b.id, - description: catalog?.description ?? "", - rarity: b.rarity as BadgeRarity, - earnedAt: b.awardedAt, - xpValue: catalog?.xpValue ?? 0, - } - }) - .sort((a, z) => new Date(z.earnedAt).getTime() - new Date(a.earnedAt).getTime()) - - if (rarityFilter) { - badges = badges.filter((b) => b.rarity === rarityFilter) - } + checkAndAwardBadges(agentId) + const badges = getAgentBadges(agentId) - return NextResponse.json({ agentId, badges, total: badges.length }) + return NextResponse.json( + { badges, count: badges.length }, + { headers: { "Cache-Control": "no-store" } }, + ) } diff --git a/app/api/agents/[id]/tasks/drain/route.ts b/app/api/agents/[id]/tasks/drain/route.ts index 4b709b9f..6bcb9fc5 100644 --- a/app/api/agents/[id]/tasks/drain/route.ts +++ b/app/api/agents/[id]/tasks/drain/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server" import { drainAgentTasks } from "@/lib/agents/task-queue" import { publishSystemEvent } from "@/lib/events/system-events" import { createApiRouteLogger } from "@/lib/api-logging" +import { recordTaskCompletion } from "@/lib/reputation/reputation-store" interface RouteContext { params: Promise<{ id: string }> @@ -21,6 +22,7 @@ export async function POST(req: Request, context: RouteContext) { maxItems, processor: async (task) => { // Emit task.completed event for each successfully processed task + recordTaskCompletion(task.agentId) publishSystemEvent({ type: "task.completed", agentId: task.agentId, diff --git a/app/api/badges/route.ts b/app/api/badges/route.ts index 5e801ec4..0fc99b9a 100644 --- a/app/api/badges/route.ts +++ b/app/api/badges/route.ts @@ -1,33 +1,14 @@ import { NextResponse } from "next/server" -import { BADGE_CATALOG, BADGE_RARITY_VALUES } from "@/lib/gamification/badge-catalog" -import { listReputations } from "@/lib/reputation/reputation-store" -export async function GET(req: Request) { - const rarityFilter = new URL(req.url).searchParams.get("rarity") - if (rarityFilter !== null && !(BADGE_RARITY_VALUES as readonly string[]).includes(rarityFilter)) { - return NextResponse.json( - { ok: false, error: `invalid rarity "${rarityFilter}"; must be one of: ${BADGE_RARITY_VALUES.join(", ")}` }, - { status: 400 }, - ) - } +import { getBadgeEarnedByCounts, listBadgeCatalog } from "@/lib/agents/badges" - const counts = new Map() - for (const snapshot of listReputations(Number.MAX_SAFE_INTEGER)) { - const seen = new Set() - for (const b of snapshot.metrics.badges ?? []) { - if (!seen.has(b.id)) { - seen.add(b.id) - counts.set(b.id, (counts.get(b.id) ?? 0) + 1) - } - } - } - - let catalog = BADGE_CATALOG - if (rarityFilter) { - catalog = catalog.filter((e) => e.rarity === rarityFilter) - } +export async function GET() { + const counts = getBadgeEarnedByCounts() return NextResponse.json( - catalog.map((e) => ({ ...e, earnedByCount: counts.get(e.badgeId) ?? 0 })), + listBadgeCatalog().map((badge) => ({ + ...badge, + earnedByCount: counts[badge.type], + })), ) } diff --git a/app/api/quests/[id]/apply/route.ts b/app/api/quests/[id]/apply/route.ts index e450a4b5..a9b79738 100644 --- a/app/api/quests/[id]/apply/route.ts +++ b/app/api/quests/[id]/apply/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" -import { getQuestById } from "@/lib/gamification/quests" +import { completeQuest, getQuestById } from "@/lib/gamification/quests" import { getReputationByActorId } from "@/lib/reputation/reputation-store" import { isAuthorized } from "@/lib/auth" import { hasClaimedQuest, markQuestClaimed } from "@/lib/gamification/quest-completions" @@ -77,16 +77,7 @@ export async function POST(req: Request, context: QuestApplyContext) { } markQuestClaimed(quest.id, actorId) - publishSystemEvent({ - type: "quest.completed", - agentId: actorId, - questId: quest.id, - quest: { - id: quest.id, - title: quest.title, - }, - reward: quest.reward, - }) + const completion = completeQuest(actorId, quest) - return NextResponse.json({ ok: true, quest, actorId }) + return NextResponse.json({ ok: true, quest, actorId, xpAward: completion.xpAward }) } diff --git a/app/api/registry/[id]/route.ts b/app/api/registry/[id]/route.ts new file mode 100644 index 00000000..543065d4 --- /dev/null +++ b/app/api/registry/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server" + +import { hasValidAdminToken } from "@/lib/admin-token" +import { deregisterAgent, getRegisteredAgent, updateAgentStatus } from "@/lib/agent-registry" + +interface RouteContext { + params: Promise<{ id: string }> +} + +interface UpdateRegistryBody { + status?: unknown +} + +async function readBody(req: Request): Promise { + try { + const body = await req.json() + return typeof body === "object" && body !== null ? (body as UpdateRegistryBody) : {} + } catch { + return {} + } +} + +export async function GET(_req: Request, context: RouteContext) { + if (!hasValidAdminToken(_req.headers)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) + } + + const { id } = await context.params + const agent = getRegisteredAgent(decodeURIComponent(id)) + + if (!agent) { + return NextResponse.json({ ok: false, error: "agent not found" }, { status: 404 }) + } + + return NextResponse.json({ ok: true, agent }, { headers: { "Cache-Control": "no-store" } }) +} + +export async function PATCH(req: Request, context: RouteContext) { + if (!hasValidAdminToken(req.headers)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) + } + + const { id } = await context.params + const agentId = decodeURIComponent(id) + const body = await readBody(req) + + if (body.status !== "offline") { + return NextResponse.json({ ok: false, error: 'status must be "offline"' }, { status: 400 }) + } + + try { + const agent = updateAgentStatus(agentId, "offline") + return NextResponse.json({ ok: true, agent }, { headers: { "Cache-Control": "no-store" } }) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : "failed updating agent" }, + { status: 404 }, + ) + } +} + +export async function DELETE(_req: Request, context: RouteContext) { + if (!hasValidAdminToken(_req.headers)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) + } + + const { id } = await context.params + const agent = deregisterAgent(decodeURIComponent(id)) + + if (!agent) { + return NextResponse.json({ ok: false, error: "agent not found" }, { status: 404 }) + } + + return NextResponse.json({ ok: true, agent }, { headers: { "Cache-Control": "no-store" } }) +} diff --git a/app/api/registry/route.ts b/app/api/registry/route.ts index 2cd217e6..fd2a70f8 100644 --- a/app/api/registry/route.ts +++ b/app/api/registry/route.ts @@ -1,10 +1,17 @@ import { NextResponse } from "next/server" import { listRegisteredAgents } from "@/lib/agent-registry" -import { getAgentHealthSummary } from "@/lib/agents/agent-error-store" +import { hasValidAdminToken } from "@/lib/admin-token" +import { getAgentHealth } from "@/lib/agents/agent-health-store" +import { getAgentXP } from "@/lib/gamification/xp" +import { getReputation } from "@/lib/reputation/reputation-store" export const dynamic = "force-dynamic" export async function GET(req: Request) { + if (!hasValidAdminToken(req.headers)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }) + } + const url = new URL(req.url) const agents = listRegisteredAgents({ capability: url.searchParams.get("capability") ?? undefined, @@ -17,8 +24,23 @@ export async function GET(req: Request) { degraded: health.degraded, } }) + const items = agents.map((agent) => { + const health = getAgentHealth(agent.agentId) + const xp = getAgentXP(agent.agentId) + const reputation = getReputation(agent.agentId) + + return { + ...agent, + name: agent.name ?? agent.agentId, + status: health?.runtimeStatus ?? agent.status, + xp: xp.xp, + level: xp.level, + tasksCompleted: reputation.metrics.tasksCompleted, + lastSeen: health?.lastHeartbeat ?? agent.updatedAt, + } + }) return NextResponse.json( - { ok: true, agents }, + { ok: true, agents: items }, { headers: { "Cache-Control": "no-store" } }, ) } diff --git a/app/forbidden.tsx b/app/forbidden.tsx new file mode 100644 index 00000000..a679d597 --- /dev/null +++ b/app/forbidden.tsx @@ -0,0 +1,13 @@ +export default function ForbiddenPage() { + return ( +
+
+

403

+

Admin Access Required

+

+ The provided admin token did not match the server configuration. +

+
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx index 576ff696..f9dbdd10 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next' import { Press_Start_2P, VT323 } from 'next/font/google' import { Analytics } from '@vercel/analytics/next' +import { NotificationProvider } from '@/components/notifications/notification-provider' import { WalletProvider } from '@/components/wallet/wallet-provider' import { MockBanner } from '@/components/mock-banner' import { PwaRegister } from '@/components/pwa-register' @@ -55,9 +56,11 @@ export default function RootLayout({ - - {children} - + + + {children} + + ) { + const [agents, setAgents] = useState([]) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [selected, setSelected] = useState([]) + const [loading, setLoading] = useState(true) + const [busyId, setBusyId] = useState(null) + const [bulkBusy, setBulkBusy] = useState(false) + + const adminHeaders = useMemo( + () => ({ + "x-admin-token": adminToken, + }), + [adminToken], + ) + + async function loadAgents(): Promise { + const response = await fetch("/api/registry", { + cache: "no-store", + headers: adminHeaders, + }) + const data = await response.json() as { agents?: RegistryAdminAgent[] } + setAgents(data.agents ?? []) + setLoading(false) + } + + useEffect(() => { + void loadAgents() + }, []) + + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase() + return agents.filter((agent) => { + const matchesSearch = + needle.length === 0 || + agent.name.toLowerCase().includes(needle) || + agent.agentId.toLowerCase().includes(needle) || + agent.status.toLowerCase().includes(needle) + const matchesStatus = statusFilter === "all" || agent.status === statusFilter + return matchesSearch && matchesStatus + }) + }, [agents, search, statusFilter]) + + const visibleIds = filtered.map((agent) => agent.agentId) + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.includes(id)) + + const toggleSelection = (agentId: string) => { + setSelected((current) => + current.includes(agentId) ? current.filter((id) => id !== agentId) : [...current, agentId], + ) + } + + const toggleVisibleSelection = () => { + setSelected((current) => { + if (allVisibleSelected) { + return current.filter((id) => !visibleIds.includes(id)) + } + return Array.from(new Set([...current, ...visibleIds])) + }) + } + + const forceOffline = async (agentId: string) => { + setBusyId(agentId) + await fetch(`/api/registry/${encodeURIComponent(agentId)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...adminHeaders, + }, + body: JSON.stringify({ status: "offline" }), + }) + await loadAgents() + setBusyId(null) + } + + const deregister = async (agentId: string) => { + setBusyId(agentId) + await fetch(`/api/registry/${encodeURIComponent(agentId)}`, { + method: "DELETE", + headers: adminHeaders, + }) + setSelected((current) => current.filter((id) => id !== agentId)) + await loadAgents() + setBusyId(null) + } + + const bulkDeregister = async () => { + setBulkBusy(true) + const ids = [...selected] + await Promise.all( + ids.map((agentId) => + fetch(`/api/registry/${encodeURIComponent(agentId)}`, { + method: "DELETE", + headers: adminHeaders, + }), + ), + ) + setSelected([]) + await loadAgents() + setBulkBusy(false) + } + + let tableContent: ReactNode + if (loading) { + tableContent = ( + + + Loading agents... + + + ) + } else if (filtered.length === 0) { + tableContent = ( + + + No agents match the current filters. + + + ) + } else { + tableContent = filtered.map((agent) => ( + + + toggleSelection(agent.agentId)} + className="h-4 w-4 accent-cyan-400" + /> + + + + {agent.agentId} + + + {agent.name} + + + {agent.status} + + + {agent.xp.toLocaleString("en-US")} + {agent.tasksCompleted.toLocaleString("en-US")} + {formatDateTime(agent.registeredAt)} + {formatDateTime(agent.lastSeen)} + +
+ + +
+ + + )) + } + + return ( +
+
+ + Back to admin + + +
+
+
+

Operations

+

Registry Agents

+

+ Search, inspect, force offline, and deregister agents without leaving the admin console. +

+
+ +
+ +
+ setSearch(event.target.value)} + placeholder="Search by agent name, id, or status" + className="h-12 rounded-2xl border border-slate-700 bg-slate-900/90 px-4 font-mono text-sm text-slate-100 outline-none transition focus:border-cyan-400/50" + /> + +
+
+ +
+
+

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

+ +
+ +
+ + + + {["", "Agent ID", "Name", "Status", "XP", "Tasks", "Registered", "Last seen", "Actions"].map((label) => ( + + ))} + + + {tableContent} +
+ {label} +
+
+
+
+
+ ) +} diff --git a/components/notifications/notification-provider.tsx b/components/notifications/notification-provider.tsx new file mode 100644 index 00000000..47b250e1 --- /dev/null +++ b/components/notifications/notification-provider.tsx @@ -0,0 +1,164 @@ +"use client" + +import { + useCallback, + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react" + +import { + createToastQueueState, + dismissToast, + enqueueToast, + TOAST_AUTO_DISMISS_MS, + type ToastNotification, + type ToastQueueState, +} from "@/lib/notifications/toast-queue" +import type { PublishedSystemEvent } from "@/lib/events/system-events" + +interface NotificationsContextValue { + push: (notification: Omit & { id?: string }) => string + dismiss: (id: string) => void +} + +const NotificationsContext = createContext(null) + +function toneClasses(tone: ToastNotification["tone"]): string { + if (tone === "success") { + return "border-emerald-400/40 bg-emerald-500/10 text-emerald-100 shadow-[0_18px_50px_rgba(16,185,129,0.15)]" + } + return "border-cyan-400/30 bg-slate-950/95 text-slate-100 shadow-[0_18px_50px_rgba(2,8,23,0.35)]" +} + +function isLevelUpEvent(event: PublishedSystemEvent): event is PublishedSystemEvent & { + type: "agent.xp" + leveledUp: boolean + level: number +} { + return event.type === "agent.xp" && Boolean((event as { leveledUp?: boolean }).leveledUp) +} + +function isQuestCompletedEvent(event: PublishedSystemEvent): event is PublishedSystemEvent & { + type: "quest.completed" + questTitle?: string + reward?: { xp?: number } +} { + return event.type === "quest.completed" +} + +export function NotificationProvider({ children }: Readonly<{ children: ReactNode }>) { + const [state, setState] = useState(() => createToastQueueState()) + const timersRef = useRef>>(new Map()) + const seenEventsRef = useRef>(new Set()) + + const dismiss = useCallback((id: string) => { + const timer = timersRef.current.get(id) + if (timer !== undefined) { + window.clearTimeout(timer) + timersRef.current.delete(id) + } + setState((current) => dismissToast(current, id)) + }, []) + + const push = useCallback((notification: Omit & { id?: string }) => { + const id = notification.id ?? crypto.randomUUID() + const next: ToastNotification = { ...notification, id } + setState((current) => enqueueToast(current, next)) + return id + }, []) + + const contextValue = useMemo( + () => ({ + push, + dismiss, + }), + [push, dismiss], + ) + + useEffect(() => { + for (const toast of state.visible) { + if (timersRef.current.has(toast.id)) continue + const timer = window.setTimeout(() => { + dismiss(toast.id) + }, TOAST_AUTO_DISMISS_MS) + timersRef.current.set(toast.id, timer) + } + }, [state.visible]) + + useEffect(() => { + const source = new EventSource("/api/events") + + const handleEvent = (message: MessageEvent) => { + try { + const event = JSON.parse(message.data) as PublishedSystemEvent + if (!event.id || seenEventsRef.current.has(event.id)) return + seenEventsRef.current.add(event.id) + + if (isLevelUpEvent(event)) { + push({ + id: `level-up:${event.id}`, + title: "Level up!", + message: `You're now Level ${event.level}`, + tone: "success", + }) + return + } + + if (isQuestCompletedEvent(event)) { + push({ + id: `quest-complete:${event.id}`, + title: "Quest complete", + message: `${event.questTitle ?? "Quest completed"} (+${event.reward?.xp ?? 0} XP)`, + tone: "success", + }) + } + } catch { + // Ignore malformed SSE payloads. + } + } + + source.addEventListener("agent.xp", handleEvent as EventListener) + source.addEventListener("quest.completed", handleEvent as EventListener) + + return () => { + source.close() + for (const timer of timersRef.current.values()) { + window.clearTimeout(timer) + } + timersRef.current.clear() + } + }, []) + + return ( + + {children} +
+ {state.visible.map((toast) => ( + +
+ {toast.title} +
+
{toast.message}
+
+ ))} +
+
+ ) +} + +export function useNotifications(): NotificationsContextValue { + const value = useContext(NotificationsContext) + if (!value) { + throw new Error("useNotifications must be used within NotificationProvider") + } + return value +} diff --git a/lib/admin-token.ts b/lib/admin-token.ts new file mode 100644 index 00000000..3175297f --- /dev/null +++ b/lib/admin-token.ts @@ -0,0 +1,28 @@ +const ADMIN_TOKEN_HEADER_NAMES = [ + "ADMIN_TOKEN", + "admin_token", + "x-admin-token", + "admin-token", +] as const + +type HeaderStore = Headers | Pick + +export function getProvidedAdminToken(headers: HeaderStore): string | null { + for (const headerName of ADMIN_TOKEN_HEADER_NAMES) { + const value = headers.get(headerName) + if (value) { + return value + } + } + + return null +} + +export function hasValidAdminToken(headers: HeaderStore): boolean { + const expectedToken = process.env.ADMIN_TOKEN + if (!expectedToken) { + return false + } + + return getProvidedAdminToken(headers) === expectedToken +} diff --git a/lib/agent-registry.ts b/lib/agent-registry.ts index a2d12547..2e6a50f2 100644 --- a/lib/agent-registry.ts +++ b/lib/agent-registry.ts @@ -14,6 +14,7 @@ export interface SkillRegistration { export interface AgentCapabilityManifest { agentId: string + name?: string model: string district: DistrictId capabilities: string[] @@ -232,6 +233,7 @@ export function registerAgent(input: unknown): AgentCapabilityManifest { const agent: AgentCapabilityManifest = { agentId, + ...(typeof input.name === "string" && input.name.trim().length > 0 ? { name: input.name.trim() } : {}), model: normalizeString(input.model, "model"), district: normalizeDistrict(input.district), capabilities: normalizeCapabilities(input.capabilities), @@ -249,6 +251,23 @@ export function registerAgent(input: unknown): AgentCapabilityManifest { return agent } +export function updateAgentStatus(agentId: string, status: AgentStatus): AgentCapabilityManifest { + const existing = registry.agents.get(agentId) + if (!existing) { + throw new Error("agent not found") + } + + const updated: AgentCapabilityManifest = { + ...existing, + status, + updatedAt: new Date().toISOString(), + } + + registry.agents.set(agentId, updated) + emitRegistryChange("updated", updated) + return updated +} + export function updateAgentCapabilities(agentId: string, input: unknown): AgentCapabilityManifest { const existing = registry.agents.get(agentId) if (!existing) { diff --git a/lib/agent-runtime/agent.ts b/lib/agent-runtime/agent.ts index 17b1a7a8..27ecb076 100644 --- a/lib/agent-runtime/agent.ts +++ b/lib/agent-runtime/agent.ts @@ -3,6 +3,7 @@ import { getAgentHealthSummary, recordAgentExecutionError, recordAgentExecutionS 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 { recordTaskCompletion } from "@/lib/reputation/reputation-store" import type { AgentStatus } from "@/lib/types" const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000 @@ -144,6 +145,7 @@ export class Agent implements AgentRuntimeContext { this.status = "running" this.recordHeartbeat() writeTaskRecord(this.id, { task, result, status: "completed", updatedAt: completedAt }) + recordTaskCompletion(this.id) publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.summary, durationMs } }) return result } catch (error) { diff --git a/lib/agents/badges.ts b/lib/agents/badges.ts new file mode 100644 index 00000000..07a1870b --- /dev/null +++ b/lib/agents/badges.ts @@ -0,0 +1,206 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { publishSystemEvent } from "@/lib/events/system-events" +import { listRegisteredAgents, getRegisteredAgent } from "@/lib/agent-registry" +import { getAgentXP } from "@/lib/gamification/xp" +import { countClaimedQuests } from "@/lib/gamification/quest-completions" +import { getReputation } from "@/lib/reputation/reputation-store" + +export type BadgeType = "first_task" | "quest_master" | "level_10" | "veteran" | "top_earner" + +export interface Badge { + type: BadgeType + name: string + description: string + icon: string + awardedAt: string +} + +const BADGES_DIR = join(process.cwd(), ".data", "badges") +const VETERAN_DAYS = 30 + +export const BADGE_DEFINITIONS: Record> = { + first_task: { + type: "first_task", + name: "First Task", + description: "Completed the first tracked task.", + icon: "check-circle", + }, + quest_master: { + type: "quest_master", + name: "Quest Master", + description: "Completed 5 quests.", + icon: "scroll", + }, + level_10: { + type: "level_10", + name: "Level 10", + description: "Reached level 10.", + icon: "sparkles", + }, + veteran: { + type: "veteran", + name: "Veteran", + description: "Stayed registered for 30 days.", + icon: "shield", + }, + top_earner: { + type: "top_earner", + name: "Top Earner", + description: "Reached the top 10% of the XP leaderboard.", + icon: "trophy", + }, +} + +function ensureBadgesDir(): void { + if (!existsSync(BADGES_DIR)) { + mkdirSync(BADGES_DIR, { recursive: true }) + } +} + +function agentBadgesPath(agentId: string): string { + return join(BADGES_DIR, `${encodeURIComponent(agentId)}.json`) +} + +function readAgentBadges(agentId: string): Badge[] { + ensureBadgesDir() + const filePath = agentBadgesPath(agentId) + if (!existsSync(filePath)) { + return [] + } + + try { + const raw = readFileSync(filePath, "utf8").trim() + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? (parsed as Badge[]) : [] + } catch { + return [] + } +} + +function writeAgentBadges(agentId: string, badges: Badge[]): void { + ensureBadgesDir() + writeFileSync(agentBadgesPath(agentId), `${JSON.stringify(badges, null, 2)}\n`, "utf8") +} + +function hasBadge(badges: Badge[], type: BadgeType): boolean { + return badges.some((badge) => badge.type === type) +} + +function isVeteran(agentId: string, nowMs: number): boolean { + const agent = getRegisteredAgent(agentId) + if (!agent) return false + const registeredMs = new Date(agent.registeredAt).getTime() + if (!Number.isFinite(registeredMs)) return false + return nowMs - registeredMs >= VETERAN_DAYS * 24 * 60 * 60 * 1000 +} + +function isTopEarner(agentId: string): boolean { + const agents = listRegisteredAgents() + if (agents.length === 0) return false + + const ranked = agents + .map((agent) => ({ agentId: agent.agentId, xp: getAgentXP(agent.agentId).xp })) + .sort((a, b) => b.xp - a.xp || a.agentId.localeCompare(b.agentId)) + + const cutoff = Math.max(1, Math.ceil(ranked.length * 0.1)) + return ranked.slice(0, cutoff).some((entry) => entry.agentId === agentId) +} + +function shouldAward(type: BadgeType, agentId: string, nowMs: number): boolean { + if (type === "first_task") { + return getReputation(agentId).metrics.tasksCompleted >= 1 + } + if (type === "quest_master") { + return countClaimedQuests(agentId) >= 5 + } + if (type === "level_10") { + return getAgentXP(agentId).level >= 10 + } + if (type === "veteran") { + return isVeteran(agentId, nowMs) + } + return isTopEarner(agentId) +} + +export function getAgentBadges(agentId: string): Badge[] { + return readAgentBadges(agentId) +} + +export function listBadgeCatalog(): Array> { + return Object.values(BADGE_DEFINITIONS) +} + +export function getBadgeEarnedByCounts(): Record { + ensureBadgesDir() + const counts = { + first_task: 0, + quest_master: 0, + level_10: 0, + veteran: 0, + top_earner: 0, + } satisfies Record + + for (const entry of readdirSync(BADGES_DIR)) { + if (!entry.endsWith(".json")) continue + const raw = readFileSync(join(BADGES_DIR, entry), "utf8").trim() + if (!raw) continue + const badges = JSON.parse(raw) as Badge[] + const seen = new Set() + for (const badge of badges) { + if (seen.has(badge.type)) continue + seen.add(badge.type) + counts[badge.type] += 1 + } + } + + return counts +} + +export function checkAndAwardBadges(agentId: string, now = new Date()): Badge[] { + const cleanId = agentId.trim() + if (!cleanId) return [] + + const existing = readAgentBadges(cleanId) + const next = [...existing] + const awarded: Badge[] = [] + const nowIso = now.toISOString() + const nowMs = now.getTime() + + for (const type of Object.keys(BADGE_DEFINITIONS) as BadgeType[]) { + if (hasBadge(next, type)) continue + if (!shouldAward(type, cleanId, nowMs)) continue + + const badge: Badge = { + ...BADGE_DEFINITIONS[type], + awardedAt: nowIso, + } + next.push(badge) + awarded.push(badge) + publishSystemEvent({ + type: "badge.unlocked", + agentId: cleanId, + badge: { + id: badge.type, + name: badge.name, + }, + }) + } + + if (awarded.length > 0) { + writeAgentBadges(cleanId, next) + } + + return awarded +} + +export function resetBadgeStoreForTests(): void { + ensureBadgesDir() + for (const entry of readdirSync(BADGES_DIR)) { + if (entry.endsWith(".json")) { + rmSync(join(BADGES_DIR, entry), { force: true }) + } + } +} diff --git a/lib/events/system-events.ts b/lib/events/system-events.ts index 7fb661d7..a3c4674b 100644 --- a/lib/events/system-events.ts +++ b/lib/events/system-events.ts @@ -41,16 +41,10 @@ export type SystemEvent = | (BaseEvent & { type: "task.started"; agentId: string; task: AgentTask }) | (BaseEvent & { type: "task.completed"; agentId: string; taskId: string; result: TaskResult; skillId?: string }) | (BaseEvent & { type: "payment.received"; agentId: string; receipt: X402Receipt }) - | (BaseEvent & { - type: "quest.completed" - agentId: string - questId?: string - quest?: QuestCompletionSummary - reward?: QuestCompletionReward - }) + | (BaseEvent & { type: "quest.completed"; agentId: string; questId?: string; questTitle?: string; quest?: unknown; reward?: { xp?: number } }) | (BaseEvent & { type: "quest.expired"; agentId: string; questId: string; completedSubtasks: number; totalSubtasks: number }) | (BaseEvent & { type: "quest.unlocked"; agentId: string; questId: string }) - | (BaseEvent & { type: "agent.xp"; agentId: string; xp: number; totalXp?: number; level: number; xpToNext?: number; reason?: string }) + | (BaseEvent & { type: "agent.xp"; agentId: string; xp: number; totalXp?: number; level: number; previousLevel?: number; xpToNext?: number; reason?: string; leveledUp?: boolean }) | (BaseEvent & { type: "badge.unlocked"; agentId: string; badge: Badge }) | (BaseEvent & { type: "district.unlocked"; districtId?: import("@/lib/types").DistrictId; district?: import("@/lib/types").District }) | (BaseEvent & { diff --git a/lib/gamification/quest-completions.ts b/lib/gamification/quest-completions.ts index 5b03eaac..853ed5d0 100644 --- a/lib/gamification/quest-completions.ts +++ b/lib/gamification/quest-completions.ts @@ -34,6 +34,17 @@ export function markQuestClaimed(questId: string, actorId: string): void { store().add(key(questId, actorId)) } +export function countClaimedQuests(actorId: string): number { + const prefix = `::${actorId}` + let count = 0 + for (const entry of store()) { + if (entry.endsWith(prefix)) { + count += 1 + } + } + return count +} + /** Test helper — clears all recorded completions. */ export function resetQuestCompletions(): void { store().clear() diff --git a/lib/gamification/quests.ts b/lib/gamification/quests.ts index 648a7d1a..3c106a5a 100644 --- a/lib/gamification/quests.ts +++ b/lib/gamification/quests.ts @@ -1,4 +1,8 @@ import { addNotification } from "@/lib/notifications/notification-store" +import { checkAndAwardBadges } from "@/lib/agents/badges" +import { publishSystemEvent } from "@/lib/events/system-events" +import { XP_AWARDS } from "@/lib/gamification/constants" +import { awardXP, type XPAwardResult } from "@/lib/gamification/xp" import { invalidateLeaderboardCache } from "./leaderboard-cache" import { listStoredQuests } from "./quest-store" @@ -35,6 +39,11 @@ export interface Quest { status?: "in_progress" | "completed" } +export interface QuestCompletionResult { + quest: Quest + xpAward: XPAwardResult +} + interface QuestDefinition { id: string type: QuestType @@ -363,6 +372,23 @@ export function getQuestById(id: string, now: Date = new Date()): Quest | null { return getQuests(now).find((quest) => quest.id === id) ?? null } +export function completeQuest(agentId: string, quest: Quest): QuestCompletionResult { + const xpAmount = Math.max(0, quest.reward.xp || XP_AWARDS.QUEST_COMPLETED) + const xpAward = awardXP(agentId, xpAmount, "quest.completed") + checkAndAwardBadges(agentId) + + publishSystemEvent({ + type: "quest.completed", + agentId, + questId: quest.id, + questTitle: quest.title, + quest, + reward: { xp: xpAward.awardedXp }, + }) + + return { quest, xpAward } +} + export function recordCompletedQuestNotifications(agentId: string, quests: Quest[]): void { const cleanId = agentId.trim() if (!cleanId) return diff --git a/lib/gamification/xp.ts b/lib/gamification/xp.ts index 775d70f2..5e592177 100644 --- a/lib/gamification/xp.ts +++ b/lib/gamification/xp.ts @@ -1,5 +1,6 @@ import type { MoltbotAgent, Skill } from "@/lib/types" import { publishSystemEvent } from "@/lib/events/system-events" +import { checkAndAwardBadges } from "@/lib/agents/badges" import { AGENT_LEVEL_CAP, FAST_TASK_MAX_DURATION_MS, @@ -104,17 +105,12 @@ export function awardXP(agentId: string, amount: number, reason: XPAwardReason): xp: awardedXp, totalXp: xp, level: next.level, + previousLevel: previous.level, xpToNext: levelState.xpToNext, reason, + leveledUp: levelState.leveledUp, }) - - if (reason === "quest.completed") { - publishSystemEvent({ - type: "quest.completed", - agentId, - reward: { xp: awardedXp }, - }) - } + checkAndAwardBadges(agentId) return result } diff --git a/lib/notifications/toast-queue.ts b/lib/notifications/toast-queue.ts new file mode 100644 index 00000000..f6f41468 --- /dev/null +++ b/lib/notifications/toast-queue.ts @@ -0,0 +1,55 @@ +export const MAX_VISIBLE_NOTIFICATIONS = 3 +export const TOAST_AUTO_DISMISS_MS = 4_000 + +export interface ToastNotification { + id: string + title: string + message: string + tone?: "info" | "success" +} + +export interface ToastQueueState { + visible: ToastNotification[] + queued: ToastNotification[] +} + +export function createToastQueueState(): ToastQueueState { + return { + visible: [], + queued: [], + } +} + +export function enqueueToast(state: ToastQueueState, notification: ToastNotification): ToastQueueState { + if (state.visible.length < MAX_VISIBLE_NOTIFICATIONS) { + return { + visible: [...state.visible, notification], + queued: state.queued, + } + } + + return { + visible: state.visible, + queued: [...state.queued, notification], + } +} + +export function dismissToast(state: ToastQueueState, id: string): ToastQueueState { + const nextVisible = state.visible.filter((notification) => notification.id !== id) + if (nextVisible.length === state.visible.length) { + return state + } + + if (state.queued.length === 0) { + return { + visible: nextVisible, + queued: [], + } + } + + const [nextToast, ...remainingQueue] = state.queued + return { + visible: [...nextVisible, nextToast], + queued: remainingQueue, + } +} diff --git a/lib/reputation/reputation-store.ts b/lib/reputation/reputation-store.ts index 44cda569..51bad33e 100644 --- a/lib/reputation/reputation-store.ts +++ b/lib/reputation/reputation-store.ts @@ -1,4 +1,5 @@ import type { ReputationAction } from '@/lib/protocols/track8004' +import { checkAndAwardBadges } from '@/lib/agents/badges' import { addNotification } from '@/lib/notifications/notification-store' import { getAgentUptime } from '@/lib/agents/agent-uptime-store' @@ -47,7 +48,7 @@ const globalDb = globalThis as typeof globalThis & { function defaultMetrics(): ReputationMetrics { return { - tasksCompleted: 500, + tasksCompleted: 0, x402RevenueXlm: 0, uptimeDaysWithoutErrors: 0, badges: [], @@ -143,9 +144,19 @@ export function upsertReputationMetrics(actorId: string, metrics: Partial ({ + agentId, + model: "test", + district: "defense" as const, + capabilities: [], + status: "active" as const, + endpoint: "http://example.test", + x402: { accepts: false }, +}) + +beforeEach(() => { + resetAgentRegistryForTests() + resetBadgeStoreForTests() + resetAgentXpDb() + resetQuestCompletions() + resetReputationStoreForTests() +}) + +describe("checkAndAwardBadges", () => { + it("awards first_task only once", () => { + registerAgent(makeAgent("agent-first-task")) + upsertReputationMetrics("agent-first-task", { tasksCompleted: 1 }) + awardXP("agent-first-task", 10, "task.completed") + + expect(checkAndAwardBadges("agent-first-task")).toHaveLength(1) + expect(checkAndAwardBadges("agent-first-task")).toHaveLength(0) + expect(getAgentBadges("agent-first-task")).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "first_task" })]), + ) + }) + + it("awards first_task automatically when a task completion is recorded", () => { + registerAgent(makeAgent("agent-auto-first-task")) + + recordTaskCompletion("agent-auto-first-task") + + expect(getAgentBadges("agent-auto-first-task")).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "first_task" })]), + ) + }) + + it("does not award first_task from quest XP alone", () => { + registerAgent(makeAgent("agent-quest-xp")) + awardXP("agent-quest-xp", 50, "quest.completed") + + expect(checkAndAwardBadges("agent-quest-xp")).toEqual([]) + expect(getAgentBadges("agent-quest-xp")).toEqual([]) + }) + + it("awards quest_master after five quests", () => { + registerAgent(makeAgent("agent-quest-master")) + for (let i = 0; i < 5; i += 1) { + markQuestClaimed(`quest-${i}`, "agent-quest-master") + } + + const awarded = checkAndAwardBadges("agent-quest-master") + expect(awarded).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "quest_master" })]), + ) + }) + + it("awards quest_master automatically when the fifth quest is completed", () => { + registerAgent(makeAgent("agent-auto-quest-master")) + const quest = getQuestById("daily-complete-5-tasks", new Date("2026-07-16T00:00:00.000Z")) + expect(quest).not.toBeNull() + + for (let i = 0; i < 4; i += 1) { + markQuestClaimed(`quest-${i}`, "agent-auto-quest-master") + } + + markQuestClaimed("quest-4", "agent-auto-quest-master") + completeQuest("agent-auto-quest-master", quest!) + + expect(getAgentBadges("agent-auto-quest-master")).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "quest_master" })]), + ) + }) + + it("awards level_10 when the XP threshold is crossed", () => { + registerAgent(makeAgent("agent-leveler")) + awardXP("agent-leveler", 4_000, "task.completed") + + const awarded = checkAndAwardBadges("agent-leveler") + expect(awarded).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "level_10" })]), + ) + }) + + it("awards veteran after 30 days since registration", () => { + registerAgent(makeAgent("agent-veteran")) + const agent = getRegisteredAgent("agent-veteran") + agent!.registeredAt = "2026-01-01T00:00:00.000Z" + + const awarded = checkAndAwardBadges("agent-veteran", new Date("2026-02-15T00:00:00.000Z")) + expect(awarded).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "veteran" })]), + ) + }) + + it("awards top_earner to agents in the top 10 percent by XP", () => { + for (let i = 0; i < 10; i += 1) { + const agentId = `agent-top-${i}` + registerAgent(makeAgent(agentId)) + awardXP(agentId, i === 9 ? 1_000 : 10 * i, "task.completed") + } + + const awarded = checkAndAwardBadges("agent-top-9") + expect(awarded).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "top_earner" })]), + ) + + expect(checkAndAwardBadges("agent-top-0")).toEqual([]) + }) +}) diff --git a/tests/lib/notifications/toast-queue.test.ts b/tests/lib/notifications/toast-queue.test.ts new file mode 100644 index 00000000..8f59904b --- /dev/null +++ b/tests/lib/notifications/toast-queue.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" + +import { createToastQueueState, dismissToast, enqueueToast } from "@/lib/notifications/toast-queue" + +describe("toast-queue", () => { + it("caps visible toasts at 3 and queues the rest", () => { + let state = createToastQueueState() + + for (let i = 1; i <= 5; i += 1) { + state = enqueueToast(state, { + id: `toast-${i}`, + title: `Toast ${i}`, + message: `Message ${i}`, + }) + } + + expect(state.visible.map((toast) => toast.id)).toEqual(["toast-1", "toast-2", "toast-3"]) + expect(state.queued.map((toast) => toast.id)).toEqual(["toast-4", "toast-5"]) + }) + + it("promotes queued toasts as visible ones are dismissed", () => { + let state = createToastQueueState() + + for (let i = 1; i <= 5; i += 1) { + state = enqueueToast(state, { + id: `toast-${i}`, + title: `Toast ${i}`, + message: `Message ${i}`, + }) + } + + state = dismissToast(state, "toast-2") + expect(state.visible.map((toast) => toast.id)).toEqual(["toast-1", "toast-3", "toast-4"]) + expect(state.queued.map((toast) => toast.id)).toEqual(["toast-5"]) + }) +})