// The run retention sweep of the agent module (G3j): the hourly job deletes a terminal run past the // retention with its steps and conversation, in each project the app serves, and keeps a run still going, // a fresher one and another organization's. The job is the module's own (adapters/procedures.ts), so it runs // on whatever data source the worker binds; this test uses the in-memory one. import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads, threadIdOf } from "@conusai-kit/agent" import { Container, type TenantScope } from "@conusai-kit/core" import { createMemoryDataSource, DATA_SOURCE, type DataSource } from "@conusai-kit/data" import { afterEach, describe, expect, it, vi } from "vitest" import { RUN_EXPIRE_JOB } from "../contract/constants.js" import { AGENT_SCOPE } from "../core/ports.js" import { AGENT_JOBS, agentSchedules } from "../index.js" const DAY = 86_400_000 const NOW = Date.parse("2026-11-02T12:00:00.000Z") const USER = "01900000-0000-7000-0000-000000000009" const ORG_A = "01900000-0000-7000-0000-000000000001" const ORG_B = "01900000-0000-7000-0000-00000000000b" const WORKSPACE = "01900000-0000-7000-0000-000000000002" const PROJECT = "01900000-0000-7000-0000-000000000003" const scopeOf = (orgId: string): TenantScope & { workspaceId: string; projectId: string } => ({ level: "project", actor: { userId: USER as never, system: true }, orgId, workspaceId: WORKSPACE, projectId: PROJECT, }) const daysAgo = (days: number): string => new Date(NOW - days * DAY).toISOString() async function seed(data: DataSource, orgId: string, status: string, finishedDaysAgo: number | null): Promise { const repos = data.forScope(scopeOf(orgId)) const run = await repos.of(agentRuns).insert({ goal: "Review the contract", plan: [{ key: "read", label: "Read", tool: "documents.read", args: {} }], status, failureCode: null, failureDetail: null, actorId: USER, budgetCostMicrocents: 1_000_000, budgetSteps: 10, budgetSeconds: 300, usageCostMicrocents: 0, usageSteps: 0, cancelRequestedAt: null, startedAt: daysAgo((finishedDaysAgo ?? 1) + 1), finishedAt: finishedDaysAgo === null ? null : daysAgo(finishedDaysAgo), } as never) await repos.of(agentSteps).insert({ runId: run.id, key: "read", index: 0, label: "Read", status: "committed", attempts: 1, usageCostMicrocents: 0, usageTokens: 0, error: null, externalEffect: false, artifacts: [], approval: null, startedAt: null, finishedAt: null, result: null, } as never) await repos.of(agentThreads).insert({ threadId: threadIdOf(run.id), messages: [] } as never) return run.id } /** The retention job, as the module registers it. */ function expireJob() { const job = AGENT_JOBS[RUN_EXPIRE_JOB] if (!job) throw new Error("the agent module registers no run retention job") return job } const runIdsOf = async (data: DataSource, orgId: string): Promise => (await data.forScope(scopeOf(orgId)).of(agentRuns).list({})).items.map((run) => run.id).sort() /** The job as the worker runs it: its container binds the data source and the project scope it runs in. */ function containerFor(data: DataSource, orgId: string): Container { const container = new Container() container.value(DATA_SOURCE, data) container.value(AGENT_SCOPE, scopeOf(orgId)) return container } afterEach(() => vi.restoreAllMocks()) describe("the agent run retention job", () => { it("deletes a terminal run past the retention with its steps and conversation, and keeps the rest", async () => { vi.spyOn(Date, "now").mockReturnValue(NOW) const data = createMemoryDataSource({ entities: [...AGENT_ENTITIES] }) await seed(data, ORG_A, "completed", 100) await seed(data, ORG_A, "failed", 91) const fresh = await seed(data, ORG_A, "completed", 89) const running = await seed(data, ORG_A, "running", null) const otherOrg = await seed(data, ORG_B, "completed", 400) await expireJob().handler( { retentionDays: 90 }, { container: containerFor(data, ORG_A), correlationId: "t", attempt: 1 }, ) expect(await runIdsOf(data, ORG_A)).toEqual([fresh, running].sort()) expect((await data.forScope(scopeOf(ORG_A)).of(agentSteps).list({})).items.map((s) => s.runId).sort()).toEqual( [fresh, running].sort(), ) expect((await data.forScope(scopeOf(ORG_A)).of(agentThreads).list({})).items.map((t) => t.threadId).sort()).toEqual( [fresh, running].map(threadIdOf).sort(), ) // Negative control: another organization's run, past the same retention, is untouched in its own project. expect(await runIdsOf(data, ORG_B)).toEqual([otherOrg]) }) it("takes its retention from the schedule's payload, which accepts only whole days from 1 to 3650", () => { const payload = expireJob().payload expect(payload.safeParse({ retentionDays: 90 }).success).toBe(true) expect(payload.safeParse({ retentionDays: 0 }).success).toBe(false) expect(payload.safeParse({ retentionDays: 3651 }).success).toBe(false) expect(payload.safeParse({ retentionDays: 2.5 }).success).toBe(false) }) it("is scheduled hourly, once per project, with the retention it was given", () => { expect(agentSchedules(90)).toEqual([ { job: RUN_EXPIRE_JOB, cron: "0 * * * *", scope: { level: "each-project" }, payload: { retentionDays: 90 }, key: "agent.runs.expire", missed: "skip", }, ]) }) })