conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

retention.test.js (21793B)


      1 // test/retention.test.js - deleting artifacts and logs once they are old
      2 //
      3 // This is the one part of the conductor whose task is to destroy data, so
      4 // the rules are pinned down here rather than left to read from the
      5 // implementation. Jobs and tasks are seeded directly with chosen
      6 // timestamps, since waiting fourteen days for a test is not practical.
      7 
      8 import test from 'node:test';
      9 import assert from 'node:assert/strict';
     10 import { startHarness } from './helpers/harness.js';
     11 
     12 const HOUR = 60 * 60 * 1000;
     13 const DAY = 24 * HOUR;
     14 const NOW = 1_800_000_000_000;
     15 
     16 async function withHarness(options, fn) {
     17   const h = await startHarness(options);
     18   try {
     19     return await fn(h);
     20   } finally {
     21     await h.stop();
     22   }
     23 }
     24 
     25 // Seeds a finished job with one task and one artifact, at a chosen age.
     26 // Returns the identifiers so a test can assert on what survived.
     27 async function seedRun(h, {
     28   number,
     29   ageDays,
     30   state = 'success',
     31   artifactExpiresAt = null,
     32   logSize = 1024,
     33   finished = true,
     34 }) {
     35   const db = h.services.db;
     36   const at = NOW - ageDays * DAY;
     37   const jobId = `job-${number}`;
     38   const taskId = `${jobId}:build`;
     39   const artifactId = `art-${number}`;
     40   const artifactKey = `artifacts/${jobId}/build/out.txt`;
     41   const logKey = `logs/${jobId}/build.log`;
     42 
     43   await db.run(
     44     `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type,
     45                        state, visibility, created_at, started_at, finished_at)
     46      VALUES ({id}, {project}, {number}, {ref}, {sha}, 'push',
     47              {state}, 'public', {at}, {at}, {finished})`,
     48     {
     49       id: jobId,
     50       project: h.project.id,
     51       number,
     52       ref: 'refs/heads/main',
     53       sha: 'a'.repeat(40),
     54       state,
     55       at,
     56       finished: finished ? at : null,
     57     }
     58   );
     59 
     60   await db.run(
     61     `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state,
     62                        allow_failure, attempt, max_attempts, timeout,
     63                        log_key, log_size, created_at, finished_at)
     64      VALUES ({id}, {job}, 'build', 'build', 'alpine:3', '[]', '{}', {state},
     65              0, 1, 1, 3600, {logKey}, {logSize}, {at}, {finished})`,
     66     {
     67       id: taskId,
     68       job: jobId,
     69       state: finished ? state : 'running',
     70       logKey,
     71       logSize,
     72       at,
     73       finished: finished ? at : null,
     74     }
     75   );
     76 
     77   await db.run(
     78     `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256,
     79                             created_at, expires_at)
     80      VALUES ({id}, {task}, {job}, 'out.txt', {key}, 512, {sha}, {at}, {expires})`,
     81     {
     82       id: artifactId,
     83       task: taskId,
     84       job: jobId,
     85       key: artifactKey,
     86       sha: 'b'.repeat(64),
     87       at,
     88       expires: artifactExpiresAt,
     89     }
     90   );
     91 
     92   // Real objects, so a sweep that forgets to delete one is visible.
     93   await h.services.storage.put(artifactKey, Buffer.from('artifact body'));
     94   await h.services.storage.put(logKey, Buffer.from('log body'));
     95 
     96   return { jobId, taskId, artifactId, artifactKey, logKey };
     97 }
     98 
     99 async function artifactIds(h) {
    100   const rows = await h.services.db.all('SELECT id FROM artifacts ORDER BY id', {});
    101   return rows.map((r) => r.id);
    102 }
    103 
    104 async function objectExists(h, key) {
    105   try {
    106     return (await h.services.storage.head(key)) !== null;
    107   } catch {
    108     return false;
    109   }
    110 }
    111 
    112 test('an artifact past its own deadline is deleted', async () => {
    113   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => {
    114     const fresh = await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW + DAY });
    115     const stale = await seedRun(h, { number: 2, ageDays: 0, artifactExpiresAt: NOW - 1 });
    116 
    117     const result = await h.services.retention.sweep({ now: NOW });
    118 
    119     assert.equal(result.artifacts, 1);
    120     assert.deepEqual(await artifactIds(h), ['art-1']);
    121     assert.equal(await objectExists(h, fresh.artifactKey), true);
    122     assert.equal(await objectExists(h, stale.artifactKey), false, 'the object must go too, not just the row');
    123   });
    124 });
    125 
    126 test('an explicit deadline overrides the policy in both directions', async () => {
    127   // Shorter than the policy would allow, on the newest job.
    128   await withHarness({ retention: { artifact_keep_jobs: 10, artifact_keep_days: 30 } }, async (h) => {
    129     await seedRun(h, { number: 1, ageDays: 0, artifactExpiresAt: NOW - 1 });
    130     await h.services.retention.sweep({ now: NOW });
    131     assert.deepEqual(await artifactIds(h), [], 'a task may expire its artifacts early');
    132   });
    133 
    134   // Longer than the policy would allow, on a job far outside it.
    135   await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => {
    136     await seedRun(h, { number: 1, ageDays: 400, artifactExpiresAt: NOW + DAY });
    137     await seedRun(h, { number: 2, ageDays: 0 });
    138     await h.services.retention.sweep({ now: NOW });
    139     assert.ok((await artifactIds(h)).includes('art-1'), 'a task may keep its artifacts longer');
    140   });
    141 });
    142 
    143 test('an explicit deadline also beats the last good job', async () => {
    144   // The protection exists so a quiet project keeps something downloadable.
    145   // A task that asked for a short life is asking on purpose, and a bulky
    146   // intermediate should not become immortal by being green.
    147   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 0 } }, async (h) => {
    148     await seedRun(h, { number: 1, ageDays: 0, state: 'success', artifactExpiresAt: NOW - 1 });
    149     await h.services.retention.sweep({ now: NOW });
    150     assert.deepEqual(await artifactIds(h), []);
    151   });
    152 });
    153 
    154 test('the last few jobs keep their artifacts however old they are', async () => {
    155   await withHarness({ retention: { artifact_keep_jobs: 3, artifact_keep_days: 0 } }, async (h) => {
    156     for (let n = 1; n <= 6; n += 1) {
    157       await seedRun(h, { number: n, ageDays: 400, state: n === 6 ? 'failed' : 'failed' });
    158     }
    159 
    160     await h.services.retention.sweep({ now: NOW });
    161 
    162     // The three highest numbered jobs, regardless of age.
    163     assert.deepEqual(await artifactIds(h), ['art-4', 'art-5', 'art-6']);
    164   });
    165 });
    166 
    167 test('recent artifacts are kept however many jobs have followed', async () => {
    168   // The two rules are combined by whichever keeps longer, so a job outside
    169   // the count window still survives while it is inside the age window.
    170   await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 30 } }, async (h) => {
    171     for (let n = 1; n <= 5; n += 1) {
    172       await seedRun(h, { number: n, ageDays: 1, state: 'failed' });
    173     }
    174 
    175     await h.services.retention.sweep({ now: NOW });
    176 
    177     assert.equal((await artifactIds(h)).length, 5, 'all are young enough, whatever their position');
    178   });
    179 });
    180 
    181 test('an artifact outside both rules is deleted', async () => {
    182   await withHarness({ retention: { artifact_keep_jobs: 2, artifact_keep_days: 7 } }, async (h) => {
    183     const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    184     await seedRun(h, { number: 2, ageDays: 1, state: 'failed' });
    185     await seedRun(h, { number: 3, ageDays: 1, state: 'failed' });
    186 
    187     const result = await h.services.retention.sweep({ now: NOW });
    188 
    189     assert.equal(result.artifacts, 1);
    190     assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']);
    191     assert.equal(await objectExists(h, old.artifactKey), false);
    192   });
    193 });
    194 
    195 test('the most recent successful job is kept whatever the policy says', async () => {
    196   await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => {
    197     await seedRun(h, { number: 1, ageDays: 400, state: 'success' });
    198     await seedRun(h, { number: 2, ageDays: 300, state: 'failed' });
    199     await seedRun(h, { number: 3, ageDays: 0, state: 'failed' });
    200 
    201     await h.services.retention.sweep({ now: NOW });
    202 
    203     // job 1 is the last green one, job 3 is inside both windows, and the
    204     // failed job between them has nothing protecting it.
    205     assert.deepEqual(await artifactIds(h), ['art-1', 'art-3']);
    206   });
    207 });
    208 
    209 test('only the latest success is protected, not every success', async () => {
    210   await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => {
    211     await seedRun(h, { number: 1, ageDays: 400, state: 'success' });
    212     await seedRun(h, { number: 2, ageDays: 300, state: 'success' });
    213     await seedRun(h, { number: 3, ageDays: 0, state: 'failed' });
    214 
    215     await h.services.retention.sweep({ now: NOW });
    216 
    217     assert.deepEqual(await artifactIds(h), ['art-2', 'art-3']);
    218   });
    219 });
    220 
    221 test('a project may keep artifacts forever despite a server default', async () => {
    222   await withHarness({ retention: { artifact_keep_jobs: 1, artifact_keep_days: 1 } }, async (h) => {
    223     await h.services.db.run(
    224       'UPDATE projects SET artifact_keep_jobs = 0, artifact_keep_days = 0 WHERE id = {id}',
    225       { id: h.project.id }
    226     );
    227 
    228     await seedRun(h, { number: 1, ageDays: 900, state: 'failed' });
    229     await seedRun(h, { number: 2, ageDays: 900, state: 'failed' });
    230 
    231     const result = await h.services.retention.sweep({ now: NOW });
    232 
    233     // Zero has to mean forever rather than immediately, or a project could
    234     // not opt out of a default that deletes things.
    235     assert.equal(result.artifacts, 0);
    236     assert.equal((await artifactIds(h)).length, 2);
    237   });
    238 });
    239 
    240 test('a project setting overrides the server default', async () => {
    241   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 365 } }, async (h) => {
    242     await h.services.db.run(
    243       'UPDATE projects SET artifact_keep_days = 7 WHERE id = {id}',
    244       { id: h.project.id }
    245     );
    246 
    247     await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    248 
    249     await h.services.retention.sweep({ now: NOW });
    250     assert.deepEqual(await artifactIds(h), [], 'the stricter project setting applies');
    251   });
    252 });
    253 
    254 test('an unfinished job is never swept', async () => {
    255   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => {
    256     // Backdated far enough to be well outside the window, but still going.
    257     await seedRun(h, { number: 1, ageDays: 400, state: 'running', finished: false });
    258 
    259     const result = await h.services.retention.sweep({ now: NOW });
    260 
    261     assert.equal(result.artifacts, 0);
    262     assert.equal(result.logs, 0);
    263     assert.equal((await artifactIds(h)).length, 1);
    264   });
    265 });
    266 
    267 test('logs older than the limit are removed but the task remains', async () => {
    268   await withHarness({ retention: { log_keep_days: 14 } }, async (h) => {
    269     const old = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    270     const recent = await seedRun(h, { number: 2, ageDays: 1, state: 'failed' });
    271 
    272     const result = await h.services.retention.sweep({ now: NOW });
    273 
    274     assert.equal(result.logs, 1);
    275     assert.equal(await objectExists(h, old.logKey), false);
    276     assert.equal(await objectExists(h, recent.logKey), true);
    277 
    278     // The task itself is history and stays, so a job remains explainable
    279     // after its output has gone.
    280     const task = await h.services.db.get(
    281       'SELECT id, log_key, log_expired_at, state FROM tasks WHERE id = {id}',
    282       { id: old.taskId }
    283     );
    284     assert.ok(task, 'the task row must survive');
    285     assert.equal(task.log_key, null);
    286     assert.equal(task.state, 'failed');
    287     assert.equal(task.log_expired_at, NOW);
    288   });
    289 });
    290 
    291 test('a swept log is distinguishable from one that never existed', async () => {
    292   await withHarness({ retention: { log_keep_days: 1 } }, async (h) => {
    293     const swept = await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    294     await h.services.retention.sweep({ now: NOW });
    295 
    296     const gone = await h.services.db.get(
    297       'SELECT log_key, log_expired_at FROM tasks WHERE id = {id}', { id: swept.taskId }
    298     );
    299     // Both have no log_key, so the timestamp is the only thing telling
    300     // "expired" apart from "never wrote anything".
    301     assert.equal(gone.log_key, null);
    302     assert.ok(gone.log_expired_at > 0);
    303   });
    304 });
    305 
    306 test('a log is swept only once', async () => {
    307   await withHarness({ retention: { log_keep_days: 1 } }, async (h) => {
    308     await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    309 
    310     const first = await h.services.retention.sweep({ now: NOW });
    311     const second = await h.services.retention.sweep({ now: NOW });
    312 
    313     assert.equal(first.logs, 1);
    314     assert.equal(second.logs, 0, 'a swept log must not be counted again on every pass');
    315   });
    316 });
    317 
    318 test('zero keeps logs forever', async () => {
    319   await withHarness({ retention: { log_keep_days: 0 } }, async (h) => {
    320     const old = await seedRun(h, { number: 1, ageDays: 900, state: 'failed' });
    321     const result = await h.services.retention.sweep({ now: NOW });
    322 
    323     assert.equal(result.logs, 0);
    324     assert.equal(await objectExists(h, old.logKey), true);
    325   });
    326 });
    327 
    328 test('a storage failure leaves the row for the next pass', async () => {
    329   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => {
    330     await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    331 
    332     const real = h.services.storage.delete;
    333     h.services.storage.delete = async () => { throw new Error('bucket unreachable'); };
    334 
    335     const failed = await h.services.retention.sweep({ now: NOW });
    336     assert.equal(failed.artifacts, 0);
    337     assert.equal((await artifactIds(h)).length, 1, 'the row must survive so the object is not orphaned');
    338 
    339     h.services.storage.delete = real;
    340 
    341     const recovered = await h.services.retention.sweep({ now: NOW });
    342     assert.equal(recovered.artifacts, 1);
    343     assert.deepEqual(await artifactIds(h), []);
    344   });
    345 });
    346 
    347 test('a sweep deletes no more than its batch', async () => {
    348   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => {
    349     for (let n = 1; n <= 5; n += 1) {
    350       await seedRun(h, { number: n, ageDays: 30, state: 'failed' });
    351     }
    352 
    353     const first = await h.services.retention.sweep({ now: NOW, batch: 2 });
    354     assert.equal(first.artifacts, 2, 'a backlog must not be swept in one go');
    355     assert.equal((await artifactIds(h)).length, 3);
    356 
    357     await h.services.retention.sweep({ now: NOW, batch: 10 });
    358     assert.equal((await artifactIds(h)).length, 0);
    359   });
    360 });
    361 
    362 test('one project policy does not reach into another', async () => {
    363   await withHarness({ retention: { artifact_keep_jobs: 0, artifact_keep_days: 1 } }, async (h) => {
    364     await h.services.projects.create({
    365       id: 'other',
    366       name: 'Other',
    367       repo_url: h.repoDir,
    368       trigger_secret: 'other-secret',
    369       visibility: 'public',
    370     }, { allowLocalPath: true });
    371     await h.services.db.run(
    372       'UPDATE projects SET artifact_keep_days = 0 WHERE id = {id}', { id: 'other' }
    373     );
    374 
    375     await seedRun(h, { number: 1, ageDays: 30, state: 'failed' });
    376 
    377     // An old artifact under the project that keeps everything.
    378     await h.services.db.run(
    379       `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type,
    380                          state, visibility, created_at, started_at, finished_at)
    381        VALUES ('other-job', 'other', 1, 'refs/heads/main', {sha}, 'push',
    382                'failed', 'public', {at}, {at}, {at})`,
    383       { sha: 'c'.repeat(40), at: NOW - 900 * DAY }
    384     );
    385     await h.services.db.run(
    386       `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state,
    387                          allow_failure, attempt, max_attempts, timeout, log_size,
    388                          created_at, finished_at)
    389        VALUES ('other-task', 'other-job', 'build', 'build', 'alpine:3', '[]', '{}',
    390                'failed', 0, 1, 1, 3600, 0, {at}, {at})`,
    391       { at: NOW - 900 * DAY }
    392     );
    393     await h.services.db.run(
    394       `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at)
    395        VALUES ('art-other', 'other-task', 'other-job', 'out.txt',
    396                'artifacts/other/out.txt', 1, {sha}, {at})`,
    397       { sha: 'd'.repeat(64), at: NOW - 900 * DAY }
    398     );
    399 
    400     await h.services.retention.sweep({ now: NOW });
    401 
    402     assert.deepEqual(await artifactIds(h), ['art-other']);
    403   });
    404 });
    405 
    406 test('artifacts.expire in a pipeline reaches the stored artifact', async () => {
    407   // The key has been in the schema since the beginning and was parsed,
    408   // stored in the task spec, and then ignored by everything. This is the
    409   // path from pipeline text to a deadline on the row.
    410   const pipeline = [
    411     'version: 1',
    412     'defaults:',
    413     '  image: alpine:3',
    414     'tasks:',
    415     '  build:',
    416     "    script: ['make']",
    417     '    artifacts:',
    418     '      paths: [out/**]',
    419     '      expire: 1h',
    420     '  keep:',
    421     "    script: ['make']",
    422     '    artifacts:',
    423     '      paths: [out/**]',
    424     '',
    425   ].join('\n');
    426 
    427   await withHarness({ pipeline }, async (h) => {
    428     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    429     await h.waitForJob(res.json().data.job_id);
    430 
    431     const before = Date.now();
    432     for (const name of ['build', 'keep']) {
    433       const res = await h.claim({});
    434       assert.equal(res.statusCode, 200, `expected to claim ${name}`);
    435       const task = res.json().data.task;
    436 
    437       const upload = await h.app.inject({
    438         method: 'POST',
    439         url: `/api/v1/tasks/${task.id}/artifacts`,
    440         headers: {
    441           ...h.auth,
    442           'content-type': 'application/octet-stream',
    443           'x-artifact-path': 'out/result.txt',
    444         },
    445         payload: Buffer.from('packaged'),
    446       });
    447       assert.equal(upload.statusCode, 200);
    448     }
    449 
    450     const rows = await h.services.db.all(
    451       `SELECT t.base_name AS name, a.expires_at
    452          FROM artifacts a JOIN tasks t ON t.id = a.task_id
    453         ORDER BY t.base_name`,
    454       {}
    455     );
    456 
    457     const byName = Object.fromEntries(rows.map((r) => [r.name, r.expires_at]));
    458 
    459     // An hour from when it was stored, give or take the time the test took.
    460     assert.ok(byName.build >= before + 3600 * 1000, 'expire should be an hour out');
    461     assert.ok(byName.build <= Date.now() + 3600 * 1000);
    462 
    463     // The task that said nothing is left to the project policy.
    464     assert.equal(byName.keep, null);
    465   });
    466 });
    467 
    468 test('the api reports and accepts project retention', async () => {
    469   await withHarness({
    470     bootstrap: true,
    471     retention: { artifact_keep_jobs: 7, artifact_keep_days: 21, log_keep_days: 9 },
    472   }, async (h) => {
    473     const { headers } = await h.login();
    474     const json = { ...headers, 'content-type': 'application/json' };
    475 
    476     const patch = (payload) => h.app.inject({
    477       method: 'PATCH',
    478       url: `/api/v1/projects/${h.project.id}`,
    479       headers: json,
    480       payload: JSON.stringify(payload),
    481     });
    482 
    483     const before = await h.app.inject({
    484       method: 'GET', url: `/api/v1/projects/${h.project.id}`, headers,
    485     });
    486     assert.equal(before.json().data.artifact_keep_days, null, 'unset means inherited');
    487 
    488     const patched = await patch({ artifact_keep_jobs: 3, artifact_keep_days: 0, log_keep_days: 30 });
    489     assert.equal(patched.statusCode, 200, patched.body);
    490     assert.equal(patched.json().data.artifact_keep_jobs, 3);
    491     assert.equal(patched.json().data.artifact_keep_days, 0, 'zero is a value, not an absence');
    492     assert.equal(patched.json().data.log_keep_days, 30);
    493 
    494     // And back to following the server default.
    495     const cleared = await patch({ artifact_keep_jobs: null });
    496     assert.equal(cleared.json().data.artifact_keep_jobs, null);
    497 
    498     const rejected = await patch({ log_keep_days: -5 });
    499     assert.equal(rejected.statusCode, 400);
    500     assert.match(rejected.json().error.message, /zero or more/);
    501   });
    502 });
    503 
    504 test('a session outlives its expiry only until the next sweep', async () => {
    505   await withHarness({ bootstrap: true }, async (h) => {
    506     const grant = await h.login();
    507     const live = await h.services.refreshTokens.page({ offset: 0, limit: 10 });
    508     assert.equal(live.total, 1);
    509 
    510     // Still within the grace window, so it can be reported as ended.
    511     await h.services.db.run(
    512       'UPDATE refresh_tokens SET expires_at = {then}',
    513       { then: Date.now() - 1000 }
    514     );
    515     assert.equal((await h.services.retention.sweep()).sessions, 0);
    516 
    517     const refused = await h.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
    518     assert.equal(refused.statusCode, 400);
    519 
    520     await h.services.db.run(
    521       'UPDATE refresh_tokens SET expires_at = {then}',
    522       { then: Date.now() - 3 * 24 * 60 * 60 * 1000 }
    523     );
    524     assert.equal((await h.services.retention.sweep()).sessions, 1);
    525   });
    526 });
    527 
    528 test('a machine that has stopped claiming is forgotten', async () => {
    529   await withHarness({ retention: { worker_agent_keep_hours: 24 } }, async (h) => {
    530     const { workerTokens, db } = h.services;
    531 
    532     await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'gone', features: ['dind'] });
    533     await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'here', features: ['unos-sign-keys'] });
    534     // recordAgent stamps the real clock, and NOW is not the real clock.
    535     await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'gone'", { then: NOW - 25 * HOUR });
    536     await db.run("UPDATE worker_agents SET last_seen_at = {then} WHERE name = 'here'", { then: NOW - 1 * HOUR });
    537 
    538     const result = await h.services.retention.sweep({ now: NOW });
    539 
    540     assert.equal(result.agents, 1);
    541     const left = await workerTokens.agentsFor(h.worker.id);
    542     assert.deepEqual(left.map((a) => a.name), ['here']);
    543   });
    544 });
    545 
    546 test('zero keeps machines for ever', async () => {
    547   await withHarness({ retention: { worker_agent_keep_hours: 0 } }, async (h) => {
    548     const { workerTokens, db } = h.services;
    549 
    550     await workerTokens.recordAgent({ tokenId: h.worker.id, name: 'ancient', features: [] });
    551     await db.run('UPDATE worker_agents SET last_seen_at = {then}', { then: NOW - 365 * DAY });
    552 
    553     const result = await h.services.retention.sweep({ now: NOW });
    554 
    555     assert.equal(result.agents, 0);
    556     assert.equal((await workerTokens.agentsFor(h.worker.id)).length, 1);
    557   });
    558 });