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 }