Files
maestro/src/engine/reflection/retention.test.ts
T

274 lines
9.1 KiB
TypeScript

// src/engine/reflection/retention.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
rmSync,
mkdirSync,
writeFileSync,
existsSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
pruneOldSnapshots,
enforceDiskCap,
runReflectionRetentionSweep,
type RetentionDeps,
} from './retention.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
const USER_ID = 'u-retention-test';
/** Format a Date as the snapshot directory name prefix (YYYYMMDDTHHmmssZ). */
function fmtTs(d: Date): string {
const pad2 = (n: number) => String(n).padStart(2, '0');
return (
`${d.getUTCFullYear()}` +
`${pad2(d.getUTCMonth() + 1)}` +
`${pad2(d.getUTCDate())}` +
`T${pad2(d.getUTCHours())}` +
`${pad2(d.getUTCMinutes())}` +
`${pad2(d.getUTCSeconds())}` +
`Z`
);
}
/** Create a fake snapshot dir for the given age in days (relative to now). */
function makeSnapshot(
histDir: string,
ageDays: number,
jobId: string,
fileSizeBytes = 100,
): string {
const d = new Date(Date.now() - ageDays * 86_400_000);
const name = `${fmtTs(d)}-${jobId}`;
const dir = join(histDir, name);
mkdirSync(dir, { recursive: true });
// Write a dummy meta.json + a content file of the requested size
writeFileSync(
join(dir, 'meta.json'),
JSON.stringify({ snapshotId: name, ts: d.toISOString(), pieceName: 'chat' }),
'utf-8',
);
writeFileSync(join(dir, 'data.bin'), Buffer.alloc(fileSizeBytes), 'binary');
return name;
}
function makeDeps(dataDir: string): RetentionDeps {
return { dataDir };
}
function histDir(dataDir: string, userId: string): string {
return join(dataDir, userId, '.reflection-history');
}
// ── Test suite ────────────────────────────────────────────────────────────────
describe('pruneOldSnapshots', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'retention-age-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('does NOT prune a snapshot newer than retentionDays', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
const snapId = makeSnapshot(hDir, 5, 'j-new'); // 5 days old
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
expect(result.pruned).toHaveLength(0);
expect(existsSync(join(hDir, snapId))).toBe(true);
});
it('prunes a snapshot older than retentionDays', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
const snapId = makeSnapshot(hDir, 100, 'j-old'); // 100 days old, retention=90
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
expect(result.pruned).toContain(snapId);
expect(existsSync(join(hDir, snapId))).toBe(false);
});
it('prunes old but keeps new when both present', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
const oldId = makeSnapshot(hDir, 200, 'j-old'); // old
const newId = makeSnapshot(hDir, 10, 'j-new'); // new
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
expect(result.pruned).toContain(oldId);
expect(result.pruned).not.toContain(newId);
expect(existsSync(join(hDir, oldId))).toBe(false);
expect(existsSync(join(hDir, newId))).toBe(true);
});
it('returns empty pruned when no history dir exists', async () => {
const result = await pruneOldSnapshots(makeDeps(tmpDir), 'u-ghost', 90);
expect(result.pruned).toHaveLength(0);
});
it('does not prune index.jsonl (not a parseable snapshot dir name)', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
// Write index.jsonl as a file (not a dir), should be ignored
writeFileSync(join(hDir, 'index.jsonl'), '', 'utf-8');
const snapId = makeSnapshot(hDir, 5, 'j-ok');
const result = await pruneOldSnapshots(makeDeps(tmpDir), USER_ID, 90);
expect(result.pruned).toHaveLength(0);
expect(existsSync(join(hDir, 'index.jsonl'))).toBe(true);
expect(existsSync(join(hDir, snapId))).toBe(true);
});
});
describe('enforceDiskCap', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'retention-cap-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('does not prune when total bytes is under cap', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
// Each snapshot ≈ 200 bytes (100 data + small meta.json); cap = 10 MiB
const snapId = makeSnapshot(hDir, 5, 'j-small', 100);
const cap = 10 * 1024 * 1024; // 10 MiB
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
expect(result.pruned).toHaveLength(0);
expect(existsSync(join(hDir, snapId))).toBe(true);
});
it('prunes oldest snapshots first when over cap', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
// Create 3 snapshots: oldest, middle, newest — each ~1024 bytes of data
const oldestId = makeSnapshot(hDir, 30, 'j-oldest', 1024);
const middleId = makeSnapshot(hDir, 20, 'j-middle', 1024);
const newestId = makeSnapshot(hDir, 10, 'j-newest', 1024);
// The total will be around 3 * (1024 + meta) bytes.
// Set cap so that only 2 snapshots fit (i.e., total > 2 * entry_size).
// Each entry is ~1024 + ~80 bytes meta ≈ 1104 bytes.
// Cap at 2200 bytes → oldest must be pruned.
const cap = 2200;
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
// oldest should be pruned, newest should survive
expect(result.pruned).toContain(oldestId);
expect(existsSync(join(hDir, oldestId))).toBe(false);
expect(existsSync(join(hDir, newestId))).toBe(true);
// middle may or may not be pruned depending on actual sizes, but newest must survive
void middleId; // referenced to suppress unused variable warning
});
it('newest snapshot survives when only one exists and under cap', async () => {
const hDir = histDir(tmpDir, USER_ID);
mkdirSync(hDir, { recursive: true });
const snapId = makeSnapshot(hDir, 5, 'j-only', 100);
const cap = 10 * 1024 * 1024; // 10 MiB — easily above 100 bytes
const result = await enforceDiskCap(makeDeps(tmpDir), USER_ID, cap);
expect(result.pruned).toHaveLength(0);
expect(existsSync(join(hDir, snapId))).toBe(true);
});
it('returns empty pruned when no history dir exists', async () => {
const result = await enforceDiskCap(makeDeps(tmpDir), 'u-ghost', 1024);
expect(result.pruned).toHaveLength(0);
});
});
describe('runReflectionRetentionSweep', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'retention-sweep-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('sweeps all users and prunes old snapshots', async () => {
const uid1 = 'u-sweep-1';
const uid2 = 'u-sweep-2';
const hDir1 = histDir(tmpDir, uid1);
const hDir2 = histDir(tmpDir, uid2);
mkdirSync(hDir1, { recursive: true });
mkdirSync(hDir2, { recursive: true });
const old1 = makeSnapshot(hDir1, 100, 'j-old1'); // old
const new1 = makeSnapshot(hDir1, 5, 'j-new1'); // new
const old2 = makeSnapshot(hDir2, 200, 'j-old2'); // old
await runReflectionRetentionSweep(makeDeps(tmpDir), {
snapshotRetentionDays: 90,
snapshotMaxBytesPerUser: 100 * 1024 * 1024, // 100 MiB — won't trigger
});
expect(existsSync(join(hDir1, old1))).toBe(false);
expect(existsSync(join(hDir1, new1))).toBe(true);
expect(existsSync(join(hDir2, old2))).toBe(false);
});
it('skips users with no .reflection-history dir', async () => {
// Create a user dir without a history dir
const uid = 'u-no-history';
mkdirSync(join(tmpDir, uid), { recursive: true });
// Should not throw
await expect(
runReflectionRetentionSweep(makeDeps(tmpDir), {
snapshotRetentionDays: 90,
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
}),
).resolves.toBeUndefined();
});
it('handles a non-existent dataDir gracefully', async () => {
await expect(
runReflectionRetentionSweep(
{ dataDir: join(tmpDir, 'does-not-exist') },
{ snapshotRetentionDays: 90, snapshotMaxBytesPerUser: 100 * 1024 * 1024 },
),
).resolves.toBeUndefined();
});
it('single snapshot under cap and within retention is not touched', async () => {
const uid = 'u-clean';
const hDir = histDir(tmpDir, uid);
mkdirSync(hDir, { recursive: true });
const snapId = makeSnapshot(hDir, 5, 'j-only', 100);
await runReflectionRetentionSweep(makeDeps(tmpDir), {
snapshotRetentionDays: 90,
snapshotMaxBytesPerUser: 100 * 1024 * 1024,
});
expect(existsSync(join(hDir, snapId))).toBe(true);
});
});