client.js (3898B)
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 } = {}) { 10 super(message); 11 this.name = 'ConductorError'; 12 this.status = status; 13 this.body = body; 14 } 15 } 16 17 export function createClient(cfg) { 18 const base = cfg.conductor_url; 19 const auth = { authorization: `Bearer ${cfg.token}` }; 20 21 async function readError(res, action) { 22 let body = null; 23 let detail = ''; 24 try { 25 const text = await res.text(); 26 detail = text.slice(0, 512); 27 body = JSON.parse(text); 28 } catch { 29 // A non JSON body is still worth reporting as text. 30 } 31 return new ConductorError(`${action} failed: ${res.status} ${res.statusText} ${detail}`.trim(), { 32 status: res.status, 33 body, 34 }); 35 } 36 37 return { 38 // Returns a task, or null when there is nothing to do. This is the only 39 // URL the worker knows; everything else it needs arrives with the task. 40 async claim({ arches, features, name }) { 41 const res = await fetch(`${base}/api/v1/tasks/claim`, { 42 method: 'POST', 43 headers: { ...auth, 'content-type': 'application/json' }, 44 body: JSON.stringify({ arches, features, name }), 45 }); 46 if (res.status === 204) return null; 47 if (!res.ok) throw await readError(res, 'claim'); 48 return (await res.json()).task; 49 }, 50 51 // The tree at the task's commit. Returned as a web stream so it can be 52 // piped straight into tar without buffering. 53 async source(url) { 54 const res = await fetch(url, { headers: auth }); 55 if (!res.ok) throw await readError(res, 'source download'); 56 return res.body; 57 }, 58 59 // Returns the new log size. A 409 means the conductor and the worker 60 // disagree about the offset, and carries the offset to resume from. 61 async appendLog(url, chunk, offset) { 62 const res = await fetch(url, { 63 method: 'POST', 64 headers: { ...auth, 'content-type': 'application/octet-stream', 'x-log-offset': String(offset) }, 65 body: chunk, 66 }); 67 68 if (res.status === 409) { 69 const body = await res.json().catch(() => ({})); 70 return { conflict: true, expected: body.expected_offset ?? 0 }; 71 } 72 if (!res.ok) throw await readError(res, 'log append'); 73 return await res.json(); 74 }, 75 76 async uploadArtifact(url, relPath, body, size) { 77 const res = await fetch(url, { 78 method: 'POST', 79 headers: { 80 ...auth, 81 'content-type': 'application/octet-stream', 82 'content-length': String(size), 83 'x-artifact-path': relPath, 84 }, 85 body, 86 duplex: 'half', 87 }); 88 if (!res.ok) throw await readError(res, `artifact upload (${relPath})`); 89 return await res.json(); 90 }, 91 92 // Reports that the task is alive, and learns whether it should stop. 93 async heartbeat(url) { 94 const res = await fetch(url, { 95 method: 'POST', 96 headers: { ...auth, 'content-type': 'application/json' }, 97 body: '{}', 98 }); 99 if (res.status === 404) return { cancelled: true, gone: true }; 100 if (!res.ok) throw await readError(res, 'heartbeat'); 101 return await res.json(); 102 }, 103 104 async done(url, { success, exitCode, error }) { 105 const res = await fetch(url, { 106 method: 'POST', 107 headers: { ...auth, 'content-type': 'application/json' }, 108 body: JSON.stringify({ success, exit_code: exitCode ?? null, error: error ?? null }), 109 }); 110 if (!res.ok) throw await readError(res, 'done'); 111 return await res.json(); 112 }, 113 }; 114 }