import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { RunActivityRow } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { createRepos } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Promise }; process.env.WAKEUP_DRIVER = "memory"; process.env.SANDBOX_PROVIDER = "fake"; process.env.AGENT_RUNTIME = "scripted"; const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const describeRunsList = hasDb ? describe : describe.skip; describeRunsList("runs.list activity tracker", () => { let app: App; let prisma: PrismaClient; let stop: () => Promise; const stamp = Date.now(); const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-runs-list-")); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); const handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", }); app = handles.app; prisma = handles.prisma; stop = handles.stop; }); afterAll(async () => { await stop?.(); }); it("lists active runs from two bots and keeps completed runs under recent", async () => { const cookie = await signup(app, `runs-${stamp}@rakazo.test`, "Runs User"); const alpha = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Alpha", title: "Alpha", description: "", instructions: "", notifyOnFinish: true, }); const beta = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Beta", title: "Beta", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "bots/update", { botId: beta.id, notifyOnFinish: false }); await seedRun(prisma, alpha.id, "running", "alpha in flight"); await seedRun(prisma, beta.id, "queued", "beta in flight"); await seedRun(prisma, alpha.id, "completed", "alpha done"); const active = await rpc<{ runs: RunActivityRow[] }>(app, cookie, "runs/list", { filter: "active", }); expect(active.runs.map((run) => run.botName).sort()).toEqual(["Alpha", "Beta"]); expect(active.runs.find((run) => run.botName === "Beta")?.notificationsEnabled).toBe(false); expect(active.runs.some((run) => run.status === "completed")).toBe(false); const recent = await rpc<{ runs: RunActivityRow[] }>(app, cookie, "runs/list", { filter: "recent", }); expect(recent.runs.some((run) => run.botName === "Alpha" && run.status === "completed")).toBe( true, ); expect(recent.runs.some((run) => run.botName === "Beta")).toBe(false); }); it("never returns runs from another workspace", async () => { const ownerCookie = await signup(app, `runs-owner-${stamp}@rakazo.test`, "Owner"); const ownerBot = await rpc<{ id: string }>(app, ownerCookie, "bots/create", { name: "OwnerBot", title: "OwnerBot", description: "", instructions: "", notifyOnFinish: true, }); await seedRun(prisma, ownerBot.id, "running", "owner active run"); const intruderCookie = await signup(app, `runs-intruder-${stamp}@rakazo.test`, "Intruder"); const active = await rpc<{ runs: RunActivityRow[] }>(app, intruderCookie, "runs/list", { filter: "active", }); expect(active.runs).toEqual([]); }); it("lists a waiting run from another of the user's spaces with its space", async () => { const cookie = await signup(app, `runs-spaces-${stamp}@rakazo.test`, "Spaces User"); const home = await rpc<{ id: string; spaceId: string }>(app, cookie, "bots/create", { name: "Home", title: "", description: "", instructions: "", notifyOnFinish: true, }); const { userId } = await prisma.bot.findUniqueOrThrow({ where: { id: home.id } }); const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" }); const scout = await createRepos(prisma).createBot( { userId, spaceId: lab.id, email: "", isDeploymentOwner: false }, { name: "Scout", title: "", description: "", instructions: "", notifyOnFinish: true }, ); await seedRun(prisma, scout.id, "waiting_takeover", "sign in to continue"); const active = await rpc<{ runs: RunActivityRow[] }>(app, cookie, "runs/list", { filter: "active", }); expect(active.runs.find((run) => run.botId === scout.id)).toMatchObject({ spaceId: lab.id, status: "waiting_takeover", }); expect(lab.id).not.toBe(home.spaceId); }); it("excludes archived bots from recent before applying the limit", async () => { const cookie = await signup(app, `runs-archived-${stamp}@rakazo.test`, "Archive User"); const archivedBot = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Archived", title: "Archived", description: "", instructions: "", notifyOnFinish: true, }); const visibleBot = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Visible", title: "Visible", description: "", instructions: "", notifyOnFinish: true, }); await rpc(app, cookie, "bots/archive", { botId: archivedBot.id }); const olderVisibleAt = new Date(Date.now() - 86_400_000); for (let i = 0; i < 21; i += 1) { await seedRun( prisma, archivedBot.id, "completed", `archived filler ${i}`, new Date(Date.now() - i * 1_000), ); } await seedRun(prisma, visibleBot.id, "completed", "visible run", olderVisibleAt); const recent = await rpc<{ runs: RunActivityRow[] }>(app, cookie, "runs/list", { filter: "recent", }); expect(recent.runs.some((run) => run.botName === "Archived")).toBe(false); expect(recent.runs.some((run) => run.botName === "Visible")).toBe(true); expect(recent.runs.length).toBe(1); }); it("returns group runs with groupId for navigation", async () => { const cookie = await signup(app, `runs-group-${stamp}@rakazo.test`, "Group User"); const botA = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Worker", title: "Worker", description: "", instructions: "", notifyOnFinish: true, }); const botB = await rpc<{ id: string }>(app, cookie, "bots/create", { name: "Helper", title: "Helper", description: "", instructions: "", notifyOnFinish: true, }); const group = await rpc<{ id: string }>(app, cookie, "groups/create", { name: "Ops", botIds: [botA.id, botB.id], }); const groupThread = await prisma.thread.findUniqueOrThrow({ where: { groupId: group.id } }); const task = await prisma.task.create({ data: { spaceId: groupThread.spaceId, userId: groupThread.userId, botId: botA.id, threadId: groupThread.id, prompt: "group task in flight", status: "running", }, }); await prisma.run.create({ data: { spaceId: groupThread.spaceId, userId: groupThread.userId, botId: botA.id, threadId: groupThread.id, taskId: task.id, status: "running", trigger: "user", }, }); const active = await rpc<{ runs: RunActivityRow[] }>(app, cookie, "runs/list", { filter: "active", }); const groupRun = active.runs.find((run) => run.groupId === group.id); expect(groupRun).toBeTruthy(); expect(groupRun?.groupName).toBe("Ops"); expect(groupRun?.botId).toBe(botA.id); }); }); async function seedRun( prisma: PrismaClient, botId: string, status: "queued" | "running" | "waiting_takeover" | "completed", prompt: string, completedAt?: Date | null, ) { const thread = await prisma.thread.findUniqueOrThrow({ where: { botId } }); const task = await prisma.task.create({ data: { spaceId: thread.spaceId, userId: thread.userId, botId, threadId: thread.id, prompt, status, }, }); return prisma.run.create({ data: { spaceId: thread.spaceId, userId: thread.userId, botId, threadId: thread.id, taskId: task.id, status, trigger: "user", completedAt: status === "completed" ? (completedAt ?? new Date()) : null, }, }); } async function signup(app: App, email: string, name: string) { const response = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, body: JSON.stringify({ email, password: "test-password-123", name }), }); expect(response.status).toBeLessThan(400); return sessionCookieHeader(response); } async function rpc(app: App, cookie: string, proc: string, body: unknown = {}): Promise { const res = await raw(app, cookie, proc, body); const text = await res.text(); const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } }; if (res.status >= 400 || parsed.error) { throw new Error(`${proc} ${res.status}: ${parsed.error?.message ?? text}`); } return parsed.json as T; } async function raw(app: App, cookie: string, proc: string, body: unknown) { return app.request(`/rpc/${proc}`, { method: "POST", headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ json: body }), }); }