conductor

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

conductor.test.js (68547B)


      1 // test/conductor.test.js - end to end behaviour of the conductor service
      2 //
      3 // Drives a real server against a real git repository: trigger, dispatch,
      4 // logs, artifacts, completion and failure handling.
      5 
      6 import test from 'node:test';
      7 import assert from 'node:assert/strict';
      8 import { startHarness, DEFAULT_PIPELINE, data, apiError } from './helpers/harness.js';
      9 
     10 // Each test gets its own harness so that ordering never matters.
     11 async function withHarness(options, fn) {
     12   const h = await startHarness(options);
     13   try {
     14     return await fn(h);
     15   } finally {
     16     await h.stop();
     17   }
     18 }
     19 
     20 function tasksByName(payload) {
     21   return Object.fromEntries(payload.tasks.map((j) => [j.name, j]));
     22 }
     23 
     24 // Straight from the database, so a test can assert on what the API chooses
     25 // not to publish.
     26 async function jobState(h, jobId) {
     27   const job = await h.services.db.get('SELECT * FROM jobs WHERE id = {id}', { id: jobId });
     28   const tasks = await h.services.db.all(
     29     'SELECT * FROM tasks WHERE job_id = {job} ORDER BY name', { job: jobId }
     30   );
     31   const deps = await h.services.db.all(
     32     `SELECT d.task_id, d.depends_on_id, d.kind FROM task_deps d
     33        JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`,
     34     { job: jobId }
     35   );
     36   const edges = new Map(tasks.map((t) => [t.id, { needs: [], after: [] }]));
     37   for (const d of deps) edges.get(d.task_id)?.[d.kind === 'after' ? 'after' : 'needs'].push(d.depends_on_id);
     38   return { job, tasks: tasks.map((t) => ({ ...t, ...(edges.get(t.id) ?? { needs: [], after: [] }) })) };
     39 }
     40 
     41 // The job a task belongs to. A worker is never told this, so a test that
     42 // needs it asks the database, exactly as the conductor does.
     43 async function jobIdOf(h, taskId) {
     44   const row = await h.services.db.get('SELECT job_id FROM tasks WHERE id = {id}', { id: taskId });
     45   return row.job_id;
     46 }
     47 
     48 // Claims everything currently eligible for a worker with the given
     49 // capabilities, keyed by task name. Tests assert on which tasks appear rather
     50 // than on the order they arrive in, which is not part of the contract.
     51 async function drain(h, query = {}) {
     52   const claimed = {};
     53   for (let i = 0; i < 50; i += 1) {
     54     const res = await h.claim(query);
     55     if (res.statusCode === 204) break;
     56     const task = res.json().data.task;
     57     claimed[task.name] = task;
     58   }
     59   return claimed;
     60 }
     61 
     62 async function claimNamed(h, name, query) {
     63   return (await drain(h, query))[name] ?? null;
     64 }
     65 
     66 async function finishRaw(h, taskId, {
     67   success = true, state = null, exitCode = 0, error = null, outputs = [],
     68 } = {}) {
     69   return h.app.inject({
     70     method: 'POST',
     71     url: `/api/v1/tasks/${taskId}/complete`,
     72     headers: { ...h.auth, 'content-type': 'application/json' },
     73     payload: JSON.stringify({ success, state, exit_code: exitCode, error, outputs }),
     74   });
     75 }
     76 
     77 async function finish(h, taskId, opts) {
     78   return data(await finishRaw(h, taskId, opts));
     79 }
     80 
     81 test('health reports the selected drivers', async () => {
     82   await withHarness({}, async (h) => {
     83     const res = await h.app.inject({ method: 'GET', url: '/health' });
     84     assert.equal(res.statusCode, 200);
     85     assert.deepEqual(data(res), {
     86       service: 'conductor', database: 'sqlite', storage: 'local', auth: 'local',
     87     });
     88   });
     89 });
     90 
     91 test('a signed trigger creates a job and expands the pipeline', async () => {
     92   await withHarness({}, async (h) => {
     93     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' });
     94     assert.equal(res.statusCode, 200);
     95 
     96     const body = data(res);
     97     assert.equal(body.status, 'created');
     98 
     99     const job = await h.waitForJob(body.job_id);
    100     assert.equal(job.state, 'running');
    101     assert.equal(job.actor, 'alice');
    102     assert.equal(job.title, 'initial commit');
    103     assert.equal(job.number, 1);
    104 
    105     const detail = await jobState(h, body.job_id);
    106     // lint + 2 build + 2 package + publish
    107     assert.equal(detail.tasks.length, 6);
    108 
    109     // Task ids are opaque, so the edge is checked by resolving the name
    110     // rather than reconstructing an id the conductor no longer derives.
    111     const tasks = tasksByName(detail);
    112     assert.deepEqual(
    113       tasks['package:arch=x86_64,pkg=musl'].needs,
    114       [tasks['build:arch=x86_64'].id]
    115     );
    116     assert.equal(tasks.publish.needs.length, 3);
    117   });
    118 });
    119 
    120 test('an unsigned or wrongly signed trigger is refused', async () => {
    121   await withHarness({}, async (h) => {
    122     const unsigned = await h.app.inject({
    123       method: 'POST',
    124       url: '/api/v1/projects/demo/trigger',
    125       headers: { 'content-type': 'application/json' },
    126       payload: JSON.stringify({ sha: h.sha }),
    127     });
    128     assert.equal(unsigned.statusCode, 401);
    129 
    130     const wrong = await h.trigger({ sha: h.sha }, { secret: 'not-the-secret' });
    131     assert.equal(wrong.statusCode, 401);
    132   });
    133 });
    134 
    135 test('github and gitlab push payloads are understood', async () => {
    136   await withHarness({}, async (h) => {
    137     const github = await h.trigger({
    138       after: h.sha,
    139       before: '0'.repeat(40),
    140       ref: 'refs/heads/main',
    141       pusher: { name: 'octocat' },
    142     });
    143     assert.equal(github.statusCode, 200);
    144     const githubJob = await h.waitForJob(github.json().data.job_id);
    145     assert.equal(githubJob.state, 'running');
    146 
    147     const gitlab = await h.trigger({
    148       checkout_sha: h.sha,
    149       ref: 'refs/heads/main',
    150       user_username: 'gl-user',
    151     });
    152     assert.equal(gitlab.statusCode, 200);
    153     const gitlabJob = await h.waitForJob(gitlab.json().data.job_id);
    154     assert.equal(gitlabJob.state, 'running');
    155   });
    156 });
    157 
    158 test('a branch deletion is ignored rather than failing', async () => {
    159   await withHarness({}, async (h) => {
    160     const res = await h.trigger({ sha: '0'.repeat(40), ref: 'refs/heads/gone' });
    161     assert.equal(res.statusCode, 200);
    162     assert.equal(res.json().data.status, 'ignored');
    163   });
    164 });
    165 
    166 test('a broken pipeline is reported as a client error with detail', async () => {
    167   await withHarness({ pipeline: 'version: 1\ntasks:\n  a:\n    script: [x]\n' }, async (h) => {
    168     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    169     assert.equal(res.statusCode, 200);
    170     const job = await h.waitForJob(res.json().data.job_id);
    171     assert.equal(job.state, 'failed');
    172     assert.ok(job.error?.includes('tasks.a.image'));
    173   });
    174 });
    175 
    176 test('a missing pipeline file is reported clearly', async () => {
    177   await withHarness({ pipeline: null }, async (h) => {
    178     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    179     assert.equal(res.statusCode, 200);
    180     const job = await h.waitForJob(res.json().data.job_id);
    181     assert.equal(job.state, 'failed');
    182     assert.match(job.error ?? '', /\.conductor\.yml not found/);
    183   });
    184 });
    185 
    186 test('a pipeline may be spelled .conductor.yaml instead of .conductor.yml', async () => {
    187   await withHarness({ repoConfigPath: '.conductor.yaml' }, async (h) => {
    188     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    189     assert.equal(res.statusCode, 200);
    190     const job = await h.waitForJob(res.json().data.job_id);
    191     assert.equal(job.state, 'running');
    192   });
    193 });
    194 
    195 test('a project pointed at a specific file gets no fallback', async () => {
    196   await withHarness({ configPath: 'ci/build.yml', repoConfigPath: '.conductor.yml' }, async (h) => {
    197     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    198     assert.equal(res.statusCode, 200);
    199     const job = await h.waitForJob(res.json().data.job_id);
    200     assert.equal(job.state, 'failed');
    201     assert.match(job.error ?? '', /ci\/build\.yml not found/);
    202   });
    203 });
    204 
    205 test('a repository carrying both spellings is refused rather than guessed at', async () => {
    206   await withHarness({}, async (h) => {
    207     const sha = await h.commit({ '.conductor.yaml': DEFAULT_PIPELINE }, 'add the other spelling');
    208     const res = await h.trigger({ sha, ref: 'refs/heads/main' });
    209     assert.equal(res.statusCode, 200);
    210     const job = await h.waitForJob(res.json().data.job_id);
    211     assert.equal(job.state, 'failed');
    212     assert.match(job.error ?? '', /both exist/);
    213   });
    214 });
    215 
    216 test('a pipeline still using the jobs key is told what it was renamed to', async () => {
    217   const old = 'version: 1\njobs:\n  build:\n    image: alpine\n    script: [make]\n';
    218   await withHarness({}, async (h) => {
    219     const sha = await h.commit({ '.conductor.yml': old }, 'pre-rename pipeline');
    220     const res = await h.trigger({ sha, ref: 'refs/heads/main' });
    221     assert.equal(res.statusCode, 200);
    222     const job = await h.waitForJob(res.json().data.job_id);
    223     assert.equal(job.state, 'failed');
    224     assert.match(job.error ?? '', /was renamed to tasks/);
    225   });
    226 });
    227 
    228 test('a job can be created directly, without a push payload to imitate', async () => {
    229   await withHarness({}, async (h) => {
    230     const res = await h.createJob({ sha: h.sha, ref: 'refs/heads/main', actor: 'alice' });
    231     assert.equal(res.statusCode, 200);
    232 
    233     const body = data(res);
    234     assert.equal(body.status, 'created');
    235 
    236     const job = await h.waitForJob(body.job_id);
    237     assert.equal(job.state, 'running');
    238     assert.equal(job.actor, 'alice');
    239     assert.equal(job.trigger_type, 'api');
    240   });
    241 });
    242 
    243 test('creating a job requires the same signature as a trigger', async () => {
    244   await withHarness({}, async (h) => {
    245     const unsigned = await h.app.inject({
    246       method: 'POST',
    247       url: '/api/v1/projects/demo/jobs',
    248       headers: { 'content-type': 'application/json' },
    249       payload: JSON.stringify({ sha: h.sha }),
    250     });
    251     assert.equal(unsigned.statusCode, 401);
    252 
    253     const wrong = await h.createJob({ sha: h.sha }, { secret: 'not-the-secret' });
    254     assert.equal(wrong.statusCode, 401);
    255   });
    256 });
    257 
    258 test('creating a job insists on a real commit rather than inferring one', async () => {
    259   await withHarness({}, async (h) => {
    260     assert.equal((await h.createJob({ ref: 'refs/heads/main' })).statusCode, 400);
    261     assert.equal((await h.createJob({ sha: 'abc' })).statusCode, 400);
    262   });
    263 });
    264 
    265 test('a job can be read back, with the state of every task', async () => {
    266   await withHarness({}, async (h) => {
    267     const jobId = (await h.createJob({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    268     await h.waitForJob(jobId);
    269 
    270     const signature = h.sign('');
    271     const headers = { 'content-type': 'application/json', 'x-hub-signature-256': signature };
    272 
    273     const signed = await h.app.inject({
    274       method: 'GET',
    275       url: `/api/v1/projects/demo/jobs/${jobId}`,
    276       headers,
    277     });
    278     assert.equal(signed.statusCode, 200, signed.body);
    279     assert.equal(data(signed).id, jobId);
    280     assert.equal(data(signed).state, 'running');
    281 
    282     const tasks = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}/tasks`, headers });
    283     const body = data(tasks);
    284     assert.equal(body.tasks.length, 6);
    285     assert.ok(body.tasks.every((t) => t.state === 'queued'));
    286     assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64'));
    287   });
    288 });
    289 
    290 test('a public job is readable without any credential at all', async () => {
    291   await withHarness({}, async (h) => {
    292     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    293     await h.waitForJob(jobId);
    294 
    295     const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` });
    296     assert.equal(res.statusCode, 200, res.body);
    297     assert.equal(res.json().data.visibility, 'public');
    298   });
    299 });
    300 
    301 test('a private job is absent to anyone without a session', async () => {
    302   await withHarness({ visibility: 'private', bootstrap: true }, async (h) => {
    303     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    304     await h.waitForJob(jobId);
    305 
    306     const anon = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` });
    307     assert.equal(anon.statusCode, 404);
    308 
    309     // The trigger secret starts builds; it does not read them back.
    310     const signed = await h.app.inject({
    311       method: 'GET',
    312       url: `/api/v1/projects/demo/jobs/${jobId}`,
    313       headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') },
    314     });
    315     assert.equal(signed.statusCode, 404, signed.body);
    316 
    317     const { headers } = await h.login();
    318     const admin = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}`, headers });
    319     assert.equal(admin.statusCode, 200, admin.body);
    320     assert.equal(admin.json().data.visibility, 'private');
    321   });
    322 });
    323 
    324 test('a job of another project cannot be read through this one', async () => {
    325   await withHarness({}, async (h) => {
    326     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    327     await h.waitForJob(jobId);
    328     const signature = h.sign('');
    329     const res = await h.app.inject({
    330       method: 'GET',
    331       url: `/api/v1/projects/absent/jobs/${jobId}`,
    332       headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
    333     });
    334     assert.equal(res.statusCode, 404);
    335   });
    336 });
    337 
    338 // --- reading a task back ---
    339 
    340 // The two paths answer identically; only what may reach a private task
    341 // differs between them.
    342 function taskUrls(taskId, project = 'demo') {
    343   return [`/api/v1/tasks/${taskId}`, `/api/v1/projects/${project}/tasks/${taskId}`];
    344 }
    345 
    346 // package needs build, so nothing downstream is claimable until the first
    347 // wave has finished.
    348 async function claimDownstream(h, name) {
    349   for (const task of Object.values(await drain(h, { arches: 'x86_64,aarch64' }))) {
    350     await finish(h, task.id);
    351   }
    352   return claimNamed(h, name, { arches: 'x86_64', features: 'sign-key' });
    353 }
    354 
    355 test('a task can be read back on its own, and by its full name', async () => {
    356   await withHarness({}, async (h) => {
    357     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    358     await h.waitForJob(res.json().data.job_id);
    359     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    360 
    361     const [bare, scoped] = await Promise.all(
    362       taskUrls(task.id).map((url) => h.app.inject({ method: 'GET', url }))
    363     );
    364     assert.equal(bare.statusCode, 200, bare.body);
    365     assert.equal(scoped.statusCode, 200, scoped.body);
    366     assert.deepEqual(data(bare), data(scoped), 'both paths describe the same task');
    367 
    368     const { task: body } = data(bare);
    369     assert.equal(body.id, task.id);
    370     assert.equal(body.name, 'build:arch=x86_64');
    371     assert.equal(body.base_name, 'build');
    372     assert.equal(body.state, 'running');
    373     assert.equal(body.arch, 'x86_64');
    374     assert.equal(body.image, 'debian:bookworm-slim');
    375     assert.equal(body.visibility, 'public');
    376     assert.equal(body.sha, h.sha);
    377     assert.equal(body.ref, 'refs/heads/main');
    378     assert.equal(body.project_id, 'demo');
    379     assert.equal(body.job_id, await jobIdOf(h, task.id));
    380     assert.equal(body.job_number, 1);
    381     assert.equal(body.allow_failure, false, 'stored as 0 or 1, reported as a boolean');
    382     assert.deepEqual(body.artifact_paths, ['dist/**'], 'what it was told to collect');
    383   });
    384 });
    385 
    386 test('reading a task back carries its dependencies and matrix', async () => {
    387   await withHarness({}, async (h) => {
    388     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    389     await h.waitForJob(tr.json().data.job_id);
    390     const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl');
    391 
    392     const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` });
    393     assert.equal(res.statusCode, 200, res.body);
    394 
    395     const { task: body } = data(res);
    396     assert.deepEqual(body.matrix, { pkg: 'musl' });
    397     assert.deepEqual(body.needs, ['build:arch=x86_64']);
    398     assert.equal(body.workdir, '/work', 'settled when the job was created');
    399   });
    400 });
    401 
    402 // The point of the endpoint: a task's environment can hold project
    403 // variables, and its services carry an environment of their own.
    404 test('reading a task back never exposes its environment, services or script', async () => {
    405   await withHarness({}, async (h) => {
    406     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    407     await h.waitForJob(res.json().data.job_id);
    408     const task = await claimDownstream(h, 'package:arch=x86_64,pkg=musl');
    409 
    410     // The claim payload has them, which is what makes their absence below
    411     // meaningful rather than incidental.
    412     assert.ok(task.env.MATRIX_PKG, 'the worker is given the environment');
    413     assert.ok(Array.isArray(task.script) && task.script.length > 0);
    414 
    415     for (const url of taskUrls(task.id)) {
    416       const res = await h.app.inject({ method: 'GET', url });
    417       assert.equal(res.statusCode, 200, res.body);
    418 
    419       const payload = data(res);
    420       // Asserted before the loop below, which would otherwise pass happily
    421       // against a payload that has no task in it at all.
    422       assert.ok(payload.task, `${url} must describe a task`);
    423       for (const key of ['env', 'services', 'script', 'spec', 'worker_token_id', 'log_key', 'storage_key']) {
    424         assert.equal(payload.task[key], undefined, `${url} must not carry ${key}`);
    425       }
    426       // Belt and braces: a value smuggled in under any other name.
    427       assert.ok(!JSON.stringify(payload).includes('MATRIX_PKG'), `${url} leaked an environment name`);
    428       assert.ok(!JSON.stringify(payload).includes('./pkg.sh'), `${url} leaked the script`);
    429     }
    430   });
    431 });
    432 
    433 test('a task lists the artifacts it stored, with a usable download url', async () => {
    434   await withHarness({}, async (h) => {
    435     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    436     await h.waitForJob(tr.json().data.job_id);
    437     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    438 
    439     await h.app.inject({
    440       method: 'POST',
    441       url: `/api/v1/tasks/${task.id}/artifacts`,
    442       headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/app' },
    443       payload: Buffer.from('artifact bytes'),
    444     });
    445 
    446     const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/artifacts` });
    447     assert.equal(res.statusCode, 200, res.body);
    448 
    449     const artifacts = data(res);
    450     assert.equal(artifacts.length, 1);
    451     assert.equal(artifacts[0].path, 'dist/app');
    452     assert.equal(artifacts[0].size, 14);
    453     assert.match(artifacts[0].sha256, /^[0-9a-f]{64}$/);
    454     assert.ok(artifacts[0].url.startsWith('http://conductor.test/'), artifacts[0].url);
    455 
    456     // The url is the whole point of returning one, so follow it.
    457     const download = await h.app.inject({ method: 'GET', url: new URL(artifacts[0].url).pathname });
    458     assert.equal(download.statusCode, 200);
    459     assert.equal(download.body, 'artifact bytes');
    460   });
    461 });
    462 
    463 test('outputs are a page of their own, with masked values withheld', async () => {
    464   await withHarness({}, async (h) => {
    465     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    466     await h.waitForJob(tr.json().data.job_id);
    467     const task = await claimNamed(h, 'lint', {});
    468 
    469     await h.services.db.run(
    470       `INSERT INTO task_outputs (task_id, name, value, masked, created_at)
    471        VALUES ({t}, 'version', {v}, 0, {now})`,
    472       { t: task.id, v: h.services.secrets.seal('1.2.3', `task:${task.id}/output:version`), now: Date.now() }
    473     );
    474     await h.services.db.run(
    475       `INSERT INTO task_outputs (task_id, name, value, masked, created_at)
    476        VALUES ({t}, 'password', {v}, 1, {now})`,
    477       { t: task.id, v: h.services.secrets.seal('hunter2', `task:${task.id}/output:password`), now: Date.now() }
    478     );
    479 
    480     const res = await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/outputs` });
    481     assert.equal(res.statusCode, 200, res.body);
    482 
    483     const outputs = Object.fromEntries(data(res).map((o) => [o.name, o]));
    484     assert.equal(outputs.version.value, '1.2.3');
    485     assert.equal(outputs.password.masked, true);
    486     assert.equal(outputs.password.value, null);
    487     assert.ok(!res.body.includes('hunter2'));
    488     assert.equal(res.json().pagination.total, 2);
    489   });
    490 });
    491 
    492 test('a log is read by byte offset and reports the total size', async () => {
    493   await withHarness({}, async (h) => {
    494     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    495     await h.waitForJob(tr.json().data.job_id);
    496     const task = await claimNamed(h, 'lint', {});
    497 
    498     await h.app.inject({
    499       method: 'POST',
    500       url: `/api/v1/tasks/${task.id}/log`,
    501       headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-log-offset': '0' },
    502       payload: Buffer.from('hello world\n'),
    503     });
    504 
    505     const whole = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log` }));
    506     assert.equal(whole.data, 'hello world\n');
    507     assert.equal(whole.size, 12);
    508     assert.equal(whole.offset, 0);
    509     assert.equal(whole.expired, false);
    510 
    511     // A tail asks from where it stopped and is given only what is new.
    512     const tail = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log?offset=6` }));
    513     assert.equal(tail.data, 'world\n');
    514     assert.equal(tail.offset, 6);
    515     assert.equal(tail.size, 12);
    516 
    517     // Asking from the end is how a caller learns there is nothing new.
    518     const caught = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log?offset=12` }));
    519     assert.equal(caught.data, '');
    520     assert.equal(caught.size, 12);
    521   });
    522 });
    523 
    524 test('a swept log reports that it expired rather than that it is empty', async () => {
    525   await withHarness({}, async (h) => {
    526     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    527     await h.waitForJob(tr.json().data.job_id);
    528     const task = await claimNamed(h, 'lint', {});
    529 
    530     await h.services.db.run(
    531       'UPDATE tasks SET log_expired_at = {now} WHERE id = {id}',
    532       { id: task.id, now: Date.now() }
    533     );
    534 
    535     const log = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}/log` }));
    536     assert.equal(log.expired, true);
    537 
    538     const detail = data(await h.app.inject({ method: 'GET', url: `/api/v1/tasks/${task.id}` }));
    539     assert.equal(detail.task.log_expired, true);
    540   });
    541 });
    542 
    543 test('reading a task back needs no worker token', async () => {
    544   await withHarness({}, async (h) => {
    545     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    546     await h.waitForJob(res.json().data.job_id);
    547     const task = await claimNamed(h, 'lint', {});
    548 
    549     // The worker protocol shares this prefix and puts a token hook over its
    550     // own plugin. If that hook ever reaches these routes, this fails.
    551     for (const url of taskUrls(task.id)) {
    552       const res = await h.app.inject({ method: 'GET', url });
    553       assert.equal(res.statusCode, 200, `${url} should not require a token`);
    554     }
    555   });
    556 });
    557 
    558 test('a private task is absent without a session, by either path', async () => {
    559   await withHarness({ visibility: 'private' }, async (h) => {
    560     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    561     await h.waitForJob(res.json().data.job_id);
    562     const task = await claimNamed(h, 'lint', {});
    563 
    564     for (const url of taskUrls(task.id)) {
    565       const anon = await h.app.inject({ method: 'GET', url });
    566       assert.equal(anon.statusCode, 404, `${url} should be absent anonymously`);
    567     }
    568 
    569     // A trigger secret reads nothing, on either path.
    570     const signed = { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') };
    571     for (const url of taskUrls(task.id)) {
    572       const res2 = await h.app.inject({ method: 'GET', url, headers: signed });
    573       assert.equal(res2.statusCode, 404, `${url} should not accept a trigger secret`);
    574     }
    575   });
    576 });
    577 
    578 test('a private task is readable by an administrator, by either path', async () => {
    579   // scrypt is slow, so the harness only creates the admin when asked.
    580   await withHarness({ visibility: 'private', bootstrap: true }, async (h) => {
    581     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    582     await h.waitForJob(res.json().data.job_id);
    583     const task = await claimNamed(h, 'lint', {});
    584     const { headers } = await h.login();
    585 
    586     for (const url of taskUrls(task.id)) {
    587       const res = await h.app.inject({ method: 'GET', url, headers });
    588       assert.equal(res.statusCode, 200, `${url}: ${res.body}`);
    589       assert.equal(res.json().data.task.id, task.id);
    590     }
    591   });
    592 });
    593 
    594 test('a task of another project cannot be read through this one', async () => {
    595   await withHarness({}, async (h) => {
    596     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    597     await h.waitForJob(res.json().data.job_id);
    598     const task = await claimNamed(h, 'lint', {});
    599 
    600     const res2 = await h.app.inject({
    601       method: 'GET',
    602       url: `/api/v1/projects/absent/tasks/${task.id}`,
    603       headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') },
    604     });
    605     assert.equal(res2.statusCode, 404);
    606   });
    607 });
    608 
    609 test('an unknown task is 404 by either path', async () => {
    610   await withHarness({}, async (h) => {
    611     for (const url of taskUrls('m2y0000000000000nope')) {
    612       const res = await h.app.inject({ method: 'GET', url });
    613       assert.equal(res.statusCode, 404, `${url} should be 404`);
    614       assert.equal(apiError(res).name, 'UnknownTask');
    615     }
    616   });
    617 });
    618 
    619 test('a claim records what the machine advertises, even when there is no work', async () => {
    620   await withHarness({}, async (h) => {
    621     const idle = await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys'] });
    622     assert.equal(idle.statusCode, 204, 'nothing is queued yet');
    623 
    624     const agents = await h.services.workerTokens.agentsFor(h.worker.id);
    625     assert.equal(agents.length, 1);
    626     assert.equal(agents[0].name, 'builder-01');
    627     assert.deepEqual(agents[0].features, ['unos-sign-keys']);
    628     assert.deepEqual(agents[0].arches, ['x86_64']);
    629   });
    630 });
    631 
    632 test('a machine that changes its features updates its row rather than adding one', async () => {
    633   await withHarness({}, async (h) => {
    634     await h.claim({ name: 'builder-01', features: ['dind'] });
    635     await h.claim({ name: 'builder-01', features: ['dind', 'unos-sign-keys'] });
    636 
    637     const agents = await h.services.workerTokens.agentsFor(h.worker.id);
    638     assert.equal(agents.length, 1, 'one machine is one row');
    639     assert.deepEqual(agents[0].features, ['dind', 'unos-sign-keys']);
    640   });
    641 });
    642 
    643 test('machines sharing a token are recorded separately', async () => {
    644   await withHarness({}, async (h) => {
    645     await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] });
    646     await h.claim({ name: 'builder-02', features: [] });
    647 
    648     const agents = await h.services.workerTokens.agentsFor(h.worker.id);
    649     assert.deepEqual(agents.map((a) => a.name).sort(), ['builder-01', 'builder-02']);
    650   });
    651 });
    652 
    653 test('the worker api rejects missing and invalid tokens', async () => {
    654   await withHarness({}, async (h) => {
    655     const none = await h.app.inject({ method: 'POST', url: '/api/v1/tasks/claim' });
    656     assert.equal(none.statusCode, 401);
    657 
    658     const bad = await h.app.inject({
    659       method: 'POST', url: '/api/v1/tasks/claim', headers: { authorization: 'Bearer nope' },
    660     });
    661     assert.equal(bad.statusCode, 401);
    662   });
    663 });
    664 
    665 test('independent tasks dispatch concurrently', async () => {
    666   await withHarness({}, async (h) => {
    667     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    668     await h.waitForJob(res.json().data.job_id);
    669 
    670     // The prototype could only ever hand out one task at a time.
    671     const [a, b, c] = await Promise.all([
    672       h.claim({ arches: 'x86_64,aarch64' }),
    673       h.claim({ arches: 'x86_64,aarch64' }),
    674       h.claim({ arches: 'x86_64,aarch64' }),
    675     ]);
    676 
    677     const names = [a, b, c].map((r) => r.json().data.task.name);
    678     assert.equal(new Set(names).size, 3, `expected three distinct tasks, got ${names.join(', ')}`);
    679     assert.deepEqual(names.slice().sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']);
    680   });
    681 });
    682 
    683 test('a task is only offered to a worker that satisfies its requires', async () => {
    684   await withHarness({}, async (h) => {
    685     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    686     await h.waitForJob(res.json().data.job_id);
    687 
    688     // Clear the three root tasks so only package and publish remain.
    689     const roots = await drain(h, { arches: 'x86_64,aarch64' });
    690     assert.deepEqual(Object.keys(roots).sort(), ['build:arch=aarch64', 'build:arch=x86_64', 'lint']);
    691     for (const task of Object.values(roots)) await finish(h, task.id, { success: true });
    692 
    693     // package requires sign-key, which this worker does not advertise.
    694     const without = await h.claim({ arches: 'x86_64,aarch64' });
    695     assert.equal(without.statusCode, 204);
    696 
    697     const with_ = await h.claim({ arches: 'x86_64,aarch64', features: 'sign-key' });
    698     assert.equal(with_.statusCode, 200);
    699     assert.match(with_.json().data.task.name, /^package:/);
    700   });
    701 });
    702 
    703 test('a worker is not offered work for an architecture it cannot build', async () => {
    704   await withHarness({}, async (h) => {
    705     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    706     await h.waitForJob(res.json().data.job_id);
    707 
    708     const claimed = [];
    709     for (let i = 0; i < 3; i += 1) {
    710       const res = await h.claim({ arches: 'aarch64' });
    711       if (res.statusCode === 204) break;
    712       claimed.push(res.json().data.task);
    713     }
    714 
    715     // lint has no arch so it is eligible; the x86_64 build is not.
    716     assert.ok(!claimed.some((j) => j.arch === 'x86_64'));
    717     assert.ok(claimed.some((j) => j.name === 'build:arch=aarch64'));
    718   });
    719 });
    720 
    721 test('a claimed task carries everything the worker needs', async () => {
    722   await withHarness({}, async (h) => {
    723     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    724     await h.waitForJob(res.json().data.job_id);
    725     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    726 
    727     assert.equal(task.image, 'debian:bookworm-slim');
    728     assert.deepEqual(task.script, ['./build.sh $ARCH']);
    729     assert.equal(task.env.ARCH, 'x86_64');
    730     assert.equal(task.sha, h.sha);
    731     assert.match(task.endpoints.source, /^http:\/\/conductor\.test\/api\/v1\/tasks\//);
    732     assert.ok(task.endpoints.source.endsWith('/source.tar.gz'));
    733     assert.ok(task.endpoints.log.endsWith('/log'));
    734 
    735     // The worker resolves features from these, so a missing field
    736     // silently disables them.
    737     assert.deepEqual(task.requires, []);
    738     assert.ok(Number.isInteger(task.heartbeat_interval) && task.heartbeat_interval > 0);
    739     assert.deepEqual(task.masked, []);
    740 
    741     // A worker is given a context and a script. Which project or job the
    742     // work belongs to is none of its business, and the id it holds carries
    743     // no structure to read it out of either.
    744     assert.equal(task.project_id, undefined);
    745     assert.equal(task.job_id, undefined);
    746     assert.ok(!task.id.includes(':'), `task id should be opaque, got ${task.id}`);
    747   });
    748 });
    749 
    750 test('a task that requires a feature reports it to the worker', async () => {
    751   await withHarness({}, async (h) => {
    752     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    753     await h.waitForJob(res.json().data.job_id);
    754     const roots = await drain(h, { arches: 'x86_64,aarch64' });
    755     for (const task of Object.values(roots)) await finish(h, task.id, { success: true });
    756 
    757     const pkg = (await drain(h, { arches: 'x86_64', features: 'sign-key' }))['package:arch=x86_64,pkg=musl'];
    758     assert.ok(pkg);
    759     assert.deepEqual(pkg.requires, ['sign-key']);
    760   });
    761 });
    762 
    763 test('the standard conductor variables are present in the task environment', async () => {
    764   await withHarness({}, async (h) => {
    765     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    766     await h.waitForJob(job);
    767     const task = await claimNamed(h, 'lint', {});
    768 
    769     // The worker is not told any of this, but the script is: these reach
    770     // the container as ordinary environment, which is the only place the
    771     // build legitimately needs them.
    772     assert.equal(task.env.CONDUCTOR_PROJECT, 'demo');
    773     assert.equal(task.env.CONDUCTOR_JOB_ID, job);
    774     assert.equal(task.env.CONDUCTOR_JOB_NUMBER, '1');
    775     assert.equal(task.env.CONDUCTOR_TASK, 'lint');
    776     assert.equal(task.env.CONDUCTOR_TASK_ID, task.id);
    777     assert.equal(task.env.CONDUCTOR_SHA, h.sha);
    778     assert.equal(task.env.CONDUCTOR_REF, 'refs/heads/main');
    779     assert.equal(task.env.CONDUCTOR_ATTEMPT, '1');
    780   });
    781 });
    782 
    783 test('the source tarball is served from the mirror', async () => {
    784   await withHarness({}, async (h) => {
    785     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    786     await h.waitForJob(res.json().data.job_id);
    787     const task = await claimNamed(h, 'lint', {});
    788 
    789     const res2 = await h.app.inject({
    790       method: 'GET',
    791       url: `/api/v1/tasks/${task.id}/source.tar.gz`,
    792       headers: h.auth,
    793     });
    794     assert.equal(res2.statusCode, 200);
    795     assert.equal(res2.headers['content-type'], 'application/gzip');
    796     // gzip magic number
    797     assert.equal(res2.rawPayload[0], 0x1f);
    798     assert.equal(res2.rawPayload[1], 0x8b);
    799   });
    800 });
    801 
    802 test('a worker cannot touch a task it does not hold', async () => {
    803   await withHarness({}, async (h) => {
    804     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    805     await h.waitForJob(tr.json().data.job_id);
    806     const task = await claimNamed(h, 'lint', {});
    807 
    808     const other = await h.services.workerTokens.create('intruder');
    809     const res = await h.app.inject({
    810       method: 'POST',
    811       url: `/api/v1/tasks/${task.id}/log`,
    812       headers: { authorization: `Bearer ${other.token}`, 'content-type': 'application/octet-stream' },
    813       payload: Buffer.from('malicious'),
    814     });
    815     assert.equal(res.statusCode, 403);
    816   });
    817 });
    818 
    819 test('log appends are resumable and deduplicated by offset', async () => {
    820   await withHarness({}, async (h) => {
    821     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    822     await h.waitForJob(res.json().data.job_id);
    823     const task = await claimNamed(h, 'lint', {});
    824     const url = `/api/v1/tasks/${task.id}/log`;
    825     const headers = { ...h.auth, 'content-type': 'application/octet-stream' };
    826 
    827     const first = await h.app.inject({
    828       method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\n'),
    829     });
    830     assert.deepEqual(data(first), { size: 6, written: 6, truncated: false });
    831 
    832     // A retry resends what was already stored plus new data.
    833     const retry = await h.app.inject({
    834       method: 'POST', url, headers: { ...headers, 'x-log-offset': '0' }, payload: Buffer.from('hello\nworld\n'),
    835     });
    836     assert.deepEqual(data(retry), { size: 12, written: 6, truncated: false });
    837 
    838     // A gap is refused, and says where to resume.
    839     const gap = await h.app.inject({
    840       method: 'POST', url, headers: { ...headers, 'x-log-offset': '9999' }, payload: Buffer.from('x'),
    841     });
    842     assert.equal(gap.statusCode, 409);
    843     assert.equal(apiError(gap).details.expected_offset, 12);
    844 
    845     const tail = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 0, limit: 1024 });
    846     assert.equal(tail.data.toString('utf8'), 'hello\nworld\n');
    847     assert.equal(tail.size, 12);
    848 
    849     const partial = await h.services.logs.read(await jobIdOf(h, task.id), task.id, { offset: 6, limit: 1024 });
    850     assert.equal(partial.data.toString('utf8'), 'world\n');
    851   });
    852 });
    853 
    854 test('an oversized log chunk is refused', async () => {
    855   await withHarness({}, async (h) => {
    856     const tr = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    857     await h.waitForJob(tr.json().data.job_id);
    858     const task = await claimNamed(h, 'lint', {});
    859 
    860     // Artifacts are streamed and may be large, but a single log append is
    861     // buffered, so it has to be bounded or a worker can exhaust memory.
    862     const res = await h.app.inject({
    863       method: 'POST',
    864       url: `/api/v1/tasks/${task.id}/log`,
    865       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    866       payload: Buffer.alloc(2 * 1024 * 1024, 0x41),
    867     });
    868 
    869     assert.equal(res.statusCode, 413);
    870     assert.equal(await h.services.logs.size(await jobIdOf(h, task.id), task.id), 0, 'nothing should have been written');
    871   });
    872 });
    873 
    874 test('a finished log moves to storage and is still readable', async () => {
    875   await withHarness({}, async (h) => {
    876     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    877     await h.waitForJob(res.json().data.job_id);
    878     const task = await claimNamed(h, 'lint', {});
    879 
    880     await h.app.inject({
    881       method: 'POST',
    882       url: `/api/v1/tasks/${task.id}/log`,
    883       headers: { ...h.auth, 'content-type': 'application/octet-stream' },
    884       payload: Buffer.from('compiling\ndone\n'),
    885     });
    886     await finish(h, task.id, { success: true });
    887 
    888     const row = await h.services.db.get('SELECT log_key FROM tasks WHERE id = {id}', { id: task.id });
    889     assert.ok(row.log_key, 'the log should have moved to storage');
    890     const object = await h.services.storage.get(row.log_key);
    891     const parts = [];
    892     for await (const part of object.stream) parts.push(part);
    893     assert.equal(Buffer.concat(parts).toString('utf8'), 'compiling\ndone\n');
    894   });
    895 });
    896 
    897 test('artifacts are stored, hashed, and stripped of traversal', async () => {
    898   await withHarness({}, async (h) => {
    899     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    900     await h.waitForJob(res.json().data.job_id);
    901     const task = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    902 
    903     const upload = await h.app.inject({
    904       method: 'POST',
    905       url: `/api/v1/tasks/${task.id}/artifacts`,
    906       headers: {
    907         ...h.auth,
    908         'content-type': 'application/octet-stream',
    909         'x-artifact-path': '../../../etc/passwd',
    910       },
    911       payload: Buffer.from('artifact bytes'),
    912     });
    913     assert.equal(upload.statusCode, 200);
    914 
    915     const body = data(upload);
    916     assert.equal(body.path, 'etc/passwd', 'traversal should be stripped, not honoured');
    917     assert.equal(body.size, 14);
    918     assert.match(body.sha256, /^[0-9a-f]{64}$/);
    919 
    920     const artifacts = await h.services.db.all(
    921       'SELECT id FROM artifacts WHERE task_id = {task}', { task: task.id }
    922     );
    923     assert.equal(artifacts.length, 1);
    924 
    925     const download = await h.app.inject({
    926       method: 'GET',
    927       url: `/api/v1/projects/demo/jobs/${await jobIdOf(h, task.id)}/tasks/${task.id}/artifacts/${artifacts[0].id}`,
    928     });
    929     assert.equal(download.statusCode, 200);
    930     assert.equal(download.body, 'artifact bytes');
    931   });
    932 });
    933 
    934 test('an artifact shorter than its content-length is rejected', async () => {
    935   await withHarness({}, async (h) => {
    936     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
    937     await h.waitForJob(res.json().data.job_id);
    938     const task = await claimNamed(h, 'lint', {});
    939 
    940     const res2 = await h.app.inject({
    941       method: 'POST',
    942       url: `/api/v1/tasks/${task.id}/artifacts`,
    943       headers: {
    944         ...h.auth,
    945         'content-type': 'application/octet-stream',
    946         'content-length': '500',
    947         'x-artifact-path': 'short.bin',
    948       },
    949       payload: Buffer.from('tiny'),
    950     });
    951     assert.equal(res2.statusCode, 400);
    952   });
    953 });
    954 
    955 test('success releases dependents, matched on shared dimensions', async () => {
    956   await withHarness({}, async (h) => {
    957     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    958     await h.waitForJob(job);
    959 
    960     const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
    961     await finish(h, roots['build:arch=x86_64'].id, { success: true });
    962 
    963     // Only the x86_64 package becomes eligible; the aarch64 one still waits
    964     // on its own architecture's build.
    965     const next = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
    966     assert.ok(next['package:arch=x86_64,pkg=musl'], 'x86_64 package should be released');
    967     assert.ok(!next['package:arch=aarch64,pkg=musl'], 'aarch64 package should still be waiting');
    968 
    969     const state = tasksByName(await jobState(h, job));
    970     assert.equal(state['package:arch=aarch64,pkg=musl'].state, 'queued');
    971   });
    972 });
    973 
    974 test('failure skips transitive dependents, not just direct ones', async () => {
    975   await withHarness({}, async (h) => {
    976     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    977     await h.waitForJob(job);
    978 
    979     const build = await claimNamed(h, 'build:arch=x86_64', { arches: 'x86_64' });
    980     const outcome = await finish(h, build.id, { success: false, exitCode: 2, error: 'compile failed' });
    981     assert.equal(outcome.state, 'failed');
    982 
    983     const tasks = tasksByName(await jobState(h, job));
    984     // Direct dependent.
    985     assert.equal(tasks['package:arch=x86_64,pkg=musl'].state, 'skipped');
    986     // Transitive dependent: the prototype left this queued and dispatchable.
    987     assert.equal(tasks.publish.state, 'skipped');
    988     // Unrelated branches are untouched.
    989     assert.equal(tasks['build:arch=aarch64'].state, 'queued');
    990     assert.equal(tasks['package:arch=aarch64,pkg=musl'].state, 'queued');
    991   });
    992 });
    993 
    994 test('a job settles as failed once every task is terminal', async () => {
    995   await withHarness({}, async (h) => {
    996     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
    997     await h.waitForJob(job);
    998 
    999     const roots = await drain(h, { arches: 'x86_64,aarch64', features: 'sign-key' });
   1000 
   1001     // Failing the x86_64 build skips its package and, transitively, publish.
   1002     await finish(h, roots['build:arch=x86_64'].id, { success: false, exitCode: 1 });
   1003     await finish(h, roots['build:arch=aarch64'].id, { success: true });
   1004     await finish(h, roots.lint.id, { success: true });
   1005 
   1006     const released = await drain(h, { arches: 'aarch64', features: 'sign-key' });
   1007     const last = await finish(h, released['package:arch=aarch64,pkg=musl'].id, { success: true });
   1008 
   1009     assert.equal(last.job_state, 'failed');
   1010     const detail = await jobState(h, job);
   1011     assert.equal(detail.job.state, 'failed');
   1012 
   1013     // The failure fails the job, not the skips it caused.
   1014     assert.ok(
   1015       detail.tasks.some((t) => t.state === 'skipped'),
   1016       'the cascade should have skipped something'
   1017     );
   1018     assert.ok(detail.tasks.some((t) => t.state === 'failed' && t.allow_failure === 0));
   1019   });
   1020 });
   1021 
   1022 test('a job settles as success when everything passes', async () => {
   1023   // Quoted, because an unquoted true in YAML is a boolean, not a command.
   1024   const pipeline = `
   1025 version: 1
   1026 tasks:
   1027   a:
   1028     image: alpine
   1029     script: ['true']
   1030   b:
   1031     image: alpine
   1032     script: ['true']
   1033     needs: [a]
   1034 `;
   1035   await withHarness({ pipeline }, async (h) => {
   1036     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1037     await h.waitForJob(job);
   1038 
   1039     const a = await claimNamed(h, 'a', {});
   1040     await finish(h, a.id, { success: true });
   1041     const b = await claimNamed(h, 'b', {});
   1042     const last = await finish(h, b.id, { success: true });
   1043 
   1044     assert.equal(last.job_state, 'success');
   1045     assert.equal((await jobState(h, job)).job.state, 'success');
   1046   });
   1047 });
   1048 
   1049 test('an allowed failure does not fail the job or block dependents', async () => {
   1050   const pipeline = `
   1051 version: 1
   1052 tasks:
   1053   flaky:
   1054     image: alpine
   1055     script: ['false']
   1056     allow_failure: true
   1057   after:
   1058     image: alpine
   1059     script: ['true']
   1060     needs: [flaky]
   1061 `;
   1062   await withHarness({ pipeline }, async (h) => {
   1063     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1064     await h.waitForJob(job);
   1065 
   1066     const flaky = await claimNamed(h, 'flaky', {});
   1067     const outcome = await finish(h, flaky.id, { success: false, exitCode: 1 });
   1068     assert.deepEqual(outcome.skipped, []);
   1069 
   1070     const after = await claimNamed(h, 'after', {});
   1071     assert.ok(after, 'dependent should still run after an allowed failure');
   1072     const last = await finish(h, after.id, { success: true });
   1073     assert.equal(last.job_state, 'success');
   1074     assert.equal((await jobState(h, job)).job.state, 'success');
   1075   });
   1076 });
   1077 
   1078 test('a task with retries left is requeued instead of failing the job', async () => {
   1079   const pipeline = `
   1080 version: 1
   1081 tasks:
   1082   retried:
   1083     image: alpine
   1084     script: [maybe]
   1085     max_attempts: 2
   1086 `;
   1087   await withHarness({ pipeline }, async (h) => {
   1088     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1089     await h.waitForJob(job);
   1090 
   1091     const first = await claimNamed(h, 'retried', {});
   1092     const outcome = await finish(h, first.id, { success: false, exitCode: 1 });
   1093     assert.equal(outcome.state, 'queued');
   1094     assert.equal(outcome.retry, true);
   1095 
   1096     const second = await claimNamed(h, 'retried', {});
   1097     assert.equal(second.attempt, 2);
   1098     const last = await finish(h, second.id, { success: false, exitCode: 1 });
   1099     assert.equal(last.state, 'failed');
   1100     assert.equal((await jobState(h, job)).job.state, 'failed');
   1101   });
   1102 });
   1103 
   1104 test('heartbeats keep a task alive and report cancellation', async () => {
   1105   await withHarness({}, async (h) => {
   1106     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1107     await h.waitForJob(job);
   1108     const task = await claimNamed(h, 'lint', {});
   1109 
   1110     const beat = await h.app.inject({
   1111       method: 'POST',
   1112       url: `/api/v1/tasks/${task.id}/heartbeat`,
   1113       headers: { ...h.auth, 'content-type': 'application/json' },
   1114       payload: '{}',
   1115     });
   1116     assert.deepEqual(data(beat), { cancelled: false });
   1117 
   1118     await h.services.scheduler.cancelJob(job);
   1119 
   1120     const after = await h.app.inject({
   1121       method: 'POST',
   1122       url: `/api/v1/tasks/${task.id}/heartbeat`,
   1123       headers: { ...h.auth, 'content-type': 'application/json' },
   1124       payload: '{}',
   1125     });
   1126     assert.equal(after.json().data.cancelled, true);
   1127     assert.equal((await jobState(h, job)).job.state, 'cancelled');
   1128   });
   1129 });
   1130 
   1131 test('the reaper fails a task whose worker stopped reporting', async () => {
   1132   await withHarness({}, async (h) => {
   1133     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1134     await h.waitForJob(job);
   1135     const task = await claimNamed(h, 'lint', {});
   1136     assert.equal(tasksByName(await jobState(h, job)).lint.state, 'running');
   1137 
   1138     // Backdate the heartbeat rather than waiting out the real timeout.
   1139     await h.services.db.run(
   1140       'UPDATE tasks SET heartbeat_at = {then} WHERE id = {id}',
   1141       { then: Date.now() - 10 * 60 * 1000, id: task.id }
   1142     );
   1143 
   1144     const reaped = await h.services.scheduler.reap();
   1145     assert.ok(reaped.length > 0);
   1146 
   1147     const after = tasksByName(await jobState(h, job)).lint;
   1148     assert.equal(after.state, 'failed');
   1149     assert.match(after.error, /stopped reporting/);
   1150 
   1151     // The worker that lost the task can no longer complete it.
   1152     const late = await finishRaw(h, task.id, { success: true });
   1153     assert.equal(late.statusCode, 409);
   1154     assert.equal(apiError(late).name, 'TaskNotRunning');
   1155   });
   1156 });
   1157 
   1158 test('a second push produces an independent, numbered job', async () => {
   1159   await withHarness({}, async (h) => {
   1160     const first = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }));
   1161     await h.waitForJob(first.job_id);
   1162     const sha2 = await h.commit({ 'README.md': 'changed\n' }, 'second commit');
   1163     const second = data(await h.trigger({ sha: sha2, ref: 'refs/heads/main' }));
   1164     await h.waitForJob(second.job_id);
   1165 
   1166     assert.notEqual(first.job_id, second.job_id);
   1167     const detail = await jobState(h, second.job_id);
   1168     assert.equal(detail.job.number, 2);
   1169     assert.equal(detail.job.title, 'second commit');
   1170     assert.equal(detail.job.head_sha, sha2);
   1171   });
   1172 });
   1173 
   1174 test('a jobs list belongs to its project', async () => {
   1175   await withHarness({ visibility: 'public' }, async (h) => {
   1176     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1177     await h.waitForJob(res.json().data.job_id);
   1178 
   1179     const mine = await h.app.inject({ method: 'GET', url: '/api/v1/projects/demo/jobs' });
   1180     assert.equal(mine.json().data.length, 1);
   1181 
   1182     const other = await h.app.inject({ method: 'GET', url: '/api/v1/projects/absent/jobs' });
   1183     assert.equal(other.statusCode, 404);
   1184   });
   1185 });
   1186 
   1187 test('a job is addressable with or without its project', async () => {
   1188   await withHarness({ visibility: 'public' }, async (h) => {
   1189     const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1190     await h.waitForJob(jobId);
   1191 
   1192     for (const url of [`/api/v1/jobs/${jobId}`, `/api/v1/projects/demo/jobs/${jobId}`]) {
   1193       const res = await h.app.inject({ method: 'GET', url });
   1194       assert.equal(res.statusCode, 200, `${url}: ${res.body}`);
   1195       assert.equal(res.json().data.id, jobId);
   1196     }
   1197 
   1198     // Naming the wrong project asserts it rather than being ignored.
   1199     const wrong = await h.app.inject({ method: 'GET', url: `/api/v1/projects/absent/jobs/${jobId}` });
   1200     assert.equal(wrong.statusCode, 404);
   1201   });
   1202 });
   1203 
   1204 test('unknown jobs, tasks and artifacts return 404', async () => {
   1205   await withHarness({}, async (h) => {
   1206     const urls = [
   1207       '/api/v1/jobs/nope',
   1208       '/api/v1/jobs/nope/tasks',
   1209       '/api/v1/tasks/nope',
   1210       '/api/v1/projects/nope',
   1211     ];
   1212     for (const url of urls) {
   1213       const res = await h.app.inject({ method: 'GET', url });
   1214       assert.equal(res.statusCode, 404, `${url} should be 404`);
   1215     }
   1216   });
   1217 });
   1218 
   1219 test('a disabled project refuses triggers', async () => {
   1220   await withHarness({}, async (h) => {
   1221     await h.services.projects.setEnabled('demo', false);
   1222     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1223     assert.equal(res.statusCode, 409);
   1224   });
   1225 });
   1226 
   1227 test('a trigger for an unknown project is a 404', async () => {
   1228   await withHarness({}, async (h) => {
   1229     const res = await h.app.inject({
   1230       method: 'POST',
   1231       url: '/api/v1/projects/absent/trigger',
   1232       headers: { 'content-type': 'application/json' },
   1233       payload: JSON.stringify({ sha: h.sha }),
   1234     });
   1235     assert.equal(res.statusCode, 404);
   1236   });
   1237 });
   1238 
   1239 test('a commit that is not in the repository is refused', async () => {
   1240   await withHarness({}, async (h) => {
   1241     const res = await h.trigger({ sha: 'b'.repeat(40), ref: 'refs/heads/main' });
   1242     assert.equal(res.statusCode, 200);
   1243     const job = await h.waitForJob(res.json().data.job_id);
   1244     assert.equal(job.state, 'failed');
   1245     assert.match(job.error ?? '', /not present in the mirror/);
   1246   });
   1247 });
   1248 
   1249 test('a task is told to fetch an archive, and never the repository itself', async () => {
   1250   await withHarness({}, async (h) => {
   1251     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1252     await h.waitForJob(res.json().data.job_id);
   1253     const task = await claimNamed(h, 'lint', {});
   1254 
   1255     assert.match(task.endpoints.source, /\/source\.tar\.gz$/);
   1256 
   1257     // A worker holds no repository credentials and can reach no ref other
   1258     // than the commit it was given work for, so the repository location
   1259     // has no business being in the payload.
   1260     assert.equal(task.source, undefined, 'there is one way to get the source, so there is no mode');
   1261     assert.ok(!JSON.stringify(task).includes(h.repoDir), 'the repository path must not reach the worker');
   1262   });
   1263 });
   1264 
   1265 test('the source archive is the bare tree, with no wrapping directory', async () => {
   1266   // The worker hands it to docker cp with the working directory as the
   1267   // destination, and docker creates that directory as it extracts. A
   1268   // wrapping directory in the archive would land one level too deep.
   1269   await withHarness({}, async (h) => {
   1270     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1271     await h.waitForJob(res.json().data.job_id);
   1272     const task = await claimNamed(h, 'lint', {});
   1273 
   1274     const archive = await h.app.inject({
   1275       method: 'GET',
   1276       url: new URL(task.endpoints.source).pathname,
   1277       headers: h.auth,
   1278     });
   1279     assert.equal(archive.statusCode, 200);
   1280 
   1281     // spawnSync rather than the promisified execFile, which has no way to
   1282     // supply stdin and simply waits for input that never comes.
   1283     const { spawnSync } = await import('node:child_process');
   1284     const listing = spawnSync('tar', ['-tzf', '-'], {
   1285       input: archive.rawPayload,
   1286       encoding: 'utf8',
   1287     });
   1288     assert.equal(listing.status, 0, `tar failed: ${listing.stderr}`);
   1289 
   1290     const entries = listing.stdout.split('\n').filter(Boolean);
   1291     assert.ok(entries.length > 0, 'the archive should not be empty');
   1292     assert.ok(
   1293       entries.includes('.conductor.yml'),
   1294       `the pipeline should sit at the root of the archive, got:\n${listing.stdout}`,
   1295     );
   1296   });
   1297 });
   1298 
   1299 test('the working directory is resolved from the pipeline, then the project', async () => {
   1300   const pipeline = [
   1301     'version: 1',
   1302     'workdir: /usr/src/app',
   1303     'defaults:',
   1304     '  image: alpine:3',
   1305     'tasks:',
   1306     '  build:',
   1307     "    script: ['make']",
   1308     '',
   1309   ].join('\n');
   1310 
   1311   await withHarness({ pipeline }, async (h) => {
   1312     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1313     await h.waitForJob(res.json().data.job_id);
   1314     const task = await claimNamed(h, 'build', {});
   1315     assert.equal(task.workdir, '/usr/src/app', 'the repository has the final say');
   1316   });
   1317 
   1318   // With the repository silent, the project decides.
   1319   await withHarness({}, async (h) => {
   1320     await h.services.db.run(
   1321       'UPDATE projects SET workdir = {dir} WHERE id = {id}',
   1322       { dir: '/srv/build', id: h.project.id }
   1323     );
   1324     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1325     await h.waitForJob(res.json().data.job_id);
   1326     const task = await claimNamed(h, 'lint', {});
   1327     assert.equal(task.workdir, '/srv/build');
   1328   });
   1329 
   1330   // With neither, there is a default.
   1331   await withHarness({}, async (h) => {
   1332     const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1333     await h.waitForJob(res.json().data.job_id);
   1334     const task = await claimNamed(h, 'lint', {});
   1335     assert.equal(task.workdir, '/work');
   1336   });
   1337 });
   1338 
   1339 test('an unusable working directory is refused rather than sanitised', async () => {
   1340   for (const workdir of ['relative/path', '/', '/a/../b', '/has space']) {
   1341     const pipeline = [
   1342       'version: 1',
   1343       `workdir: ${JSON.stringify(workdir)}`,
   1344       'defaults:',
   1345       '  image: alpine:3',
   1346       'tasks:',
   1347       '  build:',
   1348       "    script: ['make']",
   1349       '',
   1350     ].join('\n');
   1351 
   1352     await withHarness({ pipeline }, async (h) => {
   1353       const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
   1354       assert.equal(res.statusCode, 200);
   1355       const job = await h.waitForJob(res.json().data.job_id);
   1356       assert.equal(job.state, 'failed', `expected ${JSON.stringify(workdir)} to be refused`);
   1357       assert.ok((job.error ?? '').includes('workdir'), `error should mention workdir: ${job.error}`);
   1358     });
   1359   }
   1360 });
   1361 
   1362 test('the trigger secret is not stored in the clear', async () => {
   1363   await withHarness({}, async (h) => {
   1364     const row = await h.services.db.get('SELECT trigger_secret FROM projects WHERE id = {id}', { id: 'demo' });
   1365     assert.ok(row.trigger_secret.startsWith('v1.'), 'expected an encrypted value');
   1366     assert.ok(!row.trigger_secret.includes('test-secret'));
   1367     assert.equal(h.services.projects.triggerSecret(await h.services.projects.get('demo')), 'test-secret');
   1368   });
   1369 });
   1370 
   1371 test('worker tokens are stored only as hashes', async () => {
   1372   await withHarness({}, async (h) => {
   1373     const rows = await h.services.db.all('SELECT token_hash FROM worker_tokens');
   1374     assert.ok(rows.length > 0);
   1375     for (const row of rows) {
   1376       assert.match(row.token_hash, /^[0-9a-f]{64}$/);
   1377       assert.notEqual(row.token_hash, h.worker.token);
   1378     }
   1379   });
   1380 });
   1381 
   1382 test('after runs its dependent whether the dependency passed or failed', async () => {
   1383   const pipeline = `
   1384 version: 1
   1385 defaults:
   1386   image: alpine
   1387 tasks:
   1388   build:
   1389     script: ['true']
   1390   test:
   1391     script: ['false']
   1392     needs: [build]
   1393   notify:
   1394     script: ['true']
   1395     after: [test]
   1396 `;
   1397   await withHarness({ pipeline }, async (h) => {
   1398     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1399     await h.waitForJob(job);
   1400 
   1401     const build = await claimNamed(h, 'build', {});
   1402     await finish(h, build.id, { success: true });
   1403 
   1404     // Draining yields test alone: notify waits for it to finish, exactly as
   1405     // needs would. after asks for less, not for nothing.
   1406     const released = await drain(h, {});
   1407     assert.ok(released.test, 'test should be released by build');
   1408     assert.ok(!released.notify, 'notify should wait while test runs');
   1409 
   1410     const outcome = await finish(h, released.test.id, { success: false, exitCode: 1 });
   1411     // The failure has nothing downstream of it to skip.
   1412     assert.deepEqual(outcome.skipped, []);
   1413 
   1414     const notify = await claimNamed(h, 'notify', {});
   1415     assert.ok(notify, 'notify should run after a failed dependency');
   1416     const last = await finish(h, notify.id, { success: true });
   1417 
   1418     // The failure still fails the job. after decides what runs, not what counts.
   1419     assert.equal(last.job_state, 'failed');
   1420     const tasks = tasksByName(await jobState(h, job));
   1421     assert.equal(tasks.notify.state, 'success');
   1422     assert.equal(tasks.test.state, 'failed');
   1423   });
   1424 });
   1425 
   1426 test('after runs its dependent even when the dependency was skipped', async () => {
   1427   const pipeline = `
   1428 version: 1
   1429 defaults:
   1430   image: alpine
   1431 tasks:
   1432   build:
   1433     script: ['false']
   1434   test:
   1435     script: ['true']
   1436     needs: [build]
   1437   cleanup:
   1438     script: ['true']
   1439     after: [test]
   1440 `;
   1441   await withHarness({ pipeline }, async (h) => {
   1442     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1443     await h.waitForJob(job);
   1444 
   1445     const build = await claimNamed(h, 'build', {});
   1446     const outcome = await finish(h, build.id, { success: false, exitCode: 1 });
   1447     // test is skipped with its dependency, cleanup is not.
   1448     assert.deepEqual(outcome.skipped, [(await jobState(h, job)).tasks.find((t) => t.name === 'test').id]);
   1449 
   1450     const cleanup = await claimNamed(h, 'cleanup', {});
   1451     assert.ok(cleanup, 'cleanup should run once test settled as skipped');
   1452     await finish(h, cleanup.id, { success: true });
   1453 
   1454     const tasks = tasksByName(await jobState(h, job));
   1455     assert.equal(tasks.test.state, 'skipped');
   1456     assert.equal(tasks.cleanup.state, 'success');
   1457   });
   1458 });
   1459 
   1460 // --- task outputs and set-state ---
   1461 
   1462 test('an output reaches the task that reads it, and implies the dependency', async () => {
   1463   const pipeline = `
   1464 version: 1
   1465 defaults:
   1466   image: alpine
   1467 tasks:
   1468   build:
   1469     script: ['true']
   1470   deploy:
   1471     script: ['ship \${{ tasks.build.outputs.tag }}']
   1472     env:
   1473       TAG: '\${{ tasks.build.outputs.tag }}'
   1474 `;
   1475   await withHarness({ pipeline }, async (h) => {
   1476     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1477     await h.waitForJob(job);
   1478 
   1479     // deploy was never declared to need build; reading its output says so.
   1480     const tasks = tasksByName(await jobState(h, job));
   1481     assert.deepEqual(tasks.deploy.needs, [tasks.build.id]);
   1482 
   1483     const build = await claimNamed(h, 'build', {});
   1484     assert.ok(build, 'build runs first');
   1485     assert.equal(await claimNamed(h, 'deploy', {}), null, 'deploy waits for it');
   1486 
   1487     await finish(h, build.id, {
   1488       success: true,
   1489       outputs: [{ name: 'tag', value: 'v1.2.3', masked: false }],
   1490     });
   1491 
   1492     const deploy = await claimNamed(h, 'deploy', {});
   1493     assert.deepEqual(deploy.script, ['ship v1.2.3']);
   1494     assert.equal(deploy.env.TAG, 'v1.2.3');
   1495   });
   1496 });
   1497 
   1498 test('a masked output stays masked in the log of whatever reads it', async () => {
   1499   const pipeline = `
   1500 version: 1
   1501 defaults:
   1502   image: alpine
   1503 tasks:
   1504   build:
   1505     script: ['true']
   1506   deploy:
   1507     script: ['ship \${{ tasks.build.outputs.token }}']
   1508 `;
   1509   await withHarness({ pipeline }, async (h) => {
   1510     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1511     await h.waitForJob(job);
   1512 
   1513     const build = await claimNamed(h, 'build', {});
   1514     await finish(h, build.id, {
   1515       success: true,
   1516       outputs: [{ name: 'token', value: 'sup3rsecret', masked: true }],
   1517     });
   1518 
   1519     const deploy = await claimNamed(h, 'deploy', {});
   1520     assert.deepEqual(deploy.script, ['ship sup3rsecret'], 'the value is still delivered');
   1521     assert.ok(
   1522       deploy.masked.includes('sup3rsecret'),
   1523       'and the worker is told to redact it from this task too'
   1524     );
   1525   });
   1526 });
   1527 
   1528 test('outputs rest sealed rather than readable in the table', async () => {
   1529   const pipeline = `
   1530 version: 1
   1531 tasks:
   1532   build:
   1533     image: alpine
   1534     script: ['true']
   1535 `;
   1536   await withHarness({ pipeline }, async (h) => {
   1537     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1538     await h.waitForJob(job);
   1539 
   1540     const build = await claimNamed(h, 'build', {});
   1541     await finish(h, build.id, {
   1542       success: true,
   1543       outputs: [{ name: 'tag', value: 'v1.2.3', masked: false }],
   1544     });
   1545 
   1546     const row = await h.services.db.get(
   1547       'SELECT value FROM task_outputs WHERE task_id = {id}', { id: build.id }
   1548     );
   1549     assert.ok(!row.value.includes('v1.2.3'), `stored in the clear: ${row.value}`);
   1550   });
   1551 });
   1552 
   1553 test('a task that reads an output that was never produced fails before it runs', async () => {
   1554   const pipeline = `
   1555 version: 1
   1556 defaults:
   1557   image: alpine
   1558 tasks:
   1559   build:
   1560     script: ['true']
   1561   deploy:
   1562     script: ['ship \${{ tasks.build.outputs.tag }}']
   1563 `;
   1564   await withHarness({ pipeline }, async (h) => {
   1565     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1566     await h.waitForJob(job);
   1567 
   1568     const build = await claimNamed(h, 'build', {});
   1569     await finish(h, build.id, { success: true });
   1570 
   1571     assert.equal(await claimNamed(h, 'deploy', {}), null, 'nothing to hand a worker');
   1572 
   1573     const tasks = tasksByName(await jobState(h, job));
   1574     assert.equal(tasks.deploy.state, 'failed');
   1575     assert.match(tasks.deploy.error, /produced no output "tag"/);
   1576     assert.equal(tasks.deploy.started_at, null, 'it never started');
   1577     assert.equal((await jobState(h, job)).job.state, 'failed');
   1578   });
   1579 });
   1580 
   1581 test('set-state success overrides a non-zero exit', async () => {
   1582   const pipeline = `
   1583 version: 1
   1584 tasks:
   1585   check:
   1586     image: alpine
   1587     script: ['false']
   1588 `;
   1589   await withHarness({ pipeline }, async (h) => {
   1590     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1591     await h.waitForJob(job);
   1592 
   1593     const check = await claimNamed(h, 'check', {});
   1594     const outcome = await finish(h, check.id, { success: false, state: 'success', exitCode: 1 });
   1595 
   1596     assert.equal(outcome.state, 'success');
   1597     assert.equal(outcome.job_state, 'success');
   1598     const tasks = tasksByName(await jobState(h, job));
   1599     assert.equal(tasks.check.exit_code, 1, 'the exit code is still recorded');
   1600   });
   1601 });
   1602 
   1603 test('a skipped task is not a failure, and what needed it still runs', async () => {
   1604   const pipeline = `
   1605 version: 1
   1606 defaults:
   1607   image: alpine
   1608 tasks:
   1609   package:
   1610     script: ['true']
   1611   index:
   1612     script: ['reindex']
   1613     needs: [package]
   1614 `;
   1615   await withHarness({ pipeline }, async (h) => {
   1616     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1617     await h.waitForJob(job);
   1618 
   1619     const pkg = await claimNamed(h, 'package', {});
   1620     const outcome = await finish(h, pkg.id, { success: false, state: 'skipped', exitCode: 0 });
   1621     assert.equal(outcome.state, 'skipped');
   1622     assert.equal(outcome.job_state, null, 'the job is not settled: index still has to run');
   1623 
   1624     const index = await claimNamed(h, 'index', {});
   1625     assert.ok(index, 'a skip is not a failure, so the indexer still runs');
   1626     const last = await finish(h, index.id, { success: true });
   1627 
   1628     assert.equal(last.job_state, 'success');
   1629     const tasks = tasksByName(await jobState(h, job));
   1630     assert.equal(tasks.package.state, 'skipped');
   1631   });
   1632 });
   1633 
   1634 // Regression: settling inside the completing transaction stranded jobs.
   1635 test('a job whose settle was lost is recovered by the sweep', async () => {
   1636   const pipeline = `
   1637 version: 1
   1638 defaults:
   1639   image: alpine
   1640 tasks:
   1641   build:
   1642     script: ['true']
   1643 `;
   1644   await withHarness({ pipeline }, async (h) => {
   1645     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1646     await h.waitForJob(job);
   1647 
   1648     const build = await claimNamed(h, 'build', {});
   1649     assert.equal((await finish(h, build.id, { success: true })).job_state, 'success');
   1650 
   1651     await h.services.db.run(
   1652       "UPDATE jobs SET state = 'running', finished_at = NULL WHERE id = {id}",
   1653       { id: job }
   1654     );
   1655 
   1656     const settled = await h.services.scheduler.settleStalled();
   1657     assert.deepEqual(settled, [{ id: job, state: 'success' }]);
   1658 
   1659     const row = await h.services.db.get('SELECT state, finished_at FROM jobs WHERE id = {id}',
   1660       { id: job });
   1661     assert.equal(row.state, 'success');
   1662     assert.ok(row.finished_at > 0, 'the recovered job gets a finish time');
   1663   });
   1664 });
   1665 
   1666 test('the sweep leaves a job that still has work to do alone', async () => {
   1667   const pipeline = `
   1668 version: 1
   1669 defaults:
   1670   image: alpine
   1671 tasks:
   1672   build:
   1673     script: ['true']
   1674   publish:
   1675     script: ['true']
   1676     needs: [build]
   1677 `;
   1678   await withHarness({ pipeline }, async (h) => {
   1679     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1680     await h.waitForJob(job);
   1681 
   1682     const build = await claimNamed(h, 'build', {});
   1683     await finish(h, build.id, { success: true });
   1684 
   1685     assert.deepEqual(await h.services.scheduler.settleStalled(), []);
   1686     const row = await h.services.db.get('SELECT state FROM jobs WHERE id = {id}', { id: job });
   1687     assert.equal(row.state, 'running');
   1688   });
   1689 });
   1690 
   1691 test('a skipped task that owed a value fails the one that needed it', async () => {
   1692   const pipeline = `
   1693 version: 1
   1694 defaults:
   1695   image: alpine
   1696 tasks:
   1697   package:
   1698     script: ['true']
   1699   index:
   1700     script: ['reindex \${{ tasks.package.outputs.path }}']
   1701 `;
   1702   await withHarness({ pipeline }, async (h) => {
   1703     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1704     await h.waitForJob(job);
   1705 
   1706     const pkg = await claimNamed(h, 'package', {});
   1707     await finish(h, pkg.id, { success: false, state: 'skipped', exitCode: 0 });
   1708 
   1709     assert.equal(await claimNamed(h, 'index', {}), null);
   1710     const tasks = tasksByName(await jobState(h, job));
   1711     assert.equal(tasks.index.state, 'failed');
   1712     assert.match(tasks.index.error, /produced no output "path"/);
   1713   });
   1714 });
   1715 
   1716 test('set-state failed fails a task that exited zero', async () => {
   1717   const pipeline = `
   1718 version: 1
   1719 tasks:
   1720   check:
   1721     image: alpine
   1722     script: ['true']
   1723 `;
   1724   await withHarness({ pipeline }, async (h) => {
   1725     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1726     await h.waitForJob(job);
   1727 
   1728     const check = await claimNamed(h, 'check', {});
   1729     const outcome = await finish(h, check.id, { success: false, state: 'failed', exitCode: 0 });
   1730     assert.equal(outcome.state, 'failed');
   1731     assert.equal(outcome.job_state, 'failed');
   1732   });
   1733 });
   1734 
   1735 test('a worker that reports no state at all is still understood', async () => {
   1736   const pipeline = `
   1737 version: 1
   1738 tasks:
   1739   check:
   1740     image: alpine
   1741     script: ['true']
   1742 `;
   1743   await withHarness({ pipeline }, async (h) => {
   1744     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1745     await h.waitForJob(job);
   1746 
   1747     const check = await claimNamed(h, 'check', {});
   1748     const res = await h.app.inject({
   1749       method: 'POST',
   1750       url: `/api/v1/tasks/${check.id}/complete`,
   1751       headers: { ...h.auth, 'content-type': 'application/json' },
   1752       payload: JSON.stringify({ success: true, exit_code: 0 }),
   1753     });
   1754     assert.equal(data(res).state, 'success');
   1755   });
   1756 });
   1757 
   1758 test('a retry drops what the failed attempt produced', async () => {
   1759   const pipeline = `
   1760 version: 1
   1761 tasks:
   1762   flaky:
   1763     image: alpine
   1764     script: ['true']
   1765     max_attempts: 2
   1766 `;
   1767   await withHarness({ pipeline }, async (h) => {
   1768     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1769     await h.waitForJob(job);
   1770 
   1771     const first = await claimNamed(h, 'flaky', {});
   1772     await finish(h, first.id, {
   1773       success: false,
   1774       exitCode: 1,
   1775       outputs: [{ name: 'tag', value: 'from-the-bad-run', masked: false }],
   1776     });
   1777 
   1778     const rows = await h.services.db.all(
   1779       'SELECT name FROM task_outputs WHERE task_id = {id}', { id: first.id }
   1780     );
   1781     assert.deepEqual(rows, [], 'a requeued task carries nothing forward');
   1782   });
   1783 });
   1784 
   1785 test('after edges are stored as their own kind', async () => {
   1786   const pipeline = `
   1787 version: 1
   1788 defaults:
   1789   image: alpine
   1790 tasks:
   1791   a:
   1792     script: ['true']
   1793   b:
   1794     script: ['true']
   1795     needs: [a]
   1796   c:
   1797     script: ['true']
   1798     after: [a]
   1799 `;
   1800   await withHarness({ pipeline }, async (h) => {
   1801     const job = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
   1802     await h.waitForJob(job);
   1803 
   1804     const tasks = tasksByName(await jobState(h, job));
   1805     assert.deepEqual(tasks.b.needs, [tasks.a.id]);
   1806     assert.deepEqual(tasks.b.after, []);
   1807     assert.deepEqual(tasks.c.needs, []);
   1808     assert.deepEqual(tasks.c.after, [tasks.a.id]);
   1809   });
   1810 });