From 63554b5425e94911e30c4f42b2c6980d45642c22 Mon Sep 17 00:00:00 2001 From: macbook Date: Wed, 1 Jul 2026 21:24:09 +0100 Subject: [PATCH] Implemented the three feature areas across the app --- __tests__/api/agents/badges.test.ts | 144 ++++------ __tests__/api/badges/catalog.test.ts | 141 +++------- __tests__/api/registry/registry.test.ts | 45 +++ app/admin/agents/page.tsx | 20 ++ app/agents/[id]/page.tsx | 33 ++- app/api/agents/[id]/badges/route.ts | 39 +-- app/api/badges/route.ts | 33 +-- app/api/quests/[id]/apply/route.ts | 5 +- app/api/registry/[id]/route.ts | 62 +++++ app/api/registry/route.ts | 20 +- app/forbidden.tsx | 13 + app/layout.tsx | 9 +- components/admin/admin-agents-client.tsx | 263 ++++++++++++++++++ .../notifications/notification-provider.tsx | 154 ++++++++++ lib/agent-registry.ts | 21 +- lib/agents/badges.ts | 205 ++++++++++++++ lib/events/system-events.ts | 4 +- lib/gamification/quest-completions.ts | 11 + lib/gamification/quests.ts | 24 ++ lib/gamification/xp.ts | 12 +- lib/notifications/toast-queue.ts | 55 ++++ lib/reputation/reputation-store.ts | 2 + tests/lib/agents/badges.test.ts | 87 ++++++ tests/lib/notifications/toast-queue.test.ts | 36 +++ 24 files changed, 1169 insertions(+), 269 deletions(-) create mode 100644 app/admin/agents/page.tsx create mode 100644 app/api/registry/[id]/route.ts create mode 100644 app/forbidden.tsx create mode 100644 components/admin/admin-agents-client.tsx create mode 100644 components/notifications/notification-provider.tsx create mode 100644 lib/agents/badges.ts create mode 100644 lib/notifications/toast-queue.ts create mode 100644 tests/lib/agents/badges.test.ts create mode 100644 tests/lib/notifications/toast-queue.test.ts 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..e7aa43f5 100644 --- a/__tests__/api/registry/registry.test.ts +++ b/__tests__/api/registry/registry.test.ts @@ -1,7 +1,10 @@ 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 agentA = { agentId: "alpha-1", @@ -25,8 +28,14 @@ const agentB = { beforeEach(() => { resetAgentRegistryForTests() + resetAgentXpDb() + resetReputationStoreForTests() }) +function agentContext(id: string) { + return { params: Promise.resolve({ id }) } +} + 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) })) @@ -37,6 +46,9 @@ describe("GET /api/registry", () => { 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 () => { @@ -71,4 +83,37 @@ describe("GET /api/registry", () => { 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: { "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" }), + 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")) + expect((await list.json()).agents).toHaveLength(0) + }) }) diff --git a/app/admin/agents/page.tsx b/app/admin/agents/page.tsx new file mode 100644 index 00000000..ecbabd4e --- /dev/null +++ b/app/admin/agents/page.tsx @@ -0,0 +1,20 @@ +import { headers } from "next/headers" +import { forbidden } from "next/navigation" + +import { AdminAgentsClient } from "@/components/admin/admin-agents-client" + +export default async function AdminAgentsPage() { + const headerStore = await headers() + const providedToken = + headerStore.get("ADMIN_TOKEN") ?? + headerStore.get("admin_token") ?? + headerStore.get("x-admin-token") ?? + headerStore.get("admin-token") + const expectedToken = process.env.ADMIN_TOKEN + + if (!expectedToken || providedToken !== expectedToken) { + forbidden() + } + + return +} diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index adb81840..633b90b3 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -84,11 +84,12 @@ export default async function AgentPage({ params }: AgentPageProps) { const { id } = await params // Data loading as required by acceptance criteria - const [metaRes, healthRes, repRes, questRes] = 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}/quest-recommendations`), { cache: 'no-store' }), + fetch(absoluteUrl(`/api/agents/${id}/badges`), { cache: 'no-store' }), ]) const localAgent = findAgentByLookup(id) @@ -122,15 +123,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: Array<{ type: string; name: string; description: string; icon: string; awardedAt: string }> = [] + 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) { @@ -245,7 +260,14 @@ export default async function AgentPage({ params }: AgentPageProps) {
{badges.length > 0 ? badges.map((badge, i) => ( -
+
+ + {badge.icon} + {badge.name}
)) : ( @@ -290,4 +312,3 @@ export default async function AgentPage({ params }: AgentPageProps) { ) } - 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/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 3795c088..202ea61d 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" @@ -76,6 +76,7 @@ export async function POST(req: Request, context: QuestApplyContext) { } markQuestClaimed(quest.id, actorId) + 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..d942e81c --- /dev/null +++ b/app/api/registry/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +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) { + 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) { + 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) { + 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 38a50e65..f0eaebb7 100644 --- a/app/api/registry/route.ts +++ b/app/api/registry/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from "next/server" import { listRegisteredAgents } from "@/lib/agent-registry" +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" @@ -8,8 +11,23 @@ export async function GET(req: Request) { const agents = listRegisteredAgents({ capability: url.searchParams.get("capability") ?? undefined, }) + 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 [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) + + async function loadAgents(): Promise { + const response = await fetch("/api/registry", { cache: "no-store" }) + 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" }, + body: JSON.stringify({ status: "offline" }), + }) + await loadAgents() + setBusyId(null) + } + + const deregister = async (agentId: string) => { + setBusyId(agentId) + await fetch(`/api/registry/${encodeURIComponent(agentId)}`, { method: "DELETE" }) + 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" }))) + setSelected([]) + await loadAgents() + setBulkBusy(false) + } + + 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) => ( + + ))} + + + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((agent) => ( + + + + + + + + + + + + )) + )} + +
+ {label} +
+ Loading agents... +
+ No agents match the current filters. +
+ 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)} +
+ + +
+
+
+
+
+
+ ) +} diff --git a/components/notifications/notification-provider.tsx b/components/notifications/notification-provider.tsx new file mode 100644 index 00000000..28dcda04 --- /dev/null +++ b/components/notifications/notification-provider.tsx @@ -0,0 +1,154 @@ +"use client" + +import { + createContext, + useContext, + useEffect, + 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 }: { children: ReactNode }) { + const [state, setState] = useState(() => createToastQueueState()) + const timersRef = useRef>>(new Map()) + const seenEventsRef = useRef>(new Set()) + + const dismiss = (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 = (notification: Omit & { id?: string }) => { + const id = notification.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const next: ToastNotification = { ...notification, id } + setState((current) => enqueueToast(current, next)) + return id + } + + 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/agent-registry.ts b/lib/agent-registry.ts index 71ce95b2..eb5998d1 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[] @@ -226,6 +227,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), @@ -243,6 +245,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) { @@ -276,4 +295,4 @@ export function deregisterAgent(agentId: string): AgentCapabilityManifest | null export function resetAgentRegistryForTests(): void { registry.agents.clear() -} \ No newline at end of file +} diff --git a/lib/agents/badges.ts b/lib/agents/badges.ts new file mode 100644 index 00000000..45ee5b0d --- /dev/null +++ b/lib/agents/badges.ts @@ -0,0 +1,205 @@ +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" + +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 getAgentXP(agentId).xp > 0 + } + 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 7274025d..09521231 100644 --- a/lib/events/system-events.ts +++ b/lib/events/system-events.ts @@ -29,10 +29,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?: unknown; reward?: unknown }) + | (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..603da2a0 100644 --- a/lib/gamification/quests.ts +++ b/lib/gamification/quests.ts @@ -1,4 +1,7 @@ import { addNotification } from "@/lib/notifications/notification-store" +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 +38,11 @@ export interface Quest { status?: "in_progress" | "completed" } +export interface QuestCompletionResult { + quest: Quest + xpAward: XPAwardResult +} + interface QuestDefinition { id: string type: QuestType @@ -363,6 +371,22 @@ 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") + + 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..1de10d7f 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' @@ -143,6 +144,7 @@ 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 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 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"]) + }) +})