import fs from "node:fs" ;
import os from "node:os" ;
import path from "node:path" ;
import { describe, it, expect, beforeEach } from "vitest" ;
import { isCronRunSessionKey } from "../sessions/session-key-utils.js" ;
import type { Logger } from "./service/state.js" ;
import { sweepCronRunSessions, resolveRetentionMs, resetReaperThrottle } from "./session-reaper.js" ;
function createTestLogger(): Logger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
describe("resolveRetentionMs" , () => {
it("returns 24h default when no config" , () => {
expect(resolveRetentionMs()).toBe(24 * 3 _600 _000 );
});
it("returns 24h default when config is empty" , () => {
expect(resolveRetentionMs({})).toBe(24 * 3 _600 _000 );
});
it("parses duration string" , () => {
expect(resolveRetentionMs({ sessionRetention: "1h" })).toBe(3 _600 _000 );
expect(resolveRetentionMs({ sessionRetention: "7d" })).toBe(7 * 86 _400 _000 );
expect(resolveRetentionMs({ sessionRetention: "30m" })).toBe(30 * 60 _000 );
});
it("returns null when disabled" , () => {
expect(resolveRetentionMs({ sessionRetention: false })).toBeNull();
});
it("falls back to default on invalid string" , () => {
expect(resolveRetentionMs({ sessionRetention: "abc" })).toBe(24 * 3 _600 _000 );
});
});
describe("isCronRunSessionKey" , () => {
it("matches cron run session keys" , () => {
expect(isCronRunSessionKey("agent:main:cron:abc-123:run:def-456" )).toBe(true );
expect(isCronRunSessionKey("agent:debugger:cron:249ecf82:run:1102aabb" )).toBe(true );
});
it("does not match base cron session keys" , () => {
expect(isCronRunSessionKey("agent:main:cron:abc-123" )).toBe(false );
});
it("does not match regular session keys" , () => {
expect(isCronRunSessionKey("agent:main:telegram:dm:123" )).toBe(false );
});
it("does not match non-canonical cron-like keys" , () => {
expect(isCronRunSessionKey("agent:main:slack:cron:job:run:uuid" )).toBe(false );
expect(isCronRunSessionKey("cron:job:run:uuid" )).toBe(false );
});
});
describe("sweepCronRunSessions" , () => {
let tmpDir: string;
let storePath: string;
const log = createTestLogger();
beforeEach(async () => {
resetReaperThrottle();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cron-reaper-" ));
storePath = path.join(tmpDir, "sessions.json" );
});
it("prunes expired cron run sessions" , async () => {
const now = Date.now();
const store: Record<string, { sessionId: string; updatedAt: number }> = {
"agent:main:cron:job1" : {
sessionId: "base-session" ,
updatedAt: now,
},
"agent:main:cron:job1:run:old-run" : {
sessionId: "old-run" ,
updatedAt: now - 25 * 3 _600 _000 , // 25h ago — expired
},
"agent:main:cron:job1:run:recent-run" : {
sessionId: "recent-run" ,
updatedAt: now - 1 * 3 _600 _000 , // 1h ago — not expired
},
"agent:main:telegram:dm:123" : {
sessionId: "regular-session" ,
updatedAt: now - 100 * 3 _600 _000 , // old but not a cron run
},
};
fs.writeFileSync(storePath, JSON.stringify(store));
const result = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now,
log,
force: true ,
});
expect(result.swept).toBe(true );
expect(result.pruned).toBe(1 );
const updated = JSON.parse(fs.readFileSync(storePath, "utf-8" ));
expect(updated["agent:main:cron:job1" ]).toBeDefined();
expect(updated["agent:main:cron:job1:run:old-run" ]).toBeUndefined();
expect(updated["agent:main:cron:job1:run:recent-run" ]).toBeDefined();
expect(updated["agent:main:telegram:dm:123" ]).toBeDefined();
});
it("archives transcript files for pruned run sessions that are no longer referenced" , async () => {
const now = Date.now();
const runSessionId = "old-run" ;
const runTranscript = path.join(tmpDir, `${runSessionId}.jsonl`);
fs.writeFileSync(runTranscript, '{"type":"session"}\n' );
const store: Record<string, { sessionId: string; updatedAt: number }> = {
"agent:main:cron:job1:run:old-run" : {
sessionId: runSessionId,
updatedAt: now - 25 * 3 _600 _000 ,
},
};
fs.writeFileSync(storePath, JSON.stringify(store));
const result = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now,
log,
force: true ,
});
expect(result.pruned).toBe(1 );
expect(fs.existsSync(runTranscript)).toBe(false );
const files = fs.readdirSync(tmpDir);
expect(files.some((name) => name.startsWith(`${runSessionId}.jsonl.deleted.`))).toBe(true );
});
it("does not archive external transcript paths for pruned runs" , async () => {
const now = Date.now();
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), "cron-reaper-external-" ));
const externalTranscript = path.join(externalDir, "outside.jsonl" );
fs.writeFileSync(externalTranscript, '{"type":"session"}\n' );
const store: Record<string, { sessionId: string; sessionFile?: string; updatedAt: number }> = {
"agent:main:cron:job1:run:old-run" : {
sessionId: "old-run" ,
sessionFile: externalTranscript,
updatedAt: now - 25 * 3 _600 _000 ,
},
};
fs.writeFileSync(storePath, JSON.stringify(store));
try {
const result = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now,
log,
force: true ,
});
expect(result.pruned).toBe(1 );
expect(fs.existsSync(externalTranscript)).toBe(true );
} finally {
fs.rmSync(externalDir, { recursive: true , force: true });
}
});
it("respects custom retention" , async () => {
const now = Date.now();
const store: Record<string, { sessionId: string; updatedAt: number }> = {
"agent:main:cron:job1:run:run1" : {
sessionId: "run1" ,
updatedAt: now - 2 * 3 _600 _000 , // 2h ago
},
};
fs.writeFileSync(storePath, JSON.stringify(store));
const result = await sweepCronRunSessions({
cronConfig: { sessionRetention: "1h" },
sessionStorePath: storePath,
nowMs: now,
log,
force: true ,
});
expect(result.pruned).toBe(1 );
});
it("does nothing when pruning is disabled" , async () => {
const now = Date.now();
const store: Record<string, { sessionId: string; updatedAt: number }> = {
"agent:main:cron:job1:run:run1" : {
sessionId: "run1" ,
updatedAt: now - 100 * 3 _600 _000 ,
},
};
fs.writeFileSync(storePath, JSON.stringify(store));
const result = await sweepCronRunSessions({
cronConfig: { sessionRetention: false },
sessionStorePath: storePath,
nowMs: now,
log,
force: true ,
});
expect(result.swept).toBe(false );
expect(result.pruned).toBe(0 );
});
it("throttles sweeps without force" , async () => {
const now = Date.now();
fs.writeFileSync(storePath, JSON.stringify({}));
// First sweep runs
const r1 = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now,
log,
});
expect(r1.swept).toBe(true );
// Second sweep (1 second later) is throttled
const r2 = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now + 1000 ,
log,
});
expect(r2.swept).toBe(false );
});
it("throttles per store path" , async () => {
const now = Date.now();
const otherPath = path.join(tmpDir, "sessions-other.json" );
fs.writeFileSync(storePath, JSON.stringify({}));
fs.writeFileSync(otherPath, JSON.stringify({}));
const r1 = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now,
log,
});
expect(r1.swept).toBe(true );
const r2 = await sweepCronRunSessions({
sessionStorePath: otherPath,
nowMs: now + 1000 ,
log,
});
expect(r2.swept).toBe(true );
const r3 = await sweepCronRunSessions({
sessionStorePath: storePath,
nowMs: now + 1000 ,
log,
});
expect(r3.swept).toBe(false );
});
});
Messung V0.5 in Prozent C=99 H=97 G=97
¤ Dauer der Verarbeitung: 0.14 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland