import { test } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../src/store/index.js'; import { writeJobSpec, runDir, touchHeartbeat, type JobSpec } from '../src/executor/protocol.js'; import { transcriptDir } from '../src/executor/cc.js'; import { cleanupTaskRunArtifacts, sweepRunArtifacts, isRunCleanable, loadCleanupConfig, type CleanupConfig, } from '../src/executor/cleanup.js'; import type { Project, Task } from '../src/model/types.js'; const noopLog = { info: (): void => undefined, error: (): void => undefined }; const DAY_MS = 24 * 60 * 60 * 1000; /** 隔离 MAESTRO_DATA_DIR(runDir/transcriptDir 调用时读 env),跑回调后清理。 */ function withTmpDataDir(cb: (dataDir: string) => void): void { const dir = mkdtempSync(join(tmpdir(), 'maestro-cleanup-')); const prev = process.env.MAESTRO_DATA_DIR; process.env.MAESTRO_DATA_DIR = dir; try { cb(dir); } finally { if (prev === undefined) delete process.env.MAESTRO_DATA_DIR; else process.env.MAESTRO_DATA_DIR = prev; rmSync(dir, { recursive: true, force: true }); } } /** 建项目 + executor run,落 job.json/outbox/heartbeat 到磁盘;返回 store/ids。 */ function setupRun(): { store: Store; taskId: string; runId: string } { const store = new Store(':memory:'); const p = store.createProject({ name: 'cl', repoPath: '/tmp/cl-' + Math.random(), autonomy: 'auto-easy' }); const t = store.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' }); store.setOperations(t.id, 'x'); store.transition(t.id, 'queued', { by: 'test' }); store.transition(t.id, 'executing', { by: 'test' }); const run = store.startRun(t.id, 'executor', { worktree: '/tmp/wt', branch: 'b' }); // 落产物 const job: JobSpec = { runId: run.id, task: t as Task, project: p as Project, worktreeDir: '/tmp/wt', branch: 'b', }; writeJobSpec(job); return { store, taskId: t.id, runId: run.id }; } /** 把 runDir 的 mtime 拨老 days 天(绕过保留期)。 */ function ageDir(runId: string, days: number): void { const when = new Date(Date.now() - days * DAY_MS); utimesSync(runDir(runId), when, when); } test('isRunCleanable:终态/孤儿可回收,在途不可', () => { withTmpDataDir(() => { const { store, taskId, runId } = setupRun(); assert.equal(isRunCleanable(store, runId), false); // executing → 否 store.transition(taskId, 'exec_review', { by: 'test' }); assert.equal(isRunCleanable(store, runId), false); // exec_review → 否 store.transition(taskId, 'done', { by: 'test' }); assert.equal(isRunCleanable(store, runId), true); // done → 是 assert.equal(isRunCleanable(store, 'run_does_not_exist'), true); // 孤儿 → 是 }); }); test('cleanupTaskRunArtifacts:终态任务的 runDir 被即时清,转录保留', () => { withTmpDataDir(() => { const { store, taskId, runId } = setupRun(); // 造一份转录 mkdirSync(transcriptDir(), { recursive: true }); const tref = join(transcriptDir(), `${runId}.jsonl`); writeFileSync(tref, '{}\n'); store.transition(taskId, 'exec_review', { by: 'test' }); store.transition(taskId, 'done', { by: 'test' }); assert.ok(existsSync(runDir(runId))); const n = cleanupTaskRunArtifacts(store, taskId, noopLog); assert.equal(n, 1); assert.equal(existsSync(runDir(runId)), false); // runDir 清 assert.ok(existsSync(tref)); // 转录保留 }); }); test('cleanupTaskRunArtifacts:heartbeat 新鲜则跳过(不误删活跃 run)', () => { withTmpDataDir(() => { const { store, taskId, runId } = setupRun(); touchHeartbeat(runId); // 新鲜心跳 store.transition(taskId, 'exec_review', { by: 'test' }); store.transition(taskId, 'done', { by: 'test' }); const n = cleanupTaskRunArtifacts(store, taskId, noopLog); assert.equal(n, 0); assert.ok(existsSync(runDir(runId))); }); }); const cfg: CleanupConfig = { runRetentionDays: 1, transcriptRetentionDays: 0 }; test('sweepRunArtifacts:done 任务过保留期 → 清;在途 → 留', () => { withTmpDataDir(() => { const a = setupRun(); const store = a.store; // 第二个 run(同 store):保持 executing const t2 = store.createTask({ projectId: store.listProjects()[0].id, title: 't2', complexity: 'easy' }); store.setOperations(t2.id, 'y'); store.transition(t2.id, 'queued', { by: 'test' }); store.transition(t2.id, 'executing', { by: 'test' }); const run2 = store.startRun(t2.id, 'executor', { worktree: '/tmp/wt2', branch: 'b2' }); writeJobSpec({ runId: run2.id, task: t2 as Task, project: store.listProjects()[0] as Project, worktreeDir: '/tmp/wt2', branch: 'b2' }); // a → done 且拨老;run2 留在 executing store.transition(a.taskId, 'exec_review', { by: 'test' }); store.transition(a.taskId, 'done', { by: 'test' }); ageDir(a.runId, 2); ageDir(run2.id, 2); // 即便老,executing 也不该删 const res = sweepRunArtifacts(store, cfg, noopLog); assert.equal(res.runDirs, 1); assert.equal(existsSync(runDir(a.runId)), false); // done → 清 assert.ok(existsSync(runDir(run2.id))); // executing → 留 }); }); test('sweepRunArtifacts:未过保留期不清;孤儿过期清', () => { withTmpDataDir(() => { const { store, taskId, runId } = setupRun(); store.transition(taskId, 'cancelled', { by: 'test' }); // 刚创建(mtime≈now),未过 1d 保留期 → 不清 let res = sweepRunArtifacts(store, cfg, noopLog); assert.equal(res.runDirs, 0); assert.ok(existsSync(runDir(runId))); // 拨老 → 清 ageDir(runId, 2); res = sweepRunArtifacts(store, cfg, noopLog); assert.equal(res.runDirs, 1); assert.equal(existsSync(runDir(runId)), false); }); }); test('sweepRunArtifacts:转录开关(>0 时清终态过期,活跃永留)', () => { withTmpDataDir(() => { const { store, taskId, runId } = setupRun(); mkdirSync(transcriptDir(), { recursive: true }); const tref = join(transcriptDir(), `${runId}.jsonl`); writeFileSync(tref, '{}\n'); const old = new Date(Date.now() - 5 * DAY_MS); utimesSync(tref, old, old); // 活跃(executing):转录永不删,即便过期 let res = sweepRunArtifacts(store, { runRetentionDays: 1, transcriptRetentionDays: 2 }, noopLog); assert.equal(res.transcripts, 0); assert.ok(existsSync(tref)); // 转为终态 → 过期转录被清 store.transition(taskId, 'cancelled', { by: 'test' }); res = sweepRunArtifacts(store, { runRetentionDays: 1, transcriptRetentionDays: 2 }, noopLog); assert.equal(res.transcripts, 1); assert.equal(existsSync(tref), false); }); }); test('loadCleanupConfig:env 覆盖与非法回退', () => { const base = loadCleanupConfig({} as NodeJS.ProcessEnv); assert.equal(base.runRetentionDays, 3); assert.equal(base.transcriptRetentionDays, 0); const over = loadCleanupConfig({ MAESTRO_RUN_RETENTION_DAYS: '7', MAESTRO_TRANSCRIPT_RETENTION_DAYS: '30' } as unknown as NodeJS.ProcessEnv); assert.equal(over.runRetentionDays, 7); assert.equal(over.transcriptRetentionDays, 30); const bad = loadCleanupConfig({ MAESTRO_RUN_RETENTION_DAYS: 'abc' } as unknown as NodeJS.ProcessEnv); assert.equal(bad.runRetentionDays, 3); });