conductor

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

client.js (4418B)


      1 // src/worker/client.js - talking to the conductor
      2 //
      3 // Built on fetch, so there is nothing to install. Every call carries the
      4 // worker token. Network errors are surfaced rather than retried here; the
      5 // agent decides what a failure means, since a failed claim and a failed log
      6 // append want very different handling.
      7 
      8 export class ConductorError extends Error {
      9   constructor(message, { status = 0, body = null, cause = null } = {}) {
     10     super(message);
     11     this.name = 'ConductorError';
     12     this.status = status;
     13     this.body = body;
     14     // The conductor's own name for what went wrong, where it sent one.
     15     this.cause = cause;
     16   }
     17 }
     18 
     19 export function createClient(cfg) {
     20   const base = cfg.conductor_url;
     21   const auth = { authorization: `Bearer ${cfg.token}` };
     22 
     23   // The envelope is unwrapped here and nowhere else, so the rest of the
     24   // worker keeps dealing in plain task, log and outcome objects.
     25   async function data(res) {
     26     const body = await res.json();
     27     return body?.data;
     28   }
     29 
     30   async function readError(res, action) {
     31     let body = null;
     32     let detail = '';
     33     try {
     34       const text = await res.text();
     35       detail = text.slice(0, 512);
     36       body = JSON.parse(text);
     37     } catch {
     38       // A non JSON body is still worth reporting as text.
     39     }
     40     const error = body?.error;
     41     return new ConductorError(`${action} failed: ${res.status} ${res.statusText} ${detail}`.trim(), {
     42       status: res.status,
     43       body,
     44       cause: error?.name ?? null,
     45     });
     46   }
     47 
     48   return {
     49     // Returns a task, or null when there is nothing to do. This is the only
     50     // URL the worker knows; everything else it needs arrives with the task.
     51     async claim({ arches, features, name }) {
     52       const res = await fetch(`${base}/api/v1/tasks/claim`, {
     53         method: 'POST',
     54         headers: { ...auth, 'content-type': 'application/json' },
     55         body: JSON.stringify({ arches, features, name }),
     56       });
     57       if (res.status === 204) return null;
     58       if (!res.ok) throw await readError(res, 'claim');
     59       return (await data(res)).task;
     60     },
     61 
     62     // The tree at the task's commit. Returned as a web stream so it can be
     63     // piped straight into tar without buffering.
     64     async source(url) {
     65       const res = await fetch(url, { headers: auth });
     66       if (!res.ok) throw await readError(res, 'source download');
     67       return res.body;
     68     },
     69 
     70     // Returns the new log size. A 409 means the conductor and the worker
     71     // disagree about the offset, and carries the offset to resume from.
     72     async appendLog(url, chunk, offset) {
     73       const res = await fetch(url, {
     74         method: 'POST',
     75         headers: { ...auth, 'content-type': 'application/octet-stream', 'x-log-offset': String(offset) },
     76         body: chunk,
     77       });
     78 
     79       if (res.status === 409) {
     80         const body = await res.json().catch(() => ({}));
     81         return { conflict: true, expected: body.error?.details?.expected_offset ?? 0 };
     82       }
     83       if (!res.ok) throw await readError(res, 'log append');
     84       return await data(res);
     85     },
     86 
     87     async uploadArtifact(url, relPath, body, size) {
     88       const res = await fetch(url, {
     89         method: 'POST',
     90         headers: {
     91           ...auth,
     92           'content-type': 'application/octet-stream',
     93           'content-length': String(size),
     94           'x-artifact-path': relPath,
     95         },
     96         body,
     97         duplex: 'half',
     98       });
     99       if (!res.ok) throw await readError(res, `artifact upload (${relPath})`);
    100       return await data(res);
    101     },
    102 
    103     // Reports that the task is alive, and learns whether it should stop.
    104     async heartbeat(url) {
    105       const res = await fetch(url, {
    106         method: 'POST',
    107         headers: { ...auth, 'content-type': 'application/json' },
    108         body: '{}',
    109       });
    110       if (res.status === 404) return { cancelled: true, gone: true };
    111       if (!res.ok) throw await readError(res, 'heartbeat');
    112       return await data(res);
    113     },
    114 
    115     async done(url, { success, state = null, exitCode, error, outputs = [] }) {
    116       const res = await fetch(url, {
    117         method: 'POST',
    118         headers: { ...auth, 'content-type': 'application/json' },
    119         body: JSON.stringify({
    120           success,
    121           state,
    122           exit_code: exitCode ?? null,
    123           error: error ?? null,
    124           outputs,
    125         }),
    126       });
    127       if (!res.ok) throw await readError(res, 'done');
    128       return await data(res);
    129     },
    130   };
    131 }