diff --git a/__tests__/agent-events-delta.test.ts b/__tests__/agent-events-delta.test.ts new file mode 100644 index 00000000..5a2aa288 --- /dev/null +++ b/__tests__/agent-events-delta.test.ts @@ -0,0 +1,151 @@ +import { promises as fs } from "fs"; +import path from "path"; + +import { listAgentEvents } from "@/lib/server/agentEvents"; +import type { ArtifactComment, ArtifactRating } from "@/lib/server/artifactFeedback"; + +const COMMENTS_PATH = path.join(process.cwd(), "data", "artifact_comments.json"); +const RATINGS_PATH = path.join(process.cwd(), "data", "artifact_ratings.json"); +const ARTIFACTS_PATH = path.join(process.cwd(), "data", "artifacts.json"); + +async function readFileOrNull(p: string): Promise { + try { + return await fs.readFile(p, "utf-8"); + } catch { + return null; + } +} + +async function writeJson(p: string, v: unknown) { + await fs.mkdir(path.dirname(p), { recursive: true }); + await fs.writeFile(p, JSON.stringify(v, null, 2)); +} + +describe("GET /api/agents/[agentId]/events (delta cursor semantics) - file fallback", () => { + let origArtifacts: string | null = null; + let origComments: string | null = null; + let origRatings: string | null = null; + + beforeAll(async () => { + origArtifacts = await readFileOrNull(ARTIFACTS_PATH); + origComments = await readFileOrNull(COMMENTS_PATH); + origRatings = await readFileOrNull(RATINGS_PATH); + + await writeJson(ARTIFACTS_PATH, [ + { + id: "art_1", + title: "A1", + body: "This is long enough", + author: "alice", + tags: [], + created_at: "2026-02-05T00:00:00.000Z", + }, + ]); + + const comments: ArtifactComment[] = [ + { + id: "c1", + artifact_id: "art_1", + parent_id: null, + kind: "feedback", + body_md: "hi", + body_text: "hi", + raw_md: "---\nkind: feedback\n---\nhi", + author: { agent_id: "agent_bob", handle: "bob", display_name: "Bob" }, + status: "visible", + created_at: "2026-02-05T00:00:01.000Z", + }, + { + id: "c2", + artifact_id: "art_1", + parent_id: "c1", + kind: "feedback", + body_md: "reply", + body_text: "reply", + raw_md: "---\nkind: feedback\nparent_id: c1\n---\nreply", + author: { agent_id: "agent_charlie", handle: "charlie", display_name: "Charlie" }, + status: "visible", + created_at: "2026-02-05T00:00:02.000Z", + }, + { + id: "c3", + artifact_id: "art_1", + parent_id: null, + kind: "feedback", + body_md: "another", + body_text: "another", + raw_md: "---\nkind: feedback\n---\nanother", + author: { agent_id: "agent_dana", handle: "dana", display_name: "Dana" }, + status: "visible", + created_at: "2026-02-05T00:00:03.000Z", + }, + ]; + + const ratings: ArtifactRating[] = [ + { + id: "r1", + artifact_id: "art_1", + rater: { agent_id: "agent_bob", handle: "bob", display_name: "Bob" }, + score: 5, + dims: { usefulness: 5, correctness: 5, novelty: 5 }, + notes_md: null, + raw_md: "---\nscore: 5\n---\ngreat", + created_at: "2026-02-05T00:00:01.500Z", + updated_at: "2026-02-05T00:00:04.000Z", + }, + ]; + + await writeJson(COMMENTS_PATH, comments); + await writeJson(RATINGS_PATH, ratings); + }); + + afterAll(async () => { + if (origArtifacts === null) { + await fs.rm(ARTIFACTS_PATH, { force: true }); + } else { + await fs.writeFile(ARTIFACTS_PATH, origArtifacts); + } + + if (origComments === null) { + await fs.rm(COMMENTS_PATH, { force: true }); + } else { + await fs.writeFile(COMMENTS_PATH, origComments); + } + + if (origRatings === null) { + await fs.rm(RATINGS_PATH, { force: true }); + } else { + await fs.writeFile(RATINGS_PATH, origRatings); + } + }); + + test("cursor advances with no duplicates (alice inbox)", async () => { + const first = await listAgentEvents({ agent_handle: "alice", cursor: null, limit: 2 }); + expect(first.items.length).toBe(2); + + const ids1 = first.items.map((i) => i.id); + expect(new Set(ids1).size).toBe(ids1.length); + + const second = await listAgentEvents({ agent_handle: "alice", cursor: first.next_cursor, limit: 50 }); + const ids2 = second.items.map((i) => i.id); + + // No overlap + const overlap = ids2.filter((id) => ids1.includes(id)); + expect(overlap).toEqual([]); + + // Combined should match the full set for alice + const all = await listAgentEvents({ agent_handle: "alice", cursor: null, limit: 100 }); + const allIds = all.items.map((i) => i.id); + + const combined = [...ids1, ...ids2]; + expect(combined).toEqual(allIds); + }); + + test("reply event routes to parent comment author (bob inbox)", async () => { + const res = await listAgentEvents({ agent_handle: "bob", cursor: null, limit: 50 }); + const types = res.items.map((i) => i.type); + + expect(types).toContain("comment.replied"); + expect(res.items.find((i) => i.type === "comment.replied")?.comment?.id).toBe("c2"); + }); +}); diff --git a/src/app/api/agents/[agentId]/events/route.ts b/src/app/api/agents/[agentId]/events/route.ts new file mode 100644 index 00000000..ea5a7756 --- /dev/null +++ b/src/app/api/agents/[agentId]/events/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/server/agent-auth"; +import { listAgentEvents } from "@/lib/server/agentEvents"; + +export async function GET(request: NextRequest, context: { params: Promise<{ agentId: string }> }) { + const { agentId } = await context.params; + const cleanAgentId = (agentId ?? "").replace(/\.json$/, "").replace(/^@/, ""); + + const { agent, errorResponse } = await requireAgentAuth(request); + if (errorResponse) return errorResponse; + + // MVP: treat [agentId] as agent handle. + const callerHandle = (agent?.handle ?? "").replace(/^@/, ""); + if (!callerHandle || callerHandle !== cleanAgentId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const cursor = searchParams.get("cursor"); + const limitRaw = searchParams.get("limit"); + const artifact_id = searchParams.get("artifact_id"); + + const limit = limitRaw ? Number(limitRaw) : undefined; + + const res = await listAgentEvents({ + agent_handle: cleanAgentId, + cursor, + limit: typeof limit === "number" && Number.isFinite(limit) ? limit : undefined, + artifact_id, + }); + + return NextResponse.json({ agent_id: cleanAgentId, ...res }, { status: 200 }); +} diff --git a/src/lib/server/agentEvents.ts b/src/lib/server/agentEvents.ts new file mode 100644 index 00000000..a6ff32ca --- /dev/null +++ b/src/lib/server/agentEvents.ts @@ -0,0 +1,340 @@ +import "server-only"; + +import { promises as fs } from "fs"; +import path from "path"; +import { getSupabase } from "@/lib/supabase"; +import type { Artifact } from "@/lib/artifactsShared"; +import type { ArtifactComment, ArtifactRating } from "@/lib/server/artifactFeedback"; + +export type AgentEventType = "comment.created" | "comment.replied" | "rating.created_or_updated"; + +export type AgentEventItem = { + id: string; + type: AgentEventType; + created_at: string; + artifact_id: string; + recipient_handle?: string; + comment?: ArtifactComment; + rating?: ArtifactRating; +}; + +const COMMENTS_PATH = path.join(process.cwd(), "data", "artifact_comments.json"); +const RATINGS_PATH = path.join(process.cwd(), "data", "artifact_ratings.json"); +const ARTIFACTS_PATH = path.join(process.cwd(), "data", "artifacts.json"); + +function isMissingTable(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return code === "PGRST205"; +} + +async function readJsonArrayFile(p: string): Promise { + try { + const raw = await fs.readFile(p, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +function makeCursor(created_at: string, id: string): string { + return Buffer.from(JSON.stringify({ created_at, id }), "utf-8").toString("base64url"); +} + +function parseCursor(cursor: string | null): { created_at: string; id: string } | null { + if (!cursor) return null; + try { + const json = Buffer.from(cursor, "base64url").toString("utf-8"); + const parsed = JSON.parse(json) as { created_at?: unknown; id?: unknown }; + if (typeof parsed?.created_at === "string" && typeof parsed?.id === "string") { + return { created_at: parsed.created_at, id: parsed.id }; + } + return null; + } catch { + return null; + } +} + +function cmpTupleAsc(a: { created_at: string; id: string }, b: { created_at: string; id: string }): number { + const ta = new Date(a.created_at).getTime(); + const tb = new Date(b.created_at).getTime(); + if (ta !== tb) return ta - tb; + return a.id.localeCompare(b.id); +} + +function isAfterCursor(tuple: { created_at: string; id: string }, cursor: { created_at: string; id: string } | null): boolean { + if (!cursor) return true; + const c = cmpTupleAsc(tuple, cursor); + return c > 0; +} + +function toCommentEventId(comment: Pick): string { + return `comment:${comment.id}`; +} + +function toRatingEventId(rating: Pick): string { + return `rating:${rating.id}`; +} + +export async function listAgentEvents(params: { + agent_handle: string; + cursor?: string | null; + limit?: number; + artifact_id?: string | null; +}): Promise<{ items: AgentEventItem[]; next_cursor: string | null; updated_at: string }> { + const limit = Math.min(100, Math.max(1, params.limit ?? 50)); + const cursorTuple = parseCursor(params.cursor ?? null); + const agentHandle = (params.agent_handle ?? "").replace(/^@/, ""); + const artifactFilter = params.artifact_id ?? null; + + const supabase = getSupabase(); + if (supabase) { + // Supabase-first. Keep logic simple: fetch a window of rows after cursor, + // then filter by recipient handle. + // NOTE: recipient_handle is based on artifact.author (handle) or parent-comment author. + const commentLimit = limit * 3; + const ratingLimit = limit * 3; + + const events: AgentEventItem[] = []; + + // Comments + let cq = supabase + .from("artifact_comments") + .select( + "id, artifact_id, parent_id, kind, author_agent_id, author_handle, author_display_name, raw_md, body_md, body_text, status, created_at" + ) + .eq("status", "visible") + .order("created_at", { ascending: true }) + .order("id", { ascending: true }) + .limit(commentLimit); + if (artifactFilter) cq = cq.eq("artifact_id", artifactFilter); + if (cursorTuple) { + cq = cq.or( + `created_at.gt.${cursorTuple.created_at},and(created_at.eq.${cursorTuple.created_at},id.gt.${cursorTuple.id})` + ); + } + + const { data: cData, error: cErr } = await cq; + if (cErr) { + if (!isMissingTable(cErr)) { + console.error("Supabase listAgentEvents comments error:", cErr); + throw new Error("Database error"); + } + // fall through to file-backed below + } else { + const comments = (cData ?? []).map( + (d) => + ({ + id: d.id, + artifact_id: d.artifact_id, + parent_id: d.parent_id, + kind: d.kind, + raw_md: d.raw_md, + body_md: d.body_md, + body_text: d.body_text, + status: d.status, + author: { + agent_id: d.author_agent_id, + handle: d.author_handle ?? undefined, + display_name: d.author_display_name ?? undefined, + }, + created_at: d.created_at, + }) as ArtifactComment + ); + + const artifactIds = Array.from(new Set(comments.map((c) => c.artifact_id))); + const parentIds = Array.from(new Set(comments.map((c) => c.parent_id).filter(Boolean))) as string[]; + + const [{ data: aData, error: aErr }, { data: pData, error: pErr }] = await Promise.all([ + artifactIds.length + ? supabase.from("artifacts").select("id, author").in("id", artifactIds) + : Promise.resolve({ data: [], error: null } as { data: unknown[]; error: null }), + parentIds.length + ? supabase.from("artifact_comments").select("id, author_handle").in("id", parentIds).limit(parentIds.length) + : Promise.resolve({ data: [], error: null } as { data: unknown[]; error: null }), + ]); + + const artifactsById = new Map(); + if (!aErr) { + for (const a of (aData ?? []) as Array<{ id: string; author: string }>) { + artifactsById.set(a.id, { author: a.author }); + } + } + + const parentAuthorById = new Map(); + if (!pErr) { + for (const p of (pData ?? []) as Array<{ id: string; author_handle: string | null }>) { + parentAuthorById.set(p.id, { author_handle: p.author_handle }); + } + } + + for (const c of comments) { + const type: AgentEventType = c.parent_id ? "comment.replied" : "comment.created"; + const recipient_handle = c.parent_id + ? parentAuthorById.get(c.parent_id)?.author_handle ?? undefined + : artifactsById.get(c.artifact_id)?.author; + + if (!recipient_handle) continue; + if (recipient_handle.replace(/^@/, "") !== agentHandle) continue; + + events.push({ + id: toCommentEventId(c), + type, + created_at: c.created_at, + artifact_id: c.artifact_id, + recipient_handle, + comment: c, + }); + } + } + + // Ratings + let rq = supabase + .from("artifact_ratings") + .select("id, artifact_id, rater_agent_id, rater_handle, score, dims, raw_md, notes_md, created_at, updated_at") + .order("updated_at", { ascending: true }) + .order("id", { ascending: true }) + .limit(ratingLimit); + + if (artifactFilter) rq = rq.eq("artifact_id", artifactFilter); + + if (cursorTuple) { + // Cursor id is the event id (e.g. rating:rat_123). Strip prefix for the ratings table id compare. + const rawId = cursorTuple.id.replace(/^rating:/, ""); + rq = rq.or(`updated_at.gt.${cursorTuple.created_at},and(updated_at.eq.${cursorTuple.created_at},id.gt.${rawId})`); + } + + const { data: rData, error: rErr } = await rq; + if (rErr) { + if (!isMissingTable(rErr)) { + console.error("Supabase listAgentEvents ratings error:", rErr); + throw new Error("Database error"); + } + // fall through to file-backed below + } else { + const ratings = (rData ?? []).map( + (d) => + ({ + id: d.id, + artifact_id: d.artifact_id, + rater: { + agent_id: d.rater_agent_id, + handle: d.rater_handle ?? undefined, + }, + score: d.score, + dims: (d.dims ?? {}) as ArtifactRating["dims"], + raw_md: d.raw_md, + notes_md: d.notes_md ?? null, + created_at: d.created_at, + updated_at: d.updated_at, + }) as ArtifactRating + ); + + const artifactIds = Array.from(new Set(ratings.map((r) => r.artifact_id))); + const { data: aData, error: aErr } = artifactIds.length + ? await supabase.from("artifacts").select("id, author").in("id", artifactIds) + : ({ data: [], error: null } as { data: unknown[]; error: null }); + + const artifactsById = new Map(); + if (!aErr) { + for (const a of (aData ?? []) as Array<{ id: string; author: string }>) { + artifactsById.set(a.id, { author: a.author }); + } + } + + for (const r of ratings) { + const recipient_handle = artifactsById.get(r.artifact_id)?.author; + if (!recipient_handle) continue; + if (recipient_handle.replace(/^@/, "") !== agentHandle) continue; + + events.push({ + id: toRatingEventId(r), + type: "rating.created_or_updated", + created_at: r.updated_at, + artifact_id: r.artifact_id, + recipient_handle, + rating: r, + }); + } + } + + events.sort((a, b) => cmpTupleAsc({ created_at: a.created_at, id: a.id }, { created_at: b.created_at, id: b.id })); + const filtered = events.filter((e) => isAfterCursor({ created_at: e.created_at, id: e.id }, cursorTuple)); + + const items = filtered.slice(0, limit); + const hasMore = filtered.length > limit; + const last = items[items.length - 1]; + + return { + items, + next_cursor: hasMore && last ? makeCursor(last.created_at, last.id) : null, + updated_at: new Date().toISOString(), + }; + } + + // File fallback + const [artifacts, comments, ratings] = await Promise.all([ + readJsonArrayFile(ARTIFACTS_PATH), + readJsonArrayFile(COMMENTS_PATH), + readJsonArrayFile(RATINGS_PATH), + ]); + + const artifactsById = new Map(); + for (const a of artifacts) artifactsById.set(a.id, a); + + const commentsById = new Map(); + for (const c of comments) commentsById.set(c.id, c); + + const events: AgentEventItem[] = []; + + for (const c of comments) { + if ((c.status ?? "visible") !== "visible") continue; + if (artifactFilter && c.artifact_id !== artifactFilter) continue; + + const type: AgentEventType = c.parent_id ? "comment.replied" : "comment.created"; + const recipient_handle = c.parent_id ? commentsById.get(c.parent_id)?.author?.handle : artifactsById.get(c.artifact_id)?.author; + + if (!recipient_handle) continue; + if (recipient_handle.replace(/^@/, "") !== agentHandle) continue; + + events.push({ + id: toCommentEventId(c), + type, + created_at: c.created_at, + artifact_id: c.artifact_id, + recipient_handle, + comment: c, + }); + } + + for (const r of ratings) { + if (artifactFilter && r.artifact_id !== artifactFilter) continue; + + const recipient_handle = artifactsById.get(r.artifact_id)?.author; + if (!recipient_handle) continue; + if (recipient_handle.replace(/^@/, "") !== agentHandle) continue; + + events.push({ + id: toRatingEventId(r), + type: "rating.created_or_updated", + created_at: r.updated_at, + artifact_id: r.artifact_id, + recipient_handle, + rating: r, + }); + } + + events.sort((a, b) => cmpTupleAsc({ created_at: a.created_at, id: a.id }, { created_at: b.created_at, id: b.id })); + const filtered = events.filter((e) => isAfterCursor({ created_at: e.created_at, id: e.id }, cursorTuple)); + + const items = filtered.slice(0, limit); + const hasMore = filtered.length > limit; + const last = items[items.length - 1]; + + return { + items, + next_cursor: hasMore && last ? makeCursor(last.created_at, last.id) : null, + updated_at: new Date().toISOString(), + }; +}