Files
rocko 4bdc7e0e86 processing: inspectable per-run stop seam for lifecycle fire
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.
2026-10-21 22:42:12 -05:00

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 };
}