Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
95 lines
3.6 KiB
JavaScript
95 lines
3.6 KiB
JavaScript
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { currentProcessing } from '../../processing-control/src/effects.mjs';
|
|
// Vikunja v2 HTTP client. It never throws: a network error or timeout is status 0, so
|
|
// credentials.use() gets a result back instead of flattening a throw to service-failed.
|
|
// The token goes only into the Authorization header; nothing here logs.
|
|
export function apiRoot(base) {
|
|
let u;
|
|
try {
|
|
u = new URL(base);
|
|
} catch {
|
|
throw new BusError('tracker-config');
|
|
}
|
|
if (
|
|
!['http:', 'https:'].includes(u.protocol) ||
|
|
u.username ||
|
|
u.password ||
|
|
u.search ||
|
|
u.hash ||
|
|
!['', '/'].includes(u.pathname)
|
|
)
|
|
throw new BusError('tracker-config');
|
|
return u.origin + '/api/v2';
|
|
}
|
|
// URLSearchParams writes a space as '+'; the probes sent %20, so this does too.
|
|
const query = (q) =>
|
|
Object.entries(q ?? {})
|
|
.filter(([, v]) => v !== undefined && v !== null)
|
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
.join('&');
|
|
export function client({ baseUrl, fetch = globalThis.fetch, timeoutMs = 15000 }) {
|
|
const root = apiRoot(baseUrl);
|
|
return async function call(token, method, path, { body, query: q, etag } = {}) {
|
|
const processing = currentProcessing();
|
|
processing?.guard();
|
|
const qs = query(q);
|
|
const headers = { Accept: 'application/json' };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
if (etag) headers['If-None-Match'] = etag;
|
|
try {
|
|
const r = await fetch(root + path + (qs ? '?' + qs : ''), {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
redirect: 'error',
|
|
signal: processing ? AbortSignal.any([processing.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const text = await r.text();
|
|
let json = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return { status: r.status, json, etag: r.headers.get('etag') };
|
|
} catch {
|
|
return { status: 0, json: null, etag: null };
|
|
}
|
|
};
|
|
}
|
|
// What a status means to S3. 5xx and network errors on a write leave the outcome unknown.
|
|
export function outcome(r) {
|
|
const s = r.status;
|
|
if ((s >= 200 && s < 300) || s === 304) return 'ok';
|
|
if (s === 401) return 'unauthorized';
|
|
if (s === 403) return 'forbidden';
|
|
if (s === 404) return r.json?.code === 4002 ? 'not-found' : 'missing';
|
|
if (s === 400 || s === 422) return 'invalid';
|
|
if (s === 429) return 'rate-limited';
|
|
if (s === 0 || s >= 500) return 'uncertain';
|
|
return 'unexpected';
|
|
}
|
|
const CODES = {
|
|
unauthorized: 'tracker-unauthorized',
|
|
forbidden: 'tracker-forbidden',
|
|
'not-found': 'task-not-found',
|
|
missing: 'tracker-not-found',
|
|
invalid: 'tracker-invalid',
|
|
'rate-limited': 'tracker-rate-limited',
|
|
uncertain: 'tracker-unavailable',
|
|
unexpected: 'tracker-unexpected',
|
|
};
|
|
export const refusal = (r) => CODES[outcome(r)] ?? 'tracker-unexpected';
|
|
// Every item of a paginated v2 list. `page` is 1-based; a short page ends the walk.
|
|
export async function all(call, token, path, q = {}, per = 50, max = 200) {
|
|
const items = [];
|
|
for (let page = 1; page <= max; page++) {
|
|
const r = await call(token, 'GET', path, { query: { ...q, per_page: per, page } });
|
|
if (outcome(r) !== 'ok' || !Array.isArray(r.json?.items)) return { ok: false, r, items };
|
|
items.push(...r.json.items);
|
|
if (r.json.items.length < per || page >= (r.json.total_pages ?? Infinity)) return { ok: true, r, items };
|
|
}
|
|
return { ok: false, r: { status: 0, json: null }, items };
|
|
}
|