136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
// 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<string> {
|
|
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<string[]> =>
|
|
(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",
|
|
},
|
|
])
|
|
})
|
|
})
|