openvibers/openvibe.sdk Commits

openvibe-sdk/tools: the Tools platform API client (registry, run API) and jobs retry/references, tested against a contract-checked stand-in

OP OpenVibers committed 23 Sept 2026 · 03e55f2f3e · 27 files, +2517 −122

createToolsClient(client, { baseUrl, service, audience, anonymousReads }) -> { list, get, schema, run, jobs }
(ADR-027, openvibe-contracts v0.33.0):
- list({ family, q, execution, api, status }) answers tools.tool-list@1 (api is sent as true|false);
  get(id) the descriptor, schema(id) { $schema, $id, $defs }, both null on 404. Registry reads send
  no token unless anonymousReads: false.
- run(id, input, { files, waitMs, idempotencyKey, signal, timeoutMs }) posts tools.run-request@1:
  JSON, or multipart when there are uploads (input, wait_ms and the references' JSON as text parts,
  uploads as file parts). Files are uploads (the jobs client's shapes: Blob/File or { name, data,
  type }) and references ({ media_id }, { job_id, index }). A job still queued or running (202, or
  a replay) resolves to { state, tool, job, location }; a finished run to { state: 'succeeded',
  tool, result, took_ms, job?, location? }; both carry idempotencyKey, replayed and a
  non-enumerable wait(). A run that finished failed or cancelled throws ToolRunError, an
  OpenVibeError with the tool's problem+json mapped plus state, tool, job and run. The key is
  always sent (generated when omitted), so 429 Retry-After and 5xx are retried by the core.
- jobs is openvibe-sdk/jobs on the same origin: the gateway's /api/v1/jobs facade.

openvibe-sdk/jobs: retry(id) -> { job, replayed }, reference(id, ref) and unreference(id, ref) ->
the job. Without a baseUrl it goes to the tools origin (the gateway facade, Tools S6); an explicit
baseUrl still wins. Uploads share one helper with tools (src/core/form.js).

Mock platform: { tools: true | { descriptors, handlers, mediaObjects } } serves the registry and
run routes on origins.tools (defaults dns, jsonminify, png, port, yt, protectpdf; tiers,
probes, refusal codes, idempotent job runs, wait_ms, file references), addTool(),
addMediaObject(), state.tools. Mock jobs now answer tools.job@1 exactly (problem+json errors,
sha256/storage/media on files, retry_of, retried_by, references, links.retry), serve retry and
references, and origins.tools is the facade that also sees the satellites' jobs. The mock fetch
rejects on an aborted signal like fetch does.

openvibe-contracts v0.33.0 is a devDependency (tarball tag pin): test/tools.test.js validates every
run request and every answer with it, types/contracts.d.ts is re-copied from it (modules.namespace
doc, plus ToolDescriptor, ToolList, ToolsRunRequest, ToolsRun, ToolsJob, ToolsJobRequest), and CI
no longer clones Contracts v0.28.0.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex fd0aad8..3806391 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -12,10 +12,10 @@ jobs:
with:
node-version: 22.22.1 # production services run Node 22
cache: npm
-- # better-sqlite3 (devDependency) backs the outbox/inbox tests; without an install they fail.
++ # devDependencies: better-sqlite3 backs the outbox/inbox tests; openvibe-contracts (pinned tag
++ # tarball) validates the tools tests' requests and answers, and types.test.js compares
++ # types/contracts.d.ts with it.
- run: npm ci --no-audit --no-fund
-- # types.test.js compares types/contracts.d.ts with the pinned Contracts release
-- - run: git clone --depth 1 --branch v0.28.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
- run: npm test # includes the pack test (npm pack + install into a temp project)
- run: node scripts/esm.js --check
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
ddiff --git a/browser.js b/browser.js
iindex a2f5dac..3321253 100644
---- a/browser.js
++++ b/browser.js
@@@ -14,5 +14,6 @@ module.exports = {
media: require('./src/media'),
community: require('./src/community'),
jobs: require('./src/jobs'),
++ tools: require('./src/tools'),
projects: require('./src/projects'),
};
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 6a752a8..a75afa2 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,6 +1,6 @@
// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.5.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -21,7 +21,8 @@ module.exports = {
media: __load(12),
community: __load(14),
jobs: __load(15),
-- projects: __load(16),
++ tools: __load(17),
++ projects: __load(18),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -1744,30 +1745,41 @@ module.exports = { createCommunityClient, actingHeaders };
function (module, exports) {
'use strict';
/**
-- * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs
-- * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.
++ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (tools.job@1). Browser-safe; the credentials
++ * decide who owns a job.
*
-- * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
++ * const jobs = createJobsClient(client); // the Tools gateway's /api/v1/jobs facade
* const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },
* files: [{ name: 'a.png', data: bytes }], idempotencyKey });
* for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops
* const done = await jobs.get(job.id);
* const res = await jobs.file(job.id, 0); // raw Response: stream or save it
++ * await jobs.reference(job.id, 'community:paste:p_123'); // keep the result while the paste exists
*
-- * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,
-- * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same
-- * answer as for a job that does not exist.
++ * Where: without `baseUrl`, the `tools` service origin from the platform descriptor
++ * (https://openvibe.tools), whose gateway fronts every satellite's jobs under /api/v1/jobs (ADR-027).
++ * That facade comes with the Tools run API (Tools S6); until it is deployed, pass the satellite that
++ * runs the job type: `{ baseUrl: 'https://img.openvibe.tools' }` (img, audio, docs). An explicit
++ * baseUrl always wins.
++ *
++ * Capabilities (audience openvibe.tools): tools.job.create (submit, retry, reference, unreference),
++ * tools.job.read (get, events, file), tools.job.cancel (cancel). Jobs are owner-scoped: another
++ * principal gets 404, the same answer as for a job that does not exist.
*
* POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);
* Idempotency-Key -> 202 new | 200 + Idempotent-Replayed
* GET /api/v1/jobs/:id
* DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished
++ * POST /api/v1/jobs/:id/retry a failed job -> 202 new job | 200 + Idempotent-Replayed (its retry)
++ * PUT /api/v1/jobs/:id/references/:ref keep a succeeded job's result (201 new | 200 existing)
++ * DELETE /api/v1/jobs/:id/references/:ref let it expire again after the last reference
* GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;
* 204 when the job finished and nothing is newer
* GET /api/v1/jobs/:id/files/:n a result file
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
++const { appendFiles } = __load(16);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -1778,12 +1790,6 @@ const sleep = (ms, signal) => new Promise((resolve) => {
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
--function toBlob(data, type) {
-- if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
-- if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);
-- throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');
--}
--
/** Is this job finished (succeeded, failed or cancelled)? */
function isTerminal(job) {
return Boolean(job && TERMINAL.has(job.state));
@@@ -1791,6 +1797,11 @@ function isTerminal(job) {
function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {
const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ const replayedOf = (res) => String(res.headers.get('idempotent-replayed') || '') === 'true';
++ const refPath = (id, ref) => {
++ if (typeof ref !== 'string' || !ref) throw new TypeError('jobs: a reference is a string <service>:<kind>:<id>, e.g. community:paste:p_123');
++ return `/api/v1/jobs/${enc(id)}/references/${enc(ref)}`;
++ };
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
/**
@@@ -1812,17 +1823,12 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
const form = new FormData();
form.append('type', String(type));
form.append('input', JSON.stringify(input));
-- for (const f of list) {
-- const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;
-- const blob = isBlob ? f : toBlob(f && f.data, f && f.type);
-- form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');
-- }
-- opts.form = form;
++ opts.form = appendFiles(form, list, 'jobs.submit');
} else {
opts.json = { type: String(type), input };
}
const res = await call(opts);
-- return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };
++ return { job: res.data, replayed: replayedOf(res), idempotencyKey: key };
}
/** The job, or null when it does not exist or is not yours. */
@@@ -1839,6 +1845,33 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;
}
++ /**
++ * Retry a failed job as a new job (same type, input and files; `retry_of` names the failed one)
++ * -> { job, replayed }. Idempotent on the server: asking again returns that same retry
++ * (`replayed: true`) whatever state it is in, so the call is retried safely without a key.
++ * Refusals: 409 tools.job.not_failed (only failed jobs), 410 tools.job.inputs_gone or
++ * tools.job.retry_gone, 429 tools.job.too_many_active.
++ */
++ async function retry(id, { signal } = {}) {
++ const res = await call({ method: 'POST', path: `/api/v1/jobs/${enc(id)}/retry`, idempotent: true, signal });
++ return { job: res.data, replayed: replayedOf(res) };
++ }
++
++ /**
++ * Keep a succeeded job's result while `ref` (<service>:<kind>:<id>, e.g. community:paste:p_123)
++ * points at it -> the job, whose expires_at is null while any reference remains. Idempotent.
++ * Refusals: 400 tools.job.invalid (a bad ref), 409 tools.job.not_succeeded, tools.job.sandbox
++ * (sandbox results are never kept) or tools.job.too_many_references (50).
++ */
++ async function reference(id, ref, { signal } = {}) {
++ return (await call({ method: 'PUT', path: refPath(id, ref), signal })).data;
++ }
++
++ /** Drop a reference -> the job; after the last one, the result expires again. Idempotent. */
++ async function unreference(id, ref, { signal } = {}) {
++ return (await call({ method: 'DELETE', path: refPath(id, ref), signal })).data;
++ }
++
/**
* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })
* -> async iterator of { id, event, job }
@@@ -1905,12 +1938,315 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;
}
-- return { submit, get, cancel, events, wait, file };
++ return { submit, get, cancel, retry, reference, unreference, events, wait, file };
}
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 16: src/projects.js ─────────────────────────────────────────────
++// ── 16: src/core/form.js ────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * Uploaded files as multipart parts, shared by openvibe-sdk/jobs and openvibe-sdk/tools so both take
++ * the same shapes: a Blob/File, or { name, data: Blob | ArrayBuffer | typed array (Buffer) | string,
++ * type? }. Browser-safe (Blob, FormData). Internal: not a public subpath.
++ */
++
++const isBlob = (x) => typeof Blob !== 'undefined' && x instanceof Blob;
++
++/** Is `f` something appendFiles() can upload? */
++function isUpload(f) {
++ if (isBlob(f)) return true;
++ const d = f && typeof f === 'object' ? f.data : undefined;
++ return isBlob(d) || typeof d === 'string' || d instanceof ArrayBuffer || ArrayBuffer.isView(d);
++}
++
++/** Append each file as a `file` part (Tools reads `file` and `files` parts alike). */
++function appendFiles(form, list, where) {
++ for (const f of list) {
++ if (!isUpload(f)) throw new TypeError(`${where}: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }`);
++ if (isBlob(f)) {
++ form.append('file', f, f.name || 'file');
++ continue;
++ }
++ const blob = isBlob(f.data) ? f.data : new Blob([f.data], f.type ? { type: f.type } : undefined);
++ form.append('file', blob, f.name || 'file');
++ }
++ return form;
++}
++
++module.exports = { isBlob, isUpload, appendFiles };
++},
++// ── 17: src/tools.js ────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/tools: the OpenVibe.Tools platform API (ADR-027) on the gateway, https://openvibe.tools:
++ * the tool registry, the uniform run API, and the jobs client for tools that run as jobs.
++ * Browser-safe.
++ *
++ * const tools = createToolsClient(client);
++ * const { tools: list } = await tools.list({ family: 'img', api: true });
++ * const png = await tools.get('png'); // tools.tool@1, or null
++ * const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });
++ * out.result.text; // an inline tool: finished
++ * const run = await tools.run('png', {}, { files: [{ name: 'a.jpg', data: bytes }] });
++ * run.state; // a job tool: 'queued', with run.job, run.location
++ * const done = await run.wait(); // { state: 'succeeded', result: { files, data }, job }
++ * const res = await tools.jobs.file(done.job.id, 0); // raw Response
++ *
++ * Routes (audience openvibe.tools):
++ * GET /api/v1/tools[?family&execution&api&status&q] tools.tool-list@1 (schemas as $ref) tools.tool.read,
++ * GET /api/v1/tools/:id tools.tool@1 (schemas embedded) public and
++ * GET /api/v1/tools/:id/schema { $schema, $id, $defs: { input, output } } anonymous
++ * POST /api/v1/tools/:id/run tools.run-request@1 -> tools.run@1: tools.tool.run
++ * 200 finished (succeeded, or failed/cancelled with the tool's own problem), 202 + Location
++ * while a job is queued or running; tools.net.probe for network probes (partner)
++ * /api/v1/jobs… the gateway's jobs facade (openvibe-sdk/jobs)
++ *
++ * Callers are tiered anonymous < session < user < app/service. A client without a token runs the
++ * tools whose descriptor has auth.anonymous true; a person's token runs every tool but the probes; an
++ * app or service token needs auth.capability (tools.tool.run, or tools.net.probe). Registry reads
++ * send no token unless `anonymousReads: false`.
++ */
++const { OpenVibeError, isOpenVibeError } = __load(3);
++const { newIdempotencyKey } = __load(5);
++const { isUpload, appendFiles } = __load(16);
++const { createJobsClient } = __load(15);
++
++const enc = encodeURIComponent;
++const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
++const MEDIA_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++const JOB_ID_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MAX_WAIT_MS = 60000;
++/** Time on top of wait_ms (or a tool's limits.timeoutMs) for the request itself. */
++const RUN_MARGIN_MS = 10000;
++
++/**
++ * A run that finished `failed` or `cancelled`: the tool's own problem+json, mapped like any other
++ * OpenVibeError (code, status, title, detail, type, errors, requestId, traceId). `status` is the
++ * problem's status (422, 504…), not the HTTP status of the answer (200). Also: `state`, `tool`,
++ * `job` (tools.job@1 when the tool ran as a job: `jobs.retry(err.job.id)` when `retryable`) and `run`
++ * (the tools.run@1 answer, when there was one).
++ */
++class ToolRunError extends OpenVibeError {
++ constructor({ state, tool, error, job = null, run = null, requestId, traceId } = {}) {
++ const p = error && typeof error === 'object' ? error : {};
++ const code = typeof p.code === 'string' && p.code ? p.code : state === 'cancelled' ? 'tools.job.cancelled' : 'tools.job.failed';
++ const detail = typeof p.detail === 'string' ? p.detail : null;
++ super({
++ message: `tools.run ${tool} ${state}: ${code}${detail ? `: ${detail}` : ''}`,
++ code,
++ status: Number.isInteger(p.status) ? p.status : 0,
++ title: typeof p.title === 'string' ? p.title : undefined,
++ detail: detail || undefined,
++ type: typeof p.type === 'string' ? p.type : undefined,
++ errors: Array.isArray(p.errors) ? p.errors : undefined,
++ problem: typeof p.code === 'string' && typeof p.status === 'number' ? p : null,
++ requestId: p.request_id || requestId,
++ traceId: p.trace_id || traceId,
++ retryable: Boolean(job && job.retryable),
++ });
++ this.state = state;
++ this.tool = tool;
++ this.job = job || null;
++ this.run = run || null;
++ }
++}
++
++/** A run that finished failed or cancelled (a ToolRunError, also from another copy of the SDK). */
++function isToolRunError(err) {
++ return err instanceof ToolRunError
++ || Boolean(isOpenVibeError(err) && typeof err.tool === 'string' && (err.state === 'failed' || err.state === 'cancelled') && 'job' in err);
++}
++
++function checkId(id, where) {
++ if (typeof id !== 'string' || !ID_RE.test(id)) throw new TypeError(`${where}: a tool id is lowercase letters, digits and dashes (png, jsonminify, dns)`);
++}
++
++/** A files reference of tools.run-request@1, normalized to exactly its fields; null for an upload. */
++function asRef(f) {
++ if (!f || typeof f !== 'object' || isUpload(f)) return null;
++ if (f.media_id !== undefined) {
++ if (typeof f.media_id !== 'string' || !MEDIA_ID_RE.test(f.media_id)) throw new TypeError('tools.run: media_id is a Media object id (med_<ULID>)');
++ return { media_id: f.media_id };
++ }
++ if (f.job_id !== undefined) {
++ if (typeof f.job_id !== 'string' || !JOB_ID_RE.test(f.job_id)) throw new TypeError('tools.run: job_id is a Tools job id (job_<ULID>)');
++ if (!Number.isInteger(f.index) || f.index < 0 || f.index > 99) throw new TypeError('tools.run: a job file reference needs index, 0..99');
++ return { job_id: f.job_id, index: f.index };
++ }
++ throw new TypeError('tools.run: a file is an upload (Blob/File, or { name, data, type? }) or a reference ({ media_id } or { job_id, index })');
++}
++
++const tookMs = (job) => {
++ const a = Date.parse(job && job.created_at);
++ const b = Date.parse(job && job.finished_at);
++ return Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : 0;
++};
++
++/**
++ * createToolsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools', anonymousReads = true })
++ * -> { list, get, schema, run, jobs }
++ *
++ * Without `baseUrl` the gateway is the `tools` service origin from the platform descriptor.
++ * `jobs` is openvibe-sdk/jobs on the same origin and credentials (the gateway's jobs facade).
++ */
++function createToolsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools', anonymousReads = true } = {}) {
++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ const read = (path, query, signal) => call({ path, query, signal, ...(anonymousReads ? { auth: false } : {}) }).then((r) => r.data);
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const jobs = createJobsClient(client, { baseUrl, service, audience });
++ const limits = new Map(); // id -> limits.timeoutMs, from descriptors this client has read
++ const remember = (d) => { if (d && d.id && d.limits && Number.isInteger(d.limits.timeoutMs)) limits.set(d.id, d.limits.timeoutMs); };
++
++ /**
++ * The registry: tools.tool-list@1 { tools, count, updated_at, families } with every descriptor's
++ * schemas as { $ref }. Filters: family, execution ('client' | 'sync' | 'job'), api (boolean), status, q.
++ */
++ async function list({ family, q, execution, api, status, signal } = {}) {
++ const query = { family, q, execution, status, api: api === undefined || api === null ? undefined : String(Boolean(api)) };
++ const out = await read('/api/v1/tools', query, signal);
++ if (out && Array.isArray(out.tools)) out.tools.forEach(remember);
++ return out;
++ }
++
++ /** One descriptor (tools.tool@1, schemas embedded), or null when there is no such tool. */
++ async function get(id, { signal } = {}) {
++ checkId(id, 'tools.get');
++ const d = await orNull(read(`/api/v1/tools/${enc(id)}`, undefined, signal));
++ remember(d);
++ return d;
++ }
++
++ /** { $schema, $id, $defs: { input, output } } (what the list's $refs point at), or null. */
++ async function schema(id, { signal } = {}) {
++ checkId(id, 'tools.schema');
++ return orNull(read(`/api/v1/tools/${enc(id)}/schema`, undefined, signal));
++ }
++
++ const hide = (obj, name, value) => Object.defineProperty(obj, name, { value, enumerable: false, configurable: true, writable: true });
++
++ function succeeded({ tool, result, took_ms, job, location, meta }) {
++ const out = { state: 'succeeded', tool, result: result || {}, took_ms: Number.isFinite(took_ms) ? took_ms : 0 };
++ if (job) {
++ out.job = job;
++ out.location = location || (job.links && job.links.self) || `/api/v1/jobs/${job.id}`;
++ }
++ out.idempotencyKey = meta.idempotencyKey;
++ out.replayed = Boolean(meta.replayed);
++ return hide(out, 'wait', async () => out);
++ }
++
++ /** A finished job as the run it belongs to: succeeded, or a thrown ToolRunError. */
++ function fromJob(job, tool, meta) {
++ if (!job) throw new OpenVibeError({ code: 'tools.job.not_found', status: 404, message: `tools.run ${tool}: the job is gone (expired, or not yours)` });
++ if (job.state === 'succeeded') {
++ const r = job.result || { files: [], data: {} };
++ return succeeded({ tool, result: { data: r.data || {}, files: r.files || [] }, took_ms: tookMs(job), job, location: job.links && job.links.self, meta });
++ }
++ if (job.state === 'failed' || job.state === 'cancelled') throw new ToolRunError({ state: job.state, tool, error: job.error, job });
++ return pending({ state: job.state, tool, job, location: job.links && job.links.self, meta });
++ }
++
++ function pending({ state, tool, job, location, meta }) {
++ const out = { state, tool, job, location: location || (job.links && job.links.self) || `/api/v1/jobs/${job.id}`, idempotencyKey: meta.idempotencyKey, replayed: Boolean(meta.replayed) };
++ return hide(out, 'wait', async (opts = {}) => fromJob(await jobs.wait(job.id, opts), tool, meta));
++ }
++
++ /** A tools.run@1 answer (or a bare tools.job@1) -> the resolved value, or a thrown ToolRunError. */
++ function settle(body, tool, meta) {
++ if (body && body.object === 'tools.job') return fromJob(body, tool, meta); // a server that answers with the job itself
++ if (!body || typeof body !== 'object' || typeof body.state !== 'string') {
++ throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, requestId: meta.requestId, traceId: meta.traceId, message: `tools.run ${tool}: the answer is not a tools.run@1 run` });
++ }
++ const name = body.tool || tool;
++ if (body.state === 'queued' || body.state === 'running') {
++ if (!body.job || !body.job.id) throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, message: `tools.run ${tool}: ${body.state} without its job` });
++ return pending({ state: body.state, tool: name, job: body.job, location: body.location || meta.location, meta });
++ }
++ if (body.state === 'succeeded') return succeeded({ tool: name, result: body.result, took_ms: body.took_ms, job: body.job || null, location: body.location || meta.location, meta });
++ if (body.state === 'failed' || body.state === 'cancelled') {
++ throw new ToolRunError({ state: body.state, tool: name, error: body.error || (body.job && body.job.error), job: body.job || null, run: body, requestId: meta.requestId, traceId: meta.traceId });
++ }
++ throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, message: `tools.run ${tool}: unknown state ${body.state}` });
++ }
++
++ /**
++ * run(id, input = {}, { files, waitMs, idempotencyKey, signal, timeoutMs })
++ * -> { state: 'succeeded', tool, result: { data | text | files }, took_ms, job?, location?, idempotencyKey, replayed }
++ * | { state: 'queued' | 'running', tool, job, location, idempotencyKey, replayed }
++ * Every resolved run has a non-enumerable `wait(opts)`: a finished run resolves to itself; a job
++ * follows the job's events (jobs.wait, same options: signal, onEvent, lastEventId) and resolves
++ * to the succeeded run, or throws ToolRunError.
++ *
++ * - files: uploads (Blob/File, or { name, data, type? }, sent as multipart `file` parts) and
++ * references ({ media_id } a Media object you may read; { job_id, index } a result file of your
++ * own job), in that order. References go in the JSON body's `files`, or, beside uploads, as a
++ * multipart `files` part holding their JSON.
++ * - waitMs: a job tool waits up to this long (0..60000) before answering; 200 finished, else 202.
++ * - idempotencyKey: generated when omitted (returned). The same key and request give the same
++ * job (`replayed: true`); another request under it is 409 tools.job.idempotency_conflict. The
++ * key also makes the POST safe to retry: 429 (Retry-After) and 5xx are retried by the client.
++ * - timeoutMs: per attempt. Default: the client's, raised to waitMs, or to the tool's
++ * limits.timeoutMs once this client has read its descriptor (get or list), plus 10 s.
++ * A request refused before the tool ran (404 tools.tool.not_found | not_runnable, 422
++ * tools.input.invalid, 403 capability.denied, 503 tools.tool.unavailable…) throws OpenVibeError.
++ */
++ async function run(id, input = {}, { files, waitMs, idempotencyKey, signal, timeoutMs } = {}) {
++ checkId(id, 'tools.run');
++ if (input == null) input = {};
++ if (typeof input !== 'object' || Array.isArray(input)) throw new TypeError('tools.run: input must be an object');
++ if (waitMs != null && (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > MAX_WAIT_MS)) throw new TypeError(`tools.run: waitMs is an integer, 0..${MAX_WAIT_MS}`);
++ const list = files == null ? [] : Array.isArray(files) ? files : [files];
++ const refs = [];
++ const uploads = [];
++ for (const f of list) {
++ const ref = asRef(f);
++ if (ref) refs.push(ref);
++ else if (isUpload(f)) uploads.push(f);
++ else throw new TypeError('tools.run: a file is an upload (Blob/File, or { name, data, type? }) or a reference ({ media_id } or { job_id, index })');
++ }
++ if (idempotencyKey != null && (typeof idempotencyKey !== 'string' || !/^[!-~]{8,200}$/.test(idempotencyKey))) {
++ throw new TypeError('tools.run: idempotencyKey is 8-200 printable ASCII characters');
++ }
++ const key = idempotencyKey || newIdempotencyKey();
++
++ const opts = { method: 'POST', path: `/api/v1/tools/${enc(id)}/run`, idempotencyKey: key, signal };
++ if (uploads.length) {
++ const form = new FormData();
++ form.append('input', JSON.stringify(input));
++ if (refs.length) form.append('files', JSON.stringify(refs));
++ if (waitMs != null) form.append('wait_ms', String(waitMs));
++ opts.form = appendFiles(form, uploads, 'tools.run');
++ } else {
++ const body = { input };
++ if (refs.length) body.files = refs;
++ if (waitMs != null) body.wait_ms = waitMs;
++ opts.json = body;
++ }
++ const base = client.options || {};
++ const floor = Math.max(waitMs || 0, limits.get(id) || 0);
++ opts.timeoutMs = timeoutMs ?? Math.max(base.timeoutMs || 0, floor ? floor + RUN_MARGIN_MS : 0);
++ opts.deadlineMs = Math.max(base.deadlineMs || 0, opts.timeoutMs * 2);
++
++ const res = await call(opts);
++ return settle(res.data, id, {
++ idempotencyKey: key,
++ replayed: String(res.headers.get('idempotent-replayed') || '') === 'true',
++ location: res.headers.get('location') || undefined,
++ status: res.status,
++ requestId: res.requestId,
++ traceId: res.traceId,
++ });
++ }
++
++ return { list, get, schema, run, jobs };
++}
++
++module.exports = { createToolsClient, ToolRunError, isToolRunError };
++},
++// ── 18: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2042,6 +2378,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, jobs,
-- projects,
++ tools, projects,
} = sdk;
export default sdk;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 5b279b7..5460ea9 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,6 +6,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, jobs, projects,
++ community, jobs, tools, projects,
} = cjs;
export default cjs;
ddiff --git a/esm/tools.mjs b/esm/tools.mjs
nnew file mode 100644
iindex 0000000..31fa4ce
---- /dev/null
++++ b/esm/tools.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/tools.js. Do not edit.
++import cjs from '../src/tools.js';
++
++export const {
++ createToolsClient, ToolRunError, isToolRunError,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 53828e4..b69ca35 100644
---- a/index.js
++++ b/index.js
@@@ -17,5 +17,6 @@ module.exports = {
media: require('./src/media'),
community: require('./src/community'),
jobs: require('./src/jobs'),
++ tools: require('./src/tools'),
projects: require('./src/projects'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex bca0a96..d144169 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,12 +9,48 @@
"version": "0.5.0",
"license": "MIT",
"devDependencies": {
-- "better-sqlite3": "^11.10.0"
++ "better-sqlite3": "^11.10.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0"
},
"engines": {
"node": ">=20"
}
},
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@@ -158,6 +194,30 @@
"node": ">=6"
}
},
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@@ -214,6 +274,13 @@
"dev": true,
"license": "ISC"
},
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "dev": true,
++ "license": "MIT"
++ },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@@ -274,6 +341,23 @@
"wrappy": "1"
}
},
++ "node_modules/openvibe-contracts": {
++ "version": "0.33.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
++ "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
++ "dev": true,
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@@ -344,6 +428,16 @@
"node": ">= 6"
}
},
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
ddiff --git a/package.json b/package.json
iindex 4d4036f..3c4f1ff 100644
---- a/package.json
++++ b/package.json
@@@ -1,7 +1,7 @@
{
"name": "openvibe-sdk",
"version": "0.5.0",
-- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs) and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
"browser": "browser.js",
@@@ -70,6 +70,11 @@
"import": "./esm/jobs.mjs",
"default": "./src/jobs.js"
},
++ "./tools": {
++ "types": "./types/tools.d.ts",
++ "import": "./esm/tools.mjs",
++ "default": "./src/tools.js"
++ },
"./projects": {
"types": "./types/projects.d.ts",
"import": "./esm/projects.mjs",
@@@ -116,11 +121,13 @@
"sse",
"media",
"jobs",
++ "tools",
"developer-apps"
],
"repository": "github:OpenVibers/OpenVibe.SDK",
"license": "MIT",
"devDependencies": {
-- "better-sqlite3": "^11.10.0"
++ "better-sqlite3": "^11.10.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0"
}
}
ddiff --git a/scripts/browser-bundle.js b/scripts/browser-bundle.js
iindex f280168..1eec767 100644
---- a/scripts/browser-bundle.js
++++ b/scripts/browser-bundle.js
@@@ -3,7 +3,7 @@
/**
* browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK
* (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,
-- * community, jobs, projects), for pages with no build step:
++ * community, jobs, tools, projects), for pages with no build step:
*
* <script type="module">
* import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex c60295d..aa1fba2 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -24,6 +24,7 @@ const ENTRIES = {
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'jobs.mjs': '../src/jobs.js',
++ 'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'testing.mjs': '../src/testing/index.js',
};
ddiff --git a/src/core/form.js b/src/core/form.js
nnew file mode 100644
iindex 0000000..862ddd0
---- /dev/null
++++ b/src/core/form.js
@@@ -0,0 +1,31 @@
++'use strict';
++/**
++ * Uploaded files as multipart parts, shared by openvibe-sdk/jobs and openvibe-sdk/tools so both take
++ * the same shapes: a Blob/File, or { name, data: Blob | ArrayBuffer | typed array (Buffer) | string,
++ * type? }. Browser-safe (Blob, FormData). Internal: not a public subpath.
++ */
++
++const isBlob = (x) => typeof Blob !== 'undefined' && x instanceof Blob;
++
++/** Is `f` something appendFiles() can upload? */
++function isUpload(f) {
++ if (isBlob(f)) return true;
++ const d = f && typeof f === 'object' ? f.data : undefined;
++ return isBlob(d) || typeof d === 'string' || d instanceof ArrayBuffer || ArrayBuffer.isView(d);
++}
++
++/** Append each file as a `file` part (Tools reads `file` and `files` parts alike). */
++function appendFiles(form, list, where) {
++ for (const f of list) {
++ if (!isUpload(f)) throw new TypeError(`${where}: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }`);
++ if (isBlob(f)) {
++ form.append('file', f, f.name || 'file');
++ continue;
++ }
++ const blob = isBlob(f.data) ? f.data : new Blob([f.data], f.type ? { type: f.type } : undefined);
++ form.append('file', blob, f.name || 'file');
++ }
++ return form;
++}
++
++module.exports = { isBlob, isUpload, appendFiles };
ddiff --git a/src/jobs.js b/src/jobs.js
iindex dab9586..c9b3235 100644
---- a/src/jobs.js
++++ b/src/jobs.js
@@@ -1,29 +1,40 @@
'use strict';
/**
-- * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs
-- * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.
++ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (tools.job@1). Browser-safe; the credentials
++ * decide who owns a job.
*
-- * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
++ * const jobs = createJobsClient(client); // the Tools gateway's /api/v1/jobs facade
* const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },
* files: [{ name: 'a.png', data: bytes }], idempotencyKey });
* for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops
* const done = await jobs.get(job.id);
* const res = await jobs.file(job.id, 0); // raw Response: stream or save it
++ * await jobs.reference(job.id, 'community:paste:p_123'); // keep the result while the paste exists
*
-- * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,
-- * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same
-- * answer as for a job that does not exist.
++ * Where: without `baseUrl`, the `tools` service origin from the platform descriptor
++ * (https://openvibe.tools), whose gateway fronts every satellite's jobs under /api/v1/jobs (ADR-027).
++ * That facade comes with the Tools run API (Tools S6); until it is deployed, pass the satellite that
++ * runs the job type: `{ baseUrl: 'https://img.openvibe.tools' }` (img, audio, docs). An explicit
++ * baseUrl always wins.
++ *
++ * Capabilities (audience openvibe.tools): tools.job.create (submit, retry, reference, unreference),
++ * tools.job.read (get, events, file), tools.job.cancel (cancel). Jobs are owner-scoped: another
++ * principal gets 404, the same answer as for a job that does not exist.
*
* POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);
* Idempotency-Key -> 202 new | 200 + Idempotent-Replayed
* GET /api/v1/jobs/:id
* DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished
++ * POST /api/v1/jobs/:id/retry a failed job -> 202 new job | 200 + Idempotent-Replayed (its retry)
++ * PUT /api/v1/jobs/:id/references/:ref keep a succeeded job's result (201 new | 200 existing)
++ * DELETE /api/v1/jobs/:id/references/:ref let it expire again after the last reference
* GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;
* 204 when the job finished and nothing is newer
* GET /api/v1/jobs/:id/files/:n a result file
*/
const { OpenVibeError, isOpenVibeError } = require('./core/errors');
const { newIdempotencyKey } = require('./core/ids');
++const { appendFiles } = require('./core/form');
const { parseSSE } = require('./realtime');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -34,12 +45,6 @@ const sleep = (ms, signal) => new Promise((resolve) => {
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
--function toBlob(data, type) {
-- if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
-- if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);
-- throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');
--}
--
/** Is this job finished (succeeded, failed or cancelled)? */
function isTerminal(job) {
return Boolean(job && TERMINAL.has(job.state));
@@@ -47,6 +52,11 @@ function isTerminal(job) {
function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {
const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ const replayedOf = (res) => String(res.headers.get('idempotent-replayed') || '') === 'true';
++ const refPath = (id, ref) => {
++ if (typeof ref !== 'string' || !ref) throw new TypeError('jobs: a reference is a string <service>:<kind>:<id>, e.g. community:paste:p_123');
++ return `/api/v1/jobs/${enc(id)}/references/${enc(ref)}`;
++ };
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
/**
@@@ -68,17 +78,12 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
const form = new FormData();
form.append('type', String(type));
form.append('input', JSON.stringify(input));
-- for (const f of list) {
-- const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;
-- const blob = isBlob ? f : toBlob(f && f.data, f && f.type);
-- form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');
-- }
-- opts.form = form;
++ opts.form = appendFiles(form, list, 'jobs.submit');
} else {
opts.json = { type: String(type), input };
}
const res = await call(opts);
-- return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };
++ return { job: res.data, replayed: replayedOf(res), idempotencyKey: key };
}
/** The job, or null when it does not exist or is not yours. */
@@@ -95,6 +100,33 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;
}
++ /**
++ * Retry a failed job as a new job (same type, input and files; `retry_of` names the failed one)
++ * -> { job, replayed }. Idempotent on the server: asking again returns that same retry
++ * (`replayed: true`) whatever state it is in, so the call is retried safely without a key.
++ * Refusals: 409 tools.job.not_failed (only failed jobs), 410 tools.job.inputs_gone or
++ * tools.job.retry_gone, 429 tools.job.too_many_active.
++ */
++ async function retry(id, { signal } = {}) {
++ const res = await call({ method: 'POST', path: `/api/v1/jobs/${enc(id)}/retry`, idempotent: true, signal });
++ return { job: res.data, replayed: replayedOf(res) };
++ }
++
++ /**
++ * Keep a succeeded job's result while `ref` (<service>:<kind>:<id>, e.g. community:paste:p_123)
++ * points at it -> the job, whose expires_at is null while any reference remains. Idempotent.
++ * Refusals: 400 tools.job.invalid (a bad ref), 409 tools.job.not_succeeded, tools.job.sandbox
++ * (sandbox results are never kept) or tools.job.too_many_references (50).
++ */
++ async function reference(id, ref, { signal } = {}) {
++ return (await call({ method: 'PUT', path: refPath(id, ref), signal })).data;
++ }
++
++ /** Drop a reference -> the job; after the last one, the result expires again. Idempotent. */
++ async function unreference(id, ref, { signal } = {}) {
++ return (await call({ method: 'DELETE', path: refPath(id, ref), signal })).data;
++ }
++
/**
* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })
* -> async iterator of { id, event, job }
@@@ -161,7 +193,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;
}
-- return { submit, get, cancel, events, wait, file };
++ return { submit, get, cancel, retry, reference, unreference, events, wait, file };
}
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
ddiff --git a/src/testing/index.js b/src/testing/index.js
iindex f6b79ec..ccc1e32 100644
---- a/src/testing/index.js
++++ b/src/testing/index.js
@@@ -24,7 +24,10 @@
* Media /api/v1/:app/files and /f/:key. Developer apps reach /api/v1/<project_id>/files only:
* production tenant prj_…, sandbox tenant prj_…-sandbox whose files have signed URLs only
* Tools /api/v1/jobs (with { jobs: true }) at origins.tools and the img., audio. and docs.
-- * satellites (platform.toolsOrigins)
++ * satellites (platform.toolsOrigins); origins.tools is the gateway's jobs facade (it
++ * also sees the satellites' jobs). With { tools: true }, the platform API on
++ * origins.tools: GET /api/v1/tools[/:id[/schema]] and POST /api/v1/tools/:id/run
++ * (sync and job runs, file references), see ./tools.js
* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
* services check them (audience, capability, namespace, sandbox refusal). It is a fake: no
* persistence, simplified visibility rules, no Chat. Like the Network, it verifies the PKCE
@@@ -34,6 +37,7 @@ const crypto = require('node:crypto');
const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');
const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');
const { createJobsService } = require('./jobs');
++const { createToolsService } = require('./tools');
const appRules = require('./apps');
const { signDeliveryHeaders } = require('../events');
@@@ -157,7 +161,8 @@ function createMockPlatform(opts = {}) {
}
const developer = createDeveloper({ opts, issuer, sign, users, addUser, userOf, mediaApps, authorization });
-- const jobsService = createJobsService({ opts, decode, principal });
++ const jobsService = createJobsService({ opts, decode, principal, gateway: origins.tools });
++ const toolsService = createToolsService({ opts, decode, principal, origins, jobs: jobsService });
// ── Network ─────────────────────────────────────────────
function descriptor() {
@@@ -794,23 +799,41 @@ function createMockPlatform(opts = {}) {
}
// ── Router ──────────────────────────────────────────────
-- async function fetchImpl(input, init) {
-- const req = input instanceof Request && !init ? input : new Request(input, init);
-- const url = new URL(req.url);
-- stats.requests.push({ method: req.method, url: req.url, headers: Object.fromEntries(req.headers) });
++ async function route(req, url) {
const origin = url.origin;
if (origin === new URL(origins.network).origin) return network(req, url);
if (origin === new URL(origins.events).origin) return eventsService(req, url);
if (origin === new URL(origins.media).origin) return media(req, url);
-- if (toolsOrigins.includes(origin)) return jobsService.handle(req, url);
++ if (toolsOrigins.includes(origin)) return /^\/api\/v1\/tools(\/|$)/.test(url.pathname) ? toolsService.handle(req, url) : jobsService.handle(req, url);
throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
}
++ /** Like fetch: an aborted signal rejects with an AbortError, also while the answer is pending. */
++ async function fetchImpl(input, init) {
++ const req = input instanceof Request && !init ? input : new Request(input, init);
++ const url = new URL(req.url);
++ stats.requests.push({ method: req.method, url: req.url, headers: Object.fromEntries(req.headers) });
++ const signal = req.signal;
++ const aborted = () => (signal.reason instanceof Error ? signal.reason : new DOMException('This operation was aborted', 'AbortError'));
++ if (signal && signal.aborted) throw aborted();
++ const answer = route(req, url);
++ if (!signal) return answer;
++ return new Promise((resolve, reject) => {
++ const onAbort = () => reject(aborted());
++ signal.addEventListener('abort', onAbort, { once: true });
++ answer.then((res) => { signal.removeEventListener('abort', onAbort); resolve(res); }, (err) => { signal.removeEventListener('abort', onAbort); reject(err); });
++ });
++ }
++
return {
fetch: fetchImpl,
origins,
/** Every origin that answers /api/v1/jobs: origins.tools and the satellites (img., audio., docs.openvibe.tools). */
toolsOrigins,
++ /** Add or replace a tool (tools.tool@1) and optionally its handler (with { tools }). */
++ addTool: (descriptor, handler) => toolsService.addTool(descriptor, handler),
++ /** A Media object that tools.run { media_id } references read -> its med_ id. */
++ addMediaObject: (obj) => toolsService.addMediaObject(obj),
issuer,
keys: { privateKey, publicKey, jwks },
signUserToken,
@@@ -828,7 +851,7 @@ function createMockPlatform(opts = {}) {
addProject: (spec) => developer.addProject(spec).id,
signAppToken,
stats,
-- state: { events, subscriptions, modules, files, mediaTenants, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },
++ state: { events, subscriptions, modules, files, mediaTenants, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs, tools: toolsService.tools },
deliverEvents,
startDeliveries,
pruneEvents,
ddiff --git a/src/testing/jobs.js b/src/testing/jobs.js
iindex cf0cccd..c6c60ba 100644
---- a/src/testing/jobs.js
++++ b/src/testing/jobs.js
@@@ -1,47 +1,84 @@
'use strict';
/**
-- * Mock OpenVibe.Tools jobs (/api/v1/jobs), enabled with createMockPlatform({ jobs: true | {…} }).
-- * Node only. Plays the documented lifecycle queued -> running -> (progress…) -> succeeded | failed
-- * | cancelled, with owner scoping, Idempotency-Key replay, SSE events with ids and Last-Event-ID
-- * replay, 204 once finished, and result files.
++ * Mock OpenVibe.Tools jobs (/api/v1/jobs), enabled with createMockPlatform({ jobs: true | {…} })
++ * (also by { tools }). Node only. Plays the documented lifecycle queued -> running -> (progress…) ->
++ * succeeded | failed | cancelled, with owner scoping, Idempotency-Key replay, SSE events with ids and
++ * Last-Event-ID replay, 204 once finished, result files, retry of a failed job and result references.
++ * Every answer is a tools.job@1 view (openvibe-contracts v0.33.0).
*
* jobs: {
* stepMs: 5, // delay between lifecycle steps
* handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },
* }
* Every Tools origin of the platform answers (origins.tools and the img., audio. and docs.
-- * satellites); like the real satellites, each keeps its own jobs: a job id is found only on the
-- * origin that created it.
++ * satellites). A satellite keeps its own jobs: a job id is found only on the origin that created it.
++ * origins.tools is the gateway's facade (ADR-027): it also sees every satellite's jobs, and a job
++ * created through it is also found on the satellite its type names (img.process -> img.).
* Without a handler for a type, the job echoes: data { echo: input }, files = the uploaded files.
-- * A handler that throws fails the job with { code: err.code || 'tools.job.failed', detail }.
++ * A handler that throws fails the job with problem+json: code err.code when it is a tools.… code
++ * (else tools.job.failed), status err.status (else 422), retryable err.retryable.
*/
const { ulid, json, problem, sha256hex } = require('./util');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const CAPS = { create: 'tools.job.create', read: 'tools.job.read', cancel: 'tools.job.cancel' };
++const REF_RE = /^[a-z][a-z0-9_-]*(?::[A-Za-z0-9_.-]+){1,4}$/;
++const MAX_REFS = 50;
++const TTL_MS = 24 * 3600 * 1000;
++const TITLES = { 400: 'Bad Request', 404: 'Not Found', 409: 'Conflict', 410: 'Gone', 422: 'Unprocessable Content', 429: 'Too Many Requests', 500: 'Internal Server Error', 503: 'Service Unavailable', 504: 'Gateway Timeout' };
++
++/** A problem+json body (errors.problem@1), as the job's `error` and a failed run's `error`. */
++function problemBody(status, code, detail, extra = {}) {
++ return { type: `https://openvibe.network/problems/${code}`, title: TITLES[status] || 'Error', status, code, detail, error: detail, ...extra };
++}
function createJobsService(ctx) {
const cfg = ctx.opts.jobs && typeof ctx.opts.jobs === 'object' ? ctx.opts.jobs : {};
const stepMs = cfg.stepMs ?? 5;
const handlers = cfg.handlers || {};
++ const gateway = new URL(ctx.gateway || 'https://openvibe.tools').origin;
const jobs = new Map();
const idem = new Map();
const streams = new Set();
const iso = (t) => (t ? new Date(t).toISOString() : null);
++ const label = (origin) => new URL(origin).hostname.split('.')[0];
++
++ /** A satellite's label (img, audio, docs) when the job runs there; 'mock' otherwise. */
++ function serviceOf(job) {
++ if (job.origin !== gateway) return label(job.origin);
++ const prefix = String(job.type).split('.')[0];
++ return /^[a-z][a-z0-9-]{1,39}$/.test(prefix) && String(job.type).includes('.') ? prefix : 'mock';
++ }
++ /** Found here? Its own origin; the gateway sees all; a satellite sees gateway jobs of its type. */
++ function visibleAt(job, origin) {
++ return origin === job.origin || origin === gateway || (job.origin === gateway && label(origin) === serviceOf(job));
++ }
function view(job) {
const live = job.state === 'queued' || job.state === 'running';
-- return {
-- id: job.id, object: 'tools.job', service: 'mock', type: job.type, type_version: 1, state: job.state,
++ const self = `/api/v1/jobs/${job.id}`;
++ const out = {
++ id: job.id, object: 'tools.job', service: serviceOf(job),
++ };
++ if (job.tool) out.tool = job.tool;
++ Object.assign(out, {
++ type: job.type, type_version: 1, state: job.state,
progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 1,
-- cancel_requested: job.cancelRequested, created_at: iso(job.createdAt), started_at: iso(job.startedAt), finished_at: iso(job.finishedAt), expires_at: null,
++ cancel_requested: job.cancelRequested, created_at: iso(job.createdAt), started_at: iso(job.startedAt), finished_at: iso(job.finishedAt),
++ expires_at: job.references.length ? null : iso(job.createdAt + TTL_MS),
result: job.result ? {
-- files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, url: `/api/v1/jobs/${job.id}/files/${i}` })),
++ files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, sha256: sha256hex(f.bytes), storage: 'local', media: null, url: `${self}/files/${i}` })),
data: job.result.data || {},
} : null,
-- error: job.error, retryable: false,
-- links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: live ? `/api/v1/jobs/${job.id}` : null },
-- };
++ error: job.error, retryable: Boolean(job.retryable), retry_of: job.retryOf || null, retried_by: job.retriedBy || null,
++ references: job.references.map((r) => ({ ref: r.ref, created_at: iso(r.at) })),
++ links: {
++ self, events: `${self}/events`, cancel: live ? self : null,
++ retry: job.state === 'failed' ? `${self}/retry` : null,
++ retried_by: job.retriedBy ? `/api/v1/jobs/${job.retriedBy}` : null,
++ },
++ });
++ return out;
}
function emit(job, event) {
@@@ -55,13 +92,14 @@ function createJobsService(ctx) {
Object.assign(job, { state, finishedAt: Date.now() }, fields);
emit(job, `job.${state}`);
}
++ const cancelledError = () => problemBody(409, 'tools.job.cancelled', 'The job was cancelled');
async function runJob(job) {
await new Promise((r) => setTimeout(r, stepMs));
if (TERMINAL.has(job.state)) return;
Object.assign(job, { state: 'running', startedAt: Date.now() });
emit(job, 'job.running');
-- const handler = handlers[job.type] || (async ({ input, files, progress }) => {
++ const handler = job.handler || handlers[job.type] || (async ({ input, files, progress }) => {
await progress(50, 'working');
return { data: { echo: input }, files: files.map((f) => ({ name: f.name, mime: f.type, bytes: f.bytes })) };
});
@@@ -78,15 +116,42 @@ function createJobsService(ctx) {
get cancelled() { return job.cancelRequested; },
});
await new Promise((r) => setTimeout(r, stepMs));
-- if (job.cancelRequested) return finish(job, 'cancelled');
++ if (job.cancelRequested) return finish(job, 'cancelled', { error: cancelledError() });
const files = ((out && out.files) || []).map((f) => ({ name: f.name || 'result', mime: f.mime || 'application/octet-stream', bytes: Buffer.from(f.bytes || f.data || '') }));
return finish(job, 'succeeded', { percent: 100, message: null, result: { data: (out && out.data) || {}, files } });
} catch (err) {
-- if (job.cancelRequested) return finish(job, 'cancelled');
-- return finish(job, 'failed', { error: { code: (err && err.code) || 'tools.job.failed', detail: (err && err.message) || 'failed' } });
++ if (job.cancelRequested) return finish(job, 'cancelled', { error: cancelledError() });
++ const code = /^tools\.[a-z0-9_.]+$/.test(String((err && err.code) || '')) ? err.code : 'tools.job.failed';
++ const status = err && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599 ? err.status : 422;
++ return finish(job, 'failed', { error: problemBody(status, code, (err && err.message) || 'The tool could not process this input'), retryable: Boolean(err && err.retryable) });
}
}
++ /**
++ * Create a job (the POST route, and the tools mock's job runs) -> { job, replayed } | { res }.
++ * files: [{ name, type, bytes }]; handler overrides the type's handler; tool names the tool run.
++ */
++ function create({ origin, owner, env = 'production', type, input, files = [], key = null, hash = null, handler = null, tool = null, retryOf = null }) {
++ if (!handler && Object.keys(handlers).length && !handlers[type]) return { res: problem(400, 'tools.job.unknown_type', `no job type ${type}`) };
++ if (key) {
++ const prior = idem.get(`${origin}|${owner}|${key}`);
++ if (prior) {
++ if (prior.hash !== hash) return { res: problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request') };
++ return { job: jobs.get(prior.id), replayed: true };
++ }
++ }
++ const job = {
++ id: `job_${ulid()}`, origin, owner, env, type, input, files, handler, tool, state: 'queued', percent: null, message: null,
++ cancelRequested: false, createdAt: Date.now(), startedAt: null, finishedAt: null, result: null, error: null, retryable: false,
++ retryOf, retriedBy: null, references: [], events: [],
++ };
++ jobs.set(job.id, job);
++ if (key) idem.set(`${origin}|${owner}|${key}`, { id: job.id, hash });
++ emit(job, 'job.queued');
++ runJob(job);
++ return { job, replayed: false };
++ }
++
/** Who owns what this request does: a principal (svc:/app:) or a Network user. */
function owner(req, action) {
const auth = req.headers.get('authorization') || '';
@@@ -94,9 +159,9 @@ function createJobsService(ctx) {
const claims = ctx.decode(auth.slice(7));
if (claims && claims.actor_type) {
const who = ctx.principal(req, 'openvibe.tools', CAPS[action]);
-- return who.res ? who : { owner: who.claims.sub };
++ return who.res ? who : { owner: who.claims.sub, env: who.claims.env === 'sandbox' ? 'sandbox' : 'production' };
}
-- return claims ? { owner: `user:${claims.subject_id}` } : { res: problem(401, 'token.bad_signature', 'not a valid token') };
++ return claims ? { owner: `user:${claims.subject_id}`, env: 'production' } : { res: problem(401, 'token.bad_signature', 'not a valid token') };
}
function stream(req, job, after) {
@@@ -126,8 +191,27 @@ function createJobsService(ctx) {
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' } });
}
++ const noStore = { 'Cache-Control': 'no-store' };
++ /** The caller's job at this origin, or { res } (404 for anyone else's, like a missing one). */
++ function load(req, url, id, action) {
++ const who = owner(req, action);
++ if (who.res) return who;
++ const job = jobs.get(decodeURIComponent(id));
++ if (!job || job.owner !== who.owner || !visibleAt(job, url.origin)) return { res: problem(404, 'tools.job.not_found', 'No such job') };
++ return { job };
++ }
++
++ function retry(job) {
++ if (job.retriedBy) return { job: jobs.get(job.retriedBy), replayed: true };
++ if (job.state !== 'failed') return { res: problem(409, 'tools.job.not_failed', `Only failed jobs can be retried; this one is ${job.state}`, { state: job.state }) };
++ const next = create({ origin: job.origin, owner: job.owner, env: job.env, type: job.type, input: job.input, files: job.files, handler: job.handler, tool: job.tool, retryOf: job.id });
++ if (next.res) return next;
++ job.retriedBy = next.job.id;
++ return next;
++ }
++
async function handle(req, url) {
-- if (!ctx.opts.jobs) return problem(404, 'not_found', 'jobs are not enabled on this mock (createMockPlatform({ jobs: true }))');
++ if (!ctx.opts.jobs && !ctx.opts.tools) return problem(404, 'not_found', 'jobs are not enabled on this mock (createMockPlatform({ jobs: true }))');
const path = url.pathname;
let m;
if (path === '/api/v1/jobs' && req.method === 'POST') {
@@@ -150,40 +234,52 @@ function createJobsService(ctx) {
}
if (!type) return problem(400, 'tools.job.invalid', 'type is required');
if (!input || typeof input !== 'object' || Array.isArray(input)) return problem(400, 'tools.job.invalid', 'input must be a JSON object');
-- if (Object.keys(handlers).length && !handlers[type]) return problem(400, 'tools.job.unknown_type', `no job type ${type}`);
const key = req.headers.get('idempotency-key');
const hash = sha256hex(JSON.stringify([type, input, files.map((f) => sha256hex(f.bytes))]));
-- if (key) {
-- const prior = idem.get(`${url.origin}|${who.owner}|${key}`);
-- if (prior) {
-- if (prior.hash !== hash) return problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
-- return json(200, view(jobs.get(prior.id)), { Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true', 'Cache-Control': 'no-store' });
-- }
++ const out = create({ origin: url.origin, owner: who.owner, env: who.env, type, input, files, key, hash });
++ if (out.res) return out.res;
++ const headers = { Location: `/api/v1/jobs/${out.job.id}`, ...noStore, ...(out.replayed ? { 'Idempotent-Replayed': 'true' } : {}) };
++ return json(out.replayed ? 200 : 202, view(out.job), headers);
++ }
++ if ((m = path.match(/^\/api\/v1\/jobs\/([^/]+)\/retry$/))) {
++ if (req.method !== 'POST') return problem(405, 'method_not_allowed', 'method not allowed');
++ const got = load(req, url, m[1], 'create');
++ if (got.res) return got.res;
++ const out = retry(got.job);
++ if (out.res) return out.res;
++ return json(out.replayed ? 200 : 202, view(out.job), { Location: `/api/v1/jobs/${out.job.id}`, ...noStore, ...(out.replayed ? { 'Idempotent-Replayed': 'true' } : {}) });
++ }
++ if ((m = path.match(/^\/api\/v1\/jobs\/([^/]+)\/references\/([^/]+)$/))) {
++ if (req.method !== 'PUT' && req.method !== 'DELETE') return problem(405, 'method_not_allowed', 'method not allowed');
++ const got = load(req, url, m[1], 'create');
++ if (got.res) return got.res;
++ const { job } = got;
++ const ref = decodeURIComponent(m[2]);
++ if (!REF_RE.test(ref) || ref.length > 200) return problem(400, 'tools.job.invalid', 'A reference is <service>:<kind>:<id>, e.g. community:paste:p_123 (at most 200 characters)');
++ const has = job.references.some((r) => r.ref === ref);
++ if (req.method === 'DELETE') {
++ job.references = job.references.filter((r) => r.ref !== ref);
++ return json(200, view(job), noStore);
}
-- const job = {
-- id: `job_${ulid()}`, origin: url.origin, owner: who.owner, type, input, files, state: 'queued', percent: null, message: null,
-- cancelRequested: false, createdAt: Date.now(), startedAt: null, finishedAt: null, result: null, error: null, events: [],
-- };
-- jobs.set(job.id, job);
-- if (key) idem.set(`${url.origin}|${who.owner}|${key}`, { id: job.id, hash });
-- emit(job, 'job.queued');
-- runJob(job);
-- return json(202, view(job), { Location: `/api/v1/jobs/${job.id}`, 'Cache-Control': 'no-store' });
++ if (job.state !== 'succeeded') return problem(409, 'tools.job.not_succeeded', `Only a succeeded job's result can be referenced; this one is ${job.state}`, { state: job.state });
++ if (job.env === 'sandbox') return problem(409, 'tools.job.sandbox', 'Sandbox results are kept briefly and cannot be referenced');
++ if (!has && job.references.length >= MAX_REFS) return problem(409, 'tools.job.too_many_references', `At most ${MAX_REFS} references per job`);
++ if (!has) job.references.push({ ref, at: Date.now() });
++ return json(has ? 200 : 201, view(job), noStore);
}
if ((m = path.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/))) {
const action = req.method === 'DELETE' ? 'cancel' : 'read';
if (!['GET', 'DELETE'].includes(req.method) || (req.method === 'DELETE' && m[2])) return problem(405, 'method_not_allowed', 'method not allowed');
-- const who = owner(req, action);
-- if (who.res) return who.res;
-- const job = jobs.get(decodeURIComponent(m[1]));
-- if (!job || job.owner !== who.owner || job.origin !== url.origin) return problem(404, 'tools.job.not_found', 'No such job');
++ const got = load(req, url, m[1], action);
++ if (got.res) return got.res;
++ const { job } = got;
if (req.method === 'DELETE') {
if (job.state === 'succeeded' || job.state === 'failed') return problem(409, 'tools.job.already_finished', `The job already ${job.state}`);
-- if (job.state === 'queued') finish(job, 'cancelled');
++ if (job.state === 'queued') finish(job, 'cancelled', { error: problemBody(409, 'tools.job.cancelled', 'The job was cancelled before it started') });
else if (job.state === 'running' && !job.cancelRequested) { job.cancelRequested = true; emit(job, 'job.cancel_requested'); }
-- return json(job.state === 'cancelled' ? 200 : 202, view(job), { 'Cache-Control': 'no-store' });
++ return json(job.state === 'cancelled' ? 200 : 202, view(job), noStore);
}
-- if (!m[2]) return json(200, view(job), { 'Cache-Control': 'no-store' });
++ if (!m[2]) return json(200, view(job), noStore);
if (m[3] !== undefined) {
if (job.state !== 'succeeded') return problem(409, 'tools.job.not_ready', `The job is ${job.state}`);
const f = job.result.files[Number(m[3])];
@@@ -193,7 +289,7 @@ function createJobsService(ctx) {
}
const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
const after = Math.max(0, parseInt(raw, 10) || 0);
-- if (TERMINAL.has(job.state) && !job.events.some((e) => e.seq > after)) return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
++ if (TERMINAL.has(job.state) && !job.events.some((e) => e.seq > after)) return new Response(null, { status: 204, headers: noStore });
return stream(req, job, after);
}
return problem(404, 'not_found', 'Not found');
@@@ -202,9 +298,12 @@ function createJobsService(ctx) {
return {
handle,
jobs,
++ create,
++ view,
++ owner,
/** End every open job event stream (clients reconnect with Last-Event-ID). */
dropStreams() { for (const s of [...streams]) s.close(); },
};
}
--module.exports = { createJobsService, JOB_CAPS: CAPS };
++module.exports = { createJobsService, problemBody, JOB_CAPS: CAPS, TERMINAL_JOB_STATES: TERMINAL };
ddiff --git a/src/testing/tools.js b/src/testing/tools.js
nnew file mode 100644
iindex 0000000..71dd9ad
---- /dev/null
++++ b/src/testing/tools.js
@@@ -0,0 +1,366 @@
++'use strict';
++/**
++ * Mock OpenVibe.Tools platform API (ADR-027) on origins.tools, enabled with
++ * createMockPlatform({ tools: true | {…} }). Node only. Answers as openvibe-contracts v0.33.0 says:
++ *
++ * GET /api/v1/tools[?family&execution&api&status&q] tools.tool-list@1 (schemas as $ref)
++ * GET /api/v1/tools/:id tools.tool@1 (schemas embedded)
++ * GET /api/v1/tools/:id/schema { $schema, $id, $defs: { input, output } }
++ * POST /api/v1/tools/:id/run tools.run-request@1 (JSON or multipart) -> tools.run@1
++ *
++ * tools: {
++ * descriptors: [tools.tool@1, …], // added to the defaults (same id: replaced)
++ * handlers: { [toolId]: async (ctx) => … },
++ * mediaObjects: { 'med_…': { name, type, data } }, // what { media_id } references read
++ * }
++ *
++ * Handlers: an inline tool (execution client or sync) gets { input, files, caller, signal } and returns
++ * { data } (output kind json) or { text } (kind text); a string is text. A job tool's handler is its
++ * job's handler: { input (with the preset and operation applied), files, progress, cancelled } ->
++ * { data, files: [{ name, mime, bytes }] }. A handler that throws finishes the run `failed` with
++ * problem+json (err.code when it is a tools.… code, else tools.job.failed; err.status, else 422).
++ *
++ * The defaults: dns (sync, egress, anonymous), jsonminify (a client tool with a server engine), png
++ * (a job: img.process convert to PNG), port (a probe: tools.net.probe), yt (page-only, api false)
++ * and protectpdf (unavailable). Callers: no token runs tools with auth.anonymous true, except job
++ * tools (the mock has no browser sessions, so a job needs a token to have an owner); a person's
++ * token runs every tool but probes; app and service tokens need auth.capability for audience
++ * openvibe.tools. Not modelled: quotas and 429s (wrap platform.fetch for those), byte sniffing
++ * (accept is checked against the part's declared type), limits other than files.maxBytes.
++ */
++const { ulid, json, problem, sha256hex } = require('./util');
++const { problemBody, TERMINAL_JOB_STATES } = require('./jobs');
++
++const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
++const MEDIA_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++const JOB_ID_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/;
++const FAMILY_NAMES = { net: 'Network', dev: 'Developer', img: 'Images', audio: 'Audio', docs: 'Documents', text: 'Text', media: 'Media', places: 'Places', pastes: 'Pastes' };
++const RUN_FIELDS = new Set(['input', 'files', 'wait_ms', 'idempotency_key']);
++
++/** Descriptor defaults of the mock (valid tools.tool@1; schemas embedded, the list $refs them). */
++function defaultDescriptors() {
++ const base = (id, family, name, summary, extra) => ({
++ id, family, name, summary, status: 'stable', execution: 'sync', api: true,
++ run: { method: 'POST', path: `/api/v1/tools/${id}/run`, job: null },
++ input: { type: 'object', additionalProperties: false, properties: {} }, files: null, output: { kind: 'json', schema: { type: 'object' } },
++ limits: { timeoutMs: 10000 }, auth: { anonymous: true, capability: 'tools.tool.run' }, quotaClass: 'tools-run', cost: 1, egress: false,
++ hosts: [`${id}.openvibe.tools`], docs: `https://openvibe.tools/tool/${id}`, ...extra,
++ });
++ return [
++ base('dns', 'net', 'DNS Lookup', 'Look up A, AAAA, MX, TXT, NS and CNAME records for a domain.', {
++ run: { method: 'POST', path: '/api/v1/tools/dns/run', job: null, legacy: ['GET /api/net/dns/:target'] },
++ input: { type: 'object', required: ['target'], additionalProperties: false, properties: { target: { type: 'string', minLength: 1, maxLength: 253 }, type: { enum: ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME'] } } },
++ output: { kind: 'json', schema: { type: 'object', required: ['target', 'type', 'records'], properties: { target: { type: 'string' }, type: { type: 'string' }, records: { type: 'array' } } } },
++ limits: { timeoutMs: 10000, maxInputBytes: 1024, perTargetPerMinute: 30 }, quotaClass: 'tools-fetch', egress: true,
++ }),
++ base('jsonminify', 'dev', 'JSON Minifier', 'Strip the whitespace out of JSON.', {
++ execution: 'client',
++ input: { type: 'object', required: ['text'], additionalProperties: false, properties: { text: { type: 'string', maxLength: 1048576 } } },
++ output: { kind: 'text' }, limits: { timeoutMs: 5000, maxInputBytes: 1048576 },
++ hosts: ['json-minifier.openvibe.tools', 'jsonminify.openvibe.tools'],
++ }),
++ base('png', 'img', 'PNG Converter', 'Convert JPG, WebP, AVIF, GIF, BMP, TIFF and more to PNG.', {
++ execution: 'job',
++ run: { method: 'POST', path: '/api/v1/tools/png/run', job: { type: 'img.process', operation: 'convert', preset: { format: 'png' } }, legacy: ['POST /api/process', 'POST /api/process/direct'] },
++ input: { type: 'object', additionalProperties: false, properties: { quality: { type: 'integer', minimum: 1, maximum: 100 } } },
++ files: { min: 1, max: 1, accept: ['image/*'], maxBytes: 52428800 },
++ output: { kind: 'file', mime: ['image/png'] }, limits: { timeoutMs: 120000, maxPixels: 100000000 },
++ auth: { anonymous: false, capability: 'tools.tool.run' }, quotaClass: 'tools-job', cost: 5,
++ }),
++ base('port', 'net', 'Port Checker', 'Check whether TCP ports on a host are open.', {
++ status: 'beta',
++ run: { method: 'POST', path: '/api/v1/tools/port/run', job: null, legacy: ['GET /api/net/port/:target'] },
++ input: { type: 'object', required: ['host', 'ports'], additionalProperties: false, properties: { host: { type: 'string', maxLength: 253 }, ports: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'integer', minimum: 1, maximum: 65535 } } } },
++ output: { kind: 'json', schema: { type: 'object', properties: { results: { type: 'array' } } } },
++ limits: { timeoutMs: 15000, perTargetPerMinute: 6 }, auth: { anonymous: false, capability: 'tools.net.probe' }, quotaClass: 'tools-probe', cost: 5, egress: true,
++ }),
++ base('yt', 'media', 'YouTube Downloader', 'Save a YouTube video or its audio.', {
++ execution: 'job', api: false, run: null, input: null, output: { kind: 'file', mime: ['video/mp4', 'audio/mpeg'] },
++ limits: { timeoutMs: 900000, maxDurationSec: 3600, perTargetPerMinute: 20 }, quotaClass: 'tools-download', cost: 50, egress: true,
++ hosts: ['youtube-downloader.openvibe.tools', 'yt.openvibe.tools'],
++ }),
++ base('protectpdf', 'docs', 'Protect PDF', 'Put a password on a PDF (AES-256).', {
++ status: 'unavailable', statusReason: 'Needs qpdf on the host; not deployed yet.', execution: 'job',
++ run: { method: 'POST', path: '/api/v1/tools/protectpdf/run', job: { type: 'docs.process', operation: 'protect' } },
++ input: { type: 'object', required: ['password'], properties: { password: { type: 'string', minLength: 1, maxLength: 128 } } },
++ files: { min: 1, max: 1, accept: ['application/pdf'], maxBytes: 104857600 }, output: { kind: 'file', mime: ['application/pdf'] },
++ limits: { timeoutMs: 300000, maxPages: 2000 }, auth: { anonymous: false, capability: 'tools.tool.run' }, quotaClass: 'tools-job', cost: 10,
++ }),
++ ];
++}
++
++/** Handlers of the default tools: deterministic, never on the network. */
++function defaultHandlers() {
++ return {
++ dns: async ({ input }) => {
++ const type = input.type || 'A';
++ const records = { A: ['192.0.2.1'], AAAA: ['2001:db8::1'], MX: [`10 mail.${input.target}`], TXT: ['v=spf1 -all'], NS: [`ns1.${input.target}`], CNAME: [] }[type];
++ return { data: { target: input.target, type, records } };
++ },
++ jsonminify: async ({ input }) => {
++ try { return { text: JSON.stringify(JSON.parse(input.text)) }; } catch (err) {
++ throw Object.assign(new Error(`Not valid JSON: ${err.message}`), { code: 'tools.jsonminify.invalid_json', status: 422 });
++ }
++ },
++ png: async ({ files, progress }) => {
++ await progress(50, 'Converting');
++ return { data: { format: 'png' }, files: files.map((f) => ({ name: `${String(f.name || 'image').replace(/\.[^.]*$/, '')}.png`, mime: 'image/png', bytes: f.bytes })) };
++ },
++ port: async ({ input }) => ({ data: { results: input.ports.map((port) => ({ host: input.host, port, open: false })) } }),
++ };
++}
++
++/** A minimal check of an object's top-level fields against an input schema -> [{ path, message }]. */
++function checkInput(schema, input) {
++ const errors = [];
++ if (!schema || typeof schema !== 'object' || schema.$ref) return errors;
++ const props = schema.properties || {};
++ for (const k of schema.required || []) if (!(k in input)) errors.push({ path: `/${k}`, message: 'is required' });
++ for (const [k, v] of Object.entries(input)) {
++ const p = props[k];
++ if (!p) {
++ if (schema.additionalProperties === false) errors.push({ path: `/${k}`, message: 'is not a field of this tool' });
++ continue;
++ }
++ const type = Array.isArray(v) ? 'array' : v === null ? 'null' : Number.isInteger(v) ? 'integer' : typeof v;
++ if (p.type && !(p.type === type || (p.type === 'number' && type === 'integer'))) errors.push({ path: `/${k}`, message: `must be ${p.type}` });
++ else if (p.enum && !p.enum.includes(v)) errors.push({ path: `/${k}`, message: `must be one of ${p.enum.join(', ')}` });
++ else if (typeof v === 'string' && ((p.minLength && v.length < p.minLength) || (p.maxLength && v.length > p.maxLength))) errors.push({ path: `/${k}`, message: 'has the wrong length' });
++ else if (typeof v === 'number' && ((p.minimum !== undefined && v < p.minimum) || (p.maximum !== undefined && v > p.maximum))) errors.push({ path: `/${k}`, message: 'is out of range' });
++ else if (Array.isArray(v) && ((p.minItems && v.length < p.minItems) || (p.maxItems && v.length > p.maxItems))) errors.push({ path: `/${k}`, message: 'has the wrong number of items' });
++ }
++ return errors;
++}
++
++const accepts = (accept, type) => !type || accept.some((a) => a === type || (a.endsWith('/*') && type.startsWith(a.slice(0, -1))));
++
++function createToolsService(ctx) {
++ const cfg = ctx.opts.tools && typeof ctx.opts.tools === 'object' ? ctx.opts.tools : {};
++ const jobsCfg = ctx.opts.jobs && typeof ctx.opts.jobs === 'object' ? ctx.opts.jobs : {};
++ const stepMs = cfg.stepMs ?? jobsCfg.stepMs ?? 5;
++ const gateway = new URL(ctx.origins.tools).origin;
++ const tools = new Map();
++ const handlers = { ...defaultHandlers(), ...(cfg.handlers || {}) };
++ const media = new Map(Object.entries(cfg.mediaObjects || {}));
++ const runKeys = new Map(); // `${owner}|${key}` -> { jobId, hash }
++ let updatedAt = new Date().toISOString();
++ const clone = (x) => JSON.parse(JSON.stringify(x));
++
++ function addTool(d, handler) {
++ if (!d || typeof d !== 'object' || !ID_RE.test(String(d.id || ''))) throw new TypeError('mock platform: a tool descriptor needs an id (lowercase letters, digits, dashes)');
++ tools.set(d.id, clone(d));
++ if (typeof handler === 'function') handlers[d.id] = handler;
++ updatedAt = new Date().toISOString();
++ return tools.get(d.id);
++ }
++ for (const d of defaultDescriptors()) tools.set(d.id, d);
++ for (const d of cfg.descriptors || []) addTool(d);
++
++ const schemaUrl = (id) => `${gateway}/api/v1/tools/${id}/schema`;
++ /** The list form: embedded schemas become { $ref } to GET /api/v1/tools/:id/schema. */
++ function listForm(d) {
++ const out = clone(d);
++ if (out.input && !out.input.$ref) out.input = { $ref: `${schemaUrl(d.id)}#/$defs/input` };
++ if (out.output.schema && !out.output.schema.$ref) out.output.schema = { $ref: `${schemaUrl(d.id)}#/$defs/output` };
++ return out;
++ }
++
++ function list(url) {
++ const q = url.searchParams;
++ const api = q.get('api');
++ const text = (q.get('q') || '').trim().toLowerCase();
++ const items = [...tools.values()].filter((d) => (!q.get('family') || d.family === q.get('family'))
++ && (!q.get('execution') || d.execution === q.get('execution'))
++ && (!q.get('status') || d.status === q.get('status'))
++ && (api === null || d.api === ['true', '1'].includes(api))
++ && (!text || [d.id, d.name, d.summary, d.family].some((s) => String(s).toLowerCase().includes(text))));
++ const families = [...new Set(items.map((d) => d.family))].map((id) => ({ id, name: FAMILY_NAMES[id] || id[0].toUpperCase() + id.slice(1), count: items.filter((d) => d.family === id).length }));
++ return json(200, { tools: items.map(listForm), count: items.length, updated_at: updatedAt, families }, { 'Cache-Control': 'public, max-age=60' });
++ }
++
++ /** Who runs this tool: { owner, tier } or { res } (401/403 as the gateway answers). */
++ function caller(req, d) {
++ const auth = req.headers.get('authorization') || '';
++ if (!auth) {
++ if (d.auth.anonymous && d.execution !== 'job') return { owner: null, tier: 'anonymous' };
++ return { res: problem(401, 'token.missing', `${d.id} needs a signed-in person or a token${d.auth.anonymous ? ' (the mock has no browser sessions for job owners)' : ''}`) };
++ }
++ if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no Bearer token') };
++ const claims = ctx.decode(auth.slice(7));
++ if (!claims) return { res: problem(401, 'token.bad_signature', 'not a valid token') };
++ if (claims.actor_type) {
++ const who = ctx.principal(req, 'openvibe.tools', d.auth.capability);
++ return who.res ? who : { owner: claims.sub, env: claims.env === 'sandbox' ? 'sandbox' : 'production', tier: claims.actor_type };
++ }
++ if (d.auth.capability === 'tools.net.probe') return { res: problem(403, 'capability.denied', 'Network probes run through the API only for principals holding tools.net.probe; people use the tool page') };
++ return { owner: `user:${claims.subject_id}`, env: 'production', tier: 'user' };
++ }
++
++ /** tools.run-request@1 from JSON or multipart -> { body, uploads } or { res }. */
++ async function readRun(req) {
++ const ct = req.headers.get('content-type') || '';
++ const invalid = (detail) => ({ res: problem(400, 'tools.run.invalid', detail) });
++ if (ct.startsWith('multipart/form-data')) {
++ let form;
++ try { form = await req.formData(); } catch { return invalid('not a multipart body'); }
++ const body = {};
++ const uploads = [];
++ for (const [name, value] of form.entries()) {
++ if (typeof value !== 'string') {
++ if (name !== 'file' && name !== 'files') return invalid(`a file part is named file or files, not ${name}`);
++ uploads.push({ name: value.name || 'file', type: value.type || '', bytes: Buffer.from(await value.arrayBuffer()) });
++ continue;
++ }
++ if (!RUN_FIELDS.has(name)) return invalid(`unknown field ${name}`);
++ if (name === 'input' || name === 'files') {
++ try { body[name] = JSON.parse(value || (name === 'input' ? '{}' : '[]')); } catch { return invalid(`${name} must be JSON`); }
++ } else if (name === 'wait_ms') body.wait_ms = Number(value);
++ else body[name] = value;
++ }
++ return { body, uploads };
++ }
++ let body;
++ try { body = await req.json(); } catch { return invalid('send JSON (tools.run-request@1) or multipart'); }
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return invalid('the body is a JSON object');
++ for (const k of Object.keys(body)) if (!RUN_FIELDS.has(k)) return invalid(`unknown field ${k}`);
++ return { body, uploads: [] };
++ }
++
++ /** The files a reference names, read for this caller -> { file } or { res }. */
++ function resolveRef(ref, who) {
++ const notFound = (what) => ({ res: problem(404, 'tools.run.file_not_found', `${what} is not a file you can read`) });
++ if (ref && typeof ref.media_id === 'string' && MEDIA_ID_RE.test(ref.media_id) && Object.keys(ref).length === 1) {
++ const o = media.get(ref.media_id);
++ if (!o) return notFound(ref.media_id);
++ return { file: { name: o.name || 'file', type: o.type || o.mime || 'application/octet-stream', bytes: Buffer.from(o.data || o.bytes || '') } };
++ }
++ if (ref && typeof ref.job_id === 'string' && JOB_ID_RE.test(ref.job_id) && Number.isInteger(ref.index) && ref.index >= 0 && ref.index <= 99 && Object.keys(ref).length === 2) {
++ const job = ctx.jobs.jobs.get(ref.job_id);
++ const f = job && job.owner === who.owner && job.state === 'succeeded' ? job.result.files[ref.index] : null;
++ if (!f) return notFound(`${ref.job_id} file ${ref.index}`);
++ return { file: { name: f.name, type: f.mime, bytes: f.bytes } };
++ }
++ return { res: problem(400, 'tools.run.invalid', 'a files reference is { media_id } or { job_id, index }') };
++ }
++
++ const tookMs = (job) => Math.max(0, (job.finishedAt || Date.now()) - job.createdAt);
++ /** A job run as tools.run@1. */
++ function runView(d, job) {
++ const view = ctx.jobs.view(job);
++ if (job.state === 'succeeded') return { state: 'succeeded', tool: d.id, result: { data: view.result.data, files: view.result.files }, took_ms: tookMs(job), job: view };
++ if (job.state === 'failed' || job.state === 'cancelled') return { state: job.state, tool: d.id, error: view.error, took_ms: tookMs(job), job: view };
++ return { state: job.state, tool: d.id, job: view, location: view.links.self };
++ }
++
++ async function waitFor(job, ms, signal) {
++ const end = Date.now() + ms;
++ while (!TERMINAL_JOB_STATES.has(job.state) && Date.now() < end && !(signal && signal.aborted)) {
++ await new Promise((r) => setTimeout(r, Math.min(stepMs, Math.max(1, end - Date.now()))));
++ }
++ }
++
++ async function run(req, url, d) {
++ if (d.status === 'unavailable') return problem(503, 'tools.tool.unavailable', d.statusReason || `${d.id} is unavailable`);
++ if (!d.api || !d.run) return problem(404, 'tools.tool.not_runnable', `${d.id} has no run API; use its page`);
++ const who = caller(req, d);
++ if (who.res) return who.res;
++ const got = await readRun(req);
++ if (got.res) return got.res;
++ const { body, uploads } = got;
++ const input = body.input === undefined ? {} : body.input;
++ if (!input || typeof input !== 'object' || Array.isArray(input)) return problem(400, 'tools.run.invalid', 'input is a JSON object');
++ const wait = body.wait_ms === undefined ? Number(url.searchParams.get('wait_ms') || 0) : body.wait_ms;
++ if (!Number.isInteger(wait) || wait < 0 || wait > 60000) return problem(400, 'tools.run.invalid', 'wait_ms is an integer, 0..60000');
++ if (body.files !== undefined && (!Array.isArray(body.files) || body.files.length > 50)) return problem(400, 'tools.run.invalid', 'files is an array of at most 50 references');
++ const refs = body.files || [];
++ const files = [...uploads];
++ for (const ref of refs) {
++ const r = resolveRef(ref, who);
++ if (r.res) return r.res;
++ files.push(r.file);
++ }
++ const spec = d.files;
++ if (!spec && files.length) return problem(400, 'tools.run.invalid', `${d.id} takes no files`);
++ if (spec && (files.length < spec.min || files.length > spec.max)) return problem(400, 'tools.run.invalid', `${d.id} takes ${spec.min === spec.max ? spec.min : `${spec.min} to ${spec.max}`} file(s), got ${files.length}`);
++ if (spec) {
++ for (const f of files) {
++ if (f.bytes.length > spec.maxBytes) return problem(413, 'tools.file.too_large', `${f.name} is over ${spec.maxBytes} bytes`);
++ if (!accepts(spec.accept, f.type)) return problem(415, 'tools.file.unsupported_type', `${f.name} (${f.type}) is not ${spec.accept.join(', ')}`);
++ }
++ }
++ const errors = checkInput(d.input, input);
++ if (errors.length) return problem(422, 'tools.input.invalid', `The input does not match ${d.id}'s input schema`, { errors });
++
++ if (d.execution !== 'job') {
++ const started = Date.now();
++ const handler = handlers[d.id];
++ if (!handler) return problem(503, 'tools.tool.unavailable', `the mock has no handler for ${d.id} (tools.handlers)`);
++ try {
++ const out = await handler({ input, files, caller: { owner: who.owner, tier: who.tier }, signal: req.signal });
++ const result = typeof out === 'string' ? { text: out } : out && (out.data !== undefined || out.text !== undefined) ? { ...(out.data !== undefined ? { data: out.data } : {}), ...(out.text !== undefined ? { text: String(out.text) } : {}) } : { data: out || {} };
++ return json(200, { state: 'succeeded', tool: d.id, result, took_ms: Date.now() - started }, { 'Cache-Control': 'no-store' });
++ } catch (err) {
++ const code = /^tools\.[a-z0-9_.]+$/.test(String((err && err.code) || '')) ? err.code : 'tools.job.failed';
++ const status = err && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599 ? err.status : 422;
++ return json(200, { state: 'failed', tool: d.id, error: problemBody(status, code, (err && err.message) || 'The tool failed'), took_ms: Date.now() - started }, { 'Cache-Control': 'no-store' });
++ }
++ }
++
++ // A job tool: one job per (caller, Idempotency-Key) and request.
++ const key = req.headers.get('idempotency-key') || (typeof body.idempotency_key === 'string' ? body.idempotency_key : null);
++ const hash = sha256hex(JSON.stringify([d.id, input, refs, uploads.map((f) => sha256hex(f.bytes))]));
++ let job;
++ let replayed = false;
++ const prior = key ? runKeys.get(`${who.owner}|${key}`) : null;
++ if (prior) {
++ if (prior.hash !== hash) return problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
++ job = ctx.jobs.jobs.get(prior.jobId);
++ replayed = true;
++ } else {
++ const jobInput = { ...input, ...(d.run.job.preset || {}), tool: d.run.job.operation };
++ const handler = handlers[d.id] || null;
++ const out = ctx.jobs.create({ origin: gateway, owner: who.owner, env: who.env, type: d.run.job.type, input: jobInput, files, handler: handler || (async ({ files: fs }) => ({ data: {}, files: fs.map((f) => ({ name: f.name, mime: f.type, bytes: f.bytes })) })), tool: d.id });
++ if (out.res) return out.res;
++ job = out.job;
++ if (key) runKeys.set(`${who.owner}|${key}`, { jobId: job.id, hash });
++ }
++ if (wait > 0) await waitFor(job, wait, req.signal);
++ const view = runView(d, job);
++ const headers = { 'Cache-Control': 'no-store', ...(replayed ? { 'Idempotent-Replayed': 'true' } : {}) };
++ if (view.location) headers.Location = view.location;
++ return json(replayed || !view.location ? 200 : 202, view, headers);
++ }
++
++ async function handle(req, url) {
++ if (!ctx.opts.tools) return problem(404, 'not_found', 'the tools API is not enabled on this mock (createMockPlatform({ tools: true }))');
++ if (url.origin !== gateway) return problem(404, 'not_found', 'the tools API is on the gateway (origins.tools)');
++ const path = url.pathname;
++ let m;
++ if (path === '/api/v1/tools' && req.method === 'GET') return list(url);
++ if ((m = path.match(/^\/api\/v1\/tools\/([^/]+)(\/schema|\/run)?$/))) {
++ const d = tools.get(decodeURIComponent(m[1]));
++ if (!d) return problem(404, 'tools.tool.not_found', `No tool ${decodeURIComponent(m[1])}`);
++ if (m[2] === '/run') return req.method === 'POST' ? run(req, url, d) : problem(405, 'method_not_allowed', 'POST a tools.run-request@1');
++ if (req.method !== 'GET') return problem(405, 'method_not_allowed', 'method not allowed');
++ if (m[2] === '/schema') {
++ return json(200, { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: schemaUrl(d.id), $defs: { input: d.input ? clone(d.input) : null, output: d.output.schema ? clone(d.output.schema) : null } });
++ }
++ return json(200, clone(d), { 'Cache-Control': 'public, max-age=60' });
++ }
++ return problem(404, 'not_found', 'Not found');
++ }
++
++ return {
++ handle,
++ tools,
++ addTool,
++ /** Store a Media object that { media_id } references read; returns its id. */
++ addMediaObject({ id, name = 'file', type = 'application/octet-stream', data = '' } = {}) {
++ const mid = id || `med_${ulid()}`;
++ media.set(mid, { name, type, data });
++ return mid;
++ },
++ };
++}
++
++module.exports = { createToolsService, defaultDescriptors };
ddiff --git a/src/tools.js b/src/tools.js
nnew file mode 100644
iindex 0000000..cc08fa8
---- /dev/null
++++ b/src/tools.js
@@@ -0,0 +1,266 @@
++'use strict';
++/**
++ * openvibe-sdk/tools: the OpenVibe.Tools platform API (ADR-027) on the gateway, https://openvibe.tools:
++ * the tool registry, the uniform run API, and the jobs client for tools that run as jobs.
++ * Browser-safe.
++ *
++ * const tools = createToolsClient(client);
++ * const { tools: list } = await tools.list({ family: 'img', api: true });
++ * const png = await tools.get('png'); // tools.tool@1, or null
++ * const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });
++ * out.result.text; // an inline tool: finished
++ * const run = await tools.run('png', {}, { files: [{ name: 'a.jpg', data: bytes }] });
++ * run.state; // a job tool: 'queued', with run.job, run.location
++ * const done = await run.wait(); // { state: 'succeeded', result: { files, data }, job }
++ * const res = await tools.jobs.file(done.job.id, 0); // raw Response
++ *
++ * Routes (audience openvibe.tools):
++ * GET /api/v1/tools[?family&execution&api&status&q] tools.tool-list@1 (schemas as $ref) tools.tool.read,
++ * GET /api/v1/tools/:id tools.tool@1 (schemas embedded) public and
++ * GET /api/v1/tools/:id/schema { $schema, $id, $defs: { input, output } } anonymous
++ * POST /api/v1/tools/:id/run tools.run-request@1 -> tools.run@1: tools.tool.run
++ * 200 finished (succeeded, or failed/cancelled with the tool's own problem), 202 + Location
++ * while a job is queued or running; tools.net.probe for network probes (partner)
++ * /api/v1/jobs… the gateway's jobs facade (openvibe-sdk/jobs)
++ *
++ * Callers are tiered anonymous < session < user < app/service. A client without a token runs the
++ * tools whose descriptor has auth.anonymous true; a person's token runs every tool but the probes; an
++ * app or service token needs auth.capability (tools.tool.run, or tools.net.probe). Registry reads
++ * send no token unless `anonymousReads: false`.
++ */
++const { OpenVibeError, isOpenVibeError } = require('./core/errors');
++const { newIdempotencyKey } = require('./core/ids');
++const { isUpload, appendFiles } = require('./core/form');
++const { createJobsClient } = require('./jobs');
++
++const enc = encodeURIComponent;
++const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
++const MEDIA_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++const JOB_ID_RE = /^job_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MAX_WAIT_MS = 60000;
++/** Time on top of wait_ms (or a tool's limits.timeoutMs) for the request itself. */
++const RUN_MARGIN_MS = 10000;
++
++/**
++ * A run that finished `failed` or `cancelled`: the tool's own problem+json, mapped like any other
++ * OpenVibeError (code, status, title, detail, type, errors, requestId, traceId). `status` is the
++ * problem's status (422, 504…), not the HTTP status of the answer (200). Also: `state`, `tool`,
++ * `job` (tools.job@1 when the tool ran as a job: `jobs.retry(err.job.id)` when `retryable`) and `run`
++ * (the tools.run@1 answer, when there was one).
++ */
++class ToolRunError extends OpenVibeError {
++ constructor({ state, tool, error, job = null, run = null, requestId, traceId } = {}) {
++ const p = error && typeof error === 'object' ? error : {};
++ const code = typeof p.code === 'string' && p.code ? p.code : state === 'cancelled' ? 'tools.job.cancelled' : 'tools.job.failed';
++ const detail = typeof p.detail === 'string' ? p.detail : null;
++ super({
++ message: `tools.run ${tool} ${state}: ${code}${detail ? `: ${detail}` : ''}`,
++ code,
++ status: Number.isInteger(p.status) ? p.status : 0,
++ title: typeof p.title === 'string' ? p.title : undefined,
++ detail: detail || undefined,
++ type: typeof p.type === 'string' ? p.type : undefined,
++ errors: Array.isArray(p.errors) ? p.errors : undefined,
++ problem: typeof p.code === 'string' && typeof p.status === 'number' ? p : null,
++ requestId: p.request_id || requestId,
++ traceId: p.trace_id || traceId,
++ retryable: Boolean(job && job.retryable),
++ });
++ this.state = state;
++ this.tool = tool;
++ this.job = job || null;
++ this.run = run || null;
++ }
++}
++
++/** A run that finished failed or cancelled (a ToolRunError, also from another copy of the SDK). */
++function isToolRunError(err) {
++ return err instanceof ToolRunError
++ || Boolean(isOpenVibeError(err) && typeof err.tool === 'string' && (err.state === 'failed' || err.state === 'cancelled') && 'job' in err);
++}
++
++function checkId(id, where) {
++ if (typeof id !== 'string' || !ID_RE.test(id)) throw new TypeError(`${where}: a tool id is lowercase letters, digits and dashes (png, jsonminify, dns)`);
++}
++
++/** A files reference of tools.run-request@1, normalized to exactly its fields; null for an upload. */
++function asRef(f) {
++ if (!f || typeof f !== 'object' || isUpload(f)) return null;
++ if (f.media_id !== undefined) {
++ if (typeof f.media_id !== 'string' || !MEDIA_ID_RE.test(f.media_id)) throw new TypeError('tools.run: media_id is a Media object id (med_<ULID>)');
++ return { media_id: f.media_id };
++ }
++ if (f.job_id !== undefined) {
++ if (typeof f.job_id !== 'string' || !JOB_ID_RE.test(f.job_id)) throw new TypeError('tools.run: job_id is a Tools job id (job_<ULID>)');
++ if (!Number.isInteger(f.index) || f.index < 0 || f.index > 99) throw new TypeError('tools.run: a job file reference needs index, 0..99');
++ return { job_id: f.job_id, index: f.index };
++ }
++ throw new TypeError('tools.run: a file is an upload (Blob/File, or { name, data, type? }) or a reference ({ media_id } or { job_id, index })');
++}
++
++const tookMs = (job) => {
++ const a = Date.parse(job && job.created_at);
++ const b = Date.parse(job && job.finished_at);
++ return Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : 0;
++};
++
++/**
++ * createToolsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools', anonymousReads = true })
++ * -> { list, get, schema, run, jobs }
++ *
++ * Without `baseUrl` the gateway is the `tools` service origin from the platform descriptor.
++ * `jobs` is openvibe-sdk/jobs on the same origin and credentials (the gateway's jobs facade).
++ */
++function createToolsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools', anonymousReads = true } = {}) {
++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
++ const read = (path, query, signal) => call({ path, query, signal, ...(anonymousReads ? { auth: false } : {}) }).then((r) => r.data);
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const jobs = createJobsClient(client, { baseUrl, service, audience });
++ const limits = new Map(); // id -> limits.timeoutMs, from descriptors this client has read
++ const remember = (d) => { if (d && d.id && d.limits && Number.isInteger(d.limits.timeoutMs)) limits.set(d.id, d.limits.timeoutMs); };
++
++ /**
++ * The registry: tools.tool-list@1 { tools, count, updated_at, families } with every descriptor's
++ * schemas as { $ref }. Filters: family, execution ('client' | 'sync' | 'job'), api (boolean), status, q.
++ */
++ async function list({ family, q, execution, api, status, signal } = {}) {
++ const query = { family, q, execution, status, api: api === undefined || api === null ? undefined : String(Boolean(api)) };
++ const out = await read('/api/v1/tools', query, signal);
++ if (out && Array.isArray(out.tools)) out.tools.forEach(remember);
++ return out;
++ }
++
++ /** One descriptor (tools.tool@1, schemas embedded), or null when there is no such tool. */
++ async function get(id, { signal } = {}) {
++ checkId(id, 'tools.get');
++ const d = await orNull(read(`/api/v1/tools/${enc(id)}`, undefined, signal));
++ remember(d);
++ return d;
++ }
++
++ /** { $schema, $id, $defs: { input, output } } (what the list's $refs point at), or null. */
++ async function schema(id, { signal } = {}) {
++ checkId(id, 'tools.schema');
++ return orNull(read(`/api/v1/tools/${enc(id)}/schema`, undefined, signal));
++ }
++
++ const hide = (obj, name, value) => Object.defineProperty(obj, name, { value, enumerable: false, configurable: true, writable: true });
++
++ function succeeded({ tool, result, took_ms, job, location, meta }) {
++ const out = { state: 'succeeded', tool, result: result || {}, took_ms: Number.isFinite(took_ms) ? took_ms : 0 };
++ if (job) {
++ out.job = job;
++ out.location = location || (job.links && job.links.self) || `/api/v1/jobs/${job.id}`;
++ }
++ out.idempotencyKey = meta.idempotencyKey;
++ out.replayed = Boolean(meta.replayed);
++ return hide(out, 'wait', async () => out);
++ }
++
++ /** A finished job as the run it belongs to: succeeded, or a thrown ToolRunError. */
++ function fromJob(job, tool, meta) {
++ if (!job) throw new OpenVibeError({ code: 'tools.job.not_found', status: 404, message: `tools.run ${tool}: the job is gone (expired, or not yours)` });
++ if (job.state === 'succeeded') {
++ const r = job.result || { files: [], data: {} };
++ return succeeded({ tool, result: { data: r.data || {}, files: r.files || [] }, took_ms: tookMs(job), job, location: job.links && job.links.self, meta });
++ }
++ if (job.state === 'failed' || job.state === 'cancelled') throw new ToolRunError({ state: job.state, tool, error: job.error, job });
++ return pending({ state: job.state, tool, job, location: job.links && job.links.self, meta });
++ }
++
++ function pending({ state, tool, job, location, meta }) {
++ const out = { state, tool, job, location: location || (job.links && job.links.self) || `/api/v1/jobs/${job.id}`, idempotencyKey: meta.idempotencyKey, replayed: Boolean(meta.replayed) };
++ return hide(out, 'wait', async (opts = {}) => fromJob(await jobs.wait(job.id, opts), tool, meta));
++ }
++
++ /** A tools.run@1 answer (or a bare tools.job@1) -> the resolved value, or a thrown ToolRunError. */
++ function settle(body, tool, meta) {
++ if (body && body.object === 'tools.job') return fromJob(body, tool, meta); // a server that answers with the job itself
++ if (!body || typeof body !== 'object' || typeof body.state !== 'string') {
++ throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, requestId: meta.requestId, traceId: meta.traceId, message: `tools.run ${tool}: the answer is not a tools.run@1 run` });
++ }
++ const name = body.tool || tool;
++ if (body.state === 'queued' || body.state === 'running') {
++ if (!body.job || !body.job.id) throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, message: `tools.run ${tool}: ${body.state} without its job` });
++ return pending({ state: body.state, tool: name, job: body.job, location: body.location || meta.location, meta });
++ }
++ if (body.state === 'succeeded') return succeeded({ tool: name, result: body.result, took_ms: body.took_ms, job: body.job || null, location: body.location || meta.location, meta });
++ if (body.state === 'failed' || body.state === 'cancelled') {
++ throw new ToolRunError({ state: body.state, tool: name, error: body.error || (body.job && body.job.error), job: body.job || null, run: body, requestId: meta.requestId, traceId: meta.traceId });
++ }
++ throw new OpenVibeError({ code: 'sdk.bad_response', status: meta.status, message: `tools.run ${tool}: unknown state ${body.state}` });
++ }
++
++ /**
++ * run(id, input = {}, { files, waitMs, idempotencyKey, signal, timeoutMs })
++ * -> { state: 'succeeded', tool, result: { data | text | files }, took_ms, job?, location?, idempotencyKey, replayed }
++ * | { state: 'queued' | 'running', tool, job, location, idempotencyKey, replayed }
++ * Every resolved run has a non-enumerable `wait(opts)`: a finished run resolves to itself; a job
++ * follows the job's events (jobs.wait, same options: signal, onEvent, lastEventId) and resolves
++ * to the succeeded run, or throws ToolRunError.
++ *
++ * - files: uploads (Blob/File, or { name, data, type? }, sent as multipart `file` parts) and
++ * references ({ media_id } a Media object you may read; { job_id, index } a result file of your
++ * own job), in that order. References go in the JSON body's `files`, or, beside uploads, as a
++ * multipart `files` part holding their JSON.
++ * - waitMs: a job tool waits up to this long (0..60000) before answering; 200 finished, else 202.
++ * - idempotencyKey: generated when omitted (returned). The same key and request give the same
++ * job (`replayed: true`); another request under it is 409 tools.job.idempotency_conflict. The
++ * key also makes the POST safe to retry: 429 (Retry-After) and 5xx are retried by the client.
++ * - timeoutMs: per attempt. Default: the client's, raised to waitMs, or to the tool's
++ * limits.timeoutMs once this client has read its descriptor (get or list), plus 10 s.
++ * A request refused before the tool ran (404 tools.tool.not_found | not_runnable, 422
++ * tools.input.invalid, 403 capability.denied, 503 tools.tool.unavailable…) throws OpenVibeError.
++ */
++ async function run(id, input = {}, { files, waitMs, idempotencyKey, signal, timeoutMs } = {}) {
++ checkId(id, 'tools.run');
++ if (input == null) input = {};
++ if (typeof input !== 'object' || Array.isArray(input)) throw new TypeError('tools.run: input must be an object');
++ if (waitMs != null && (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > MAX_WAIT_MS)) throw new TypeError(`tools.run: waitMs is an integer, 0..${MAX_WAIT_MS}`);
++ const list = files == null ? [] : Array.isArray(files) ? files : [files];
++ const refs = [];
++ const uploads = [];
++ for (const f of list) {
++ const ref = asRef(f);
++ if (ref) refs.push(ref);
++ else if (isUpload(f)) uploads.push(f);
++ else throw new TypeError('tools.run: a file is an upload (Blob/File, or { name, data, type? }) or a reference ({ media_id } or { job_id, index })');
++ }
++ if (idempotencyKey != null && (typeof idempotencyKey !== 'string' || !/^[!-~]{8,200}$/.test(idempotencyKey))) {
++ throw new TypeError('tools.run: idempotencyKey is 8-200 printable ASCII characters');
++ }
++ const key = idempotencyKey || newIdempotencyKey();
++
++ const opts = { method: 'POST', path: `/api/v1/tools/${enc(id)}/run`, idempotencyKey: key, signal };
++ if (uploads.length) {
++ const form = new FormData();
++ form.append('input', JSON.stringify(input));
++ if (refs.length) form.append('files', JSON.stringify(refs));
++ if (waitMs != null) form.append('wait_ms', String(waitMs));
++ opts.form = appendFiles(form, uploads, 'tools.run');
++ } else {
++ const body = { input };
++ if (refs.length) body.files = refs;
++ if (waitMs != null) body.wait_ms = waitMs;
++ opts.json = body;
++ }
++ const base = client.options || {};
++ const floor = Math.max(waitMs || 0, limits.get(id) || 0);
++ opts.timeoutMs = timeoutMs ?? Math.max(base.timeoutMs || 0, floor ? floor + RUN_MARGIN_MS : 0);
++ opts.deadlineMs = Math.max(base.deadlineMs || 0, opts.timeoutMs * 2);
++
++ const res = await call(opts);
++ return settle(res.data, id, {
++ idempotencyKey: key,
++ replayed: String(res.headers.get('idempotent-replayed') || '') === 'true',
++ location: res.headers.get('location') || undefined,
++ status: res.status,
++ requestId: res.requestId,
++ traceId: res.traceId,
++ });
++ }
++
++ return { list, get, schema, run, jobs };
++}
++
++module.exports = { createToolsClient, ToolRunError, isToolRunError };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex b35c1ef..5767f10 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -78,7 +78,7 @@ run([
assert.deepEqual(problems, []);
assert.deepEqual(blocked.sort(), ['./events', './identity', './testing']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
-- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
}
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {
ddiff --git a/test/bundle.test.js b/test/bundle.test.js
iindex 703b9b4..63399a8 100644
---- a/test/bundle.test.js
++++ b/test/bundle.test.js
@@@ -29,7 +29,7 @@ run([
}
const ids = collect().map((m) => m.id);
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);
-- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);
++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/tools.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);
}],
['exports match the browser entry', async () => {
@@@ -37,7 +37,7 @@ run([
const cjs = require('../browser.js');
assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());
assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());
-- for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'projects']) {
++ for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'tools', 'projects']) {
assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);
}
assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex 3b349c7..bc24a44 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -2,9 +2,11 @@
/**
* openvibe-sdk/jobs against the mock platform's Tools jobs: submit once (Idempotency-Key), follow
* events across dropped streams and a restart (Last-Event-ID), 204 when finished, cancel, result
-- * files as raw Responses, owner scoping, failures, and sandbox refusal.
++ * files as raw Responses, owner scoping, failures, sandbox refusal, retry, result references, and
++ * the gateway's jobs facade as the default origin. Job views are checked against tools.job@1.
*/
const assert = require('node:assert/strict');
++const contracts = require('openvibe-contracts');
const { run, stubServer, send } = require('./helpers');
const { createClient } = require('../src/core');
const { createServiceTokenClient } = require('../src/auth');
@@@ -118,7 +120,9 @@ run([
const failed = await jobs.submit({ type: 'boom' });
const out = await jobs.wait(failed.job.id);
assert.equal(out.state, 'failed');
-- assert.deepEqual(out.error, { code: 'tools.job.invalid_input', detail: 'bad input' });
++ assert.deepEqual(out.error, { type: 'https://openvibe.network/problems/tools.job.invalid_input', title: 'Unprocessable Content', status: 422, code: 'tools.job.invalid_input', detail: 'bad input', error: 'bad input' });
++ assert.equal(out.links.retry, `/api/v1/jobs/${out.id}/retry`);
++ assert.equal(cancelled.error.code, 'tools.job.cancelled');
await assert.rejects(jobs.cancel(failed.job.id), { status: 409, code: 'tools.job.already_finished' });
await assert.rejects(jobs.file(failed.job.id, 0), { status: 409, code: 'tools.job.not_ready' });
await assert.rejects(jobs.submit({ type: 'nope' }), { status: 400, code: 'tools.job.unknown_type' });
@@@ -183,4 +187,81 @@ run([
assert.equal(down.requests.length, 3);
await down.close();
}],
++
++ ['retry: a failed job becomes a new job once; asking again replays it; other states are refused', async () => {
++ let n = 0;
++ const { jobs, other, platform } = setup({ handlers: { 'img.process': async ({ files }) => { if (++n === 1) throw Object.assign(new Error('decoder crashed'), { code: 'tools.img.decoder', retryable: true }); return { data: { n }, files: files.map((f) => ({ name: f.name, mime: 'image/webp', bytes: f.bytes })) }; } } });
++ const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert' }, files: [{ name: 'a.png', data: 'PNG' }] });
++ const failed = await jobs.wait(job.id);
++ assert.equal(failed.state, 'failed');
++ assert.equal(failed.retryable, true);
++ assert.deepEqual(contracts.validate('tools.job@1', failed).errors, []);
++
++ const a = await jobs.retry(job.id);
++ assert.equal(a.replayed, false);
++ assert.notEqual(a.job.id, job.id);
++ assert.equal(a.job.retry_of, job.id);
++ assert.equal(a.job.state, 'queued');
++ const b = await jobs.retry(job.id);
++ assert.equal(b.replayed, true);
++ assert.equal(b.job.id, a.job.id);
++ const retried = platform.stats.requests.filter((r) => r.url.endsWith('/retry'));
++ assert.ok(retried.every((r) => r.method === 'POST' && !r.headers['idempotency-key']), 'the server dedupes retries: no key needed');
++
++ const done = await jobs.wait(a.job.id);
++ assert.equal(done.state, 'succeeded');
++ assert.deepEqual(done.result.data, { n: 2 });
++ assert.equal(Buffer.from(await (await jobs.file(done.id, 0)).arrayBuffer()).toString(), 'PNG', 'the retry has the input files');
++ const original = await jobs.get(job.id);
++ assert.equal(original.retried_by, a.job.id);
++ assert.equal(original.links.retried_by, `/api/v1/jobs/${a.job.id}`);
++ await assert.rejects(jobs.retry(done.id), { status: 409, code: 'tools.job.not_failed' });
++ await assert.rejects(other.retry(job.id), { status: 404, code: 'tools.job.not_found' });
++ }],
++
++ ['references keep a succeeded result (expires_at null) until the last one is dropped', async () => {
++ const { jobs } = setup({ handlers: { 'docs.process': async () => ({ data: { pages: 3 } }) } });
++ const { job } = await jobs.submit({ type: 'docs.process', input: { tool: 'merge' } });
++ await assert.rejects(jobs.reference(job.id, 'community:paste:p_123'), { status: 409, code: 'tools.job.not_succeeded' });
++ const done = await jobs.wait(job.id);
++ assert.ok(done.expires_at, 'a result expires by default');
++
++ const kept = await jobs.reference(job.id, 'community:paste:p_123');
++ assert.equal(kept.expires_at, null);
++ assert.deepEqual(kept.references.map((r) => r.ref), ['community:paste:p_123']);
++ assert.deepEqual(contracts.validate('tools.job@1', kept).errors, []);
++ const again = await jobs.reference(job.id, 'community:paste:p_123');
++ assert.equal(again.references.length, 1, 'idempotent');
++ await jobs.reference(job.id, 'live:clip:c_9');
++ const one = await jobs.unreference(job.id, 'community:paste:p_123');
++ assert.deepEqual(one.references.map((r) => r.ref), ['live:clip:c_9']);
++ assert.equal(one.expires_at, null, 'still referenced');
++ const none = await jobs.unreference(job.id, 'live:clip:c_9');
++ assert.deepEqual(none.references, []);
++ assert.ok(none.expires_at, 'expires again after the last reference');
++ assert.equal((await jobs.unreference(job.id, 'live:clip:c_9')).references.length, 0, 'dropping a missing reference is fine');
++
++ await assert.rejects(jobs.reference(job.id, 'not a ref'), { status: 400, code: 'tools.job.invalid' });
++ await assert.rejects(jobs.reference(job.id, ''), TypeError);
++ await assert.rejects(jobs.reference('job_01K5WZX7S7Q4D2B8N3M6V1C9TR', 'community:paste:p_1'), { status: 404, code: 'tools.job.not_found' });
++ }],
++
++ ['no baseUrl: the gateway facade (origins.tools), which also finds the satellites\' jobs; an explicit baseUrl wins', async () => {
++ const { platform } = setup();
++ const clientFor = () => createClient({ fetch: platform.fetch, retryDelayMs: 5, tokenProvider: createServiceTokenClient({ clientId: APP, clientSecret: 'a', fetch: platform.fetch }) });
++ const gateway = createJobsClient(clientFor());
++ const img = createJobsClient(clientFor(), { baseUrl: 'https://img.openvibe.tools' });
++ const audio = createJobsClient(clientFor(), { baseUrl: 'https://audio.openvibe.tools' });
++
++ const { job } = await img.submit({ type: 'img.process', input: { tool: 'convert' } });
++ assert.equal(job.service, 'img');
++ assert.equal((await gateway.wait(job.id)).state, 'succeeded', 'the facade fronts the satellite');
++ assert.equal(await audio.get(job.id), null);
++ assert.ok(platform.stats.requests.some((r) => r.url === `https://openvibe.tools/api/v1/jobs/${job.id}`));
++
++ const viaGateway = (await gateway.submit({ type: 'audio.process', input: { tool: 'trim' } })).job;
++ assert.equal(viaGateway.service, 'audio');
++ assert.equal((await audio.get(viaGateway.id)).id, viaGateway.id, 'a gateway job lives on the satellite of its type');
++ assert.equal(await img.get(viaGateway.id), null);
++ }],
]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 95942ab..c2076ac 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -56,12 +56,17 @@ run([
const bundle = await import('openvibe-sdk/browser/openvibe-sdk.mjs');
assert.equal(bundle.SDK_VERSION, ${JSON.stringify(pkg.version)});
assert.equal(typeof bundle.jobs.createJobsClient, 'function');
++ assert.equal(typeof bundle.tools.createToolsClient, 'function');
++ const { createToolsClient } = require('openvibe-sdk/tools');
++ const tp = createMockPlatform({ tools: true });
++ const ran = await createToolsClient(createClient({ fetch: tp.fetch })).run('jsonminify', { text: '{ "a": 1 }' });
++ assert.equal(ran.result.text, '{"a":1}');
assert.equal(typeof require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'string');
console.log('consumer ok: ' + specs.length + ' subpaths + the browser bundle');
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 14);
++ assert.equal(subpaths.length, 15);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/tools.test.js b/test/tools.test.js
nnew file mode 100644
iindex 0000000..db20436
---- /dev/null
++++ b/test/tools.test.js
@@@ -0,0 +1,419 @@
++'use strict';
++/**
++ * openvibe-sdk/tools against a stand-in of the Tools gateway: the mock platform's tools API
++ * (createMockPlatform({ tools })), wrapped so every run request is checked against
++ * tools.run-request@1 and every answer against its contract (tools.tool-list@1 + checkList,
++ * tools.tool@1 + checkDescriptor, tools.run@1, tools.job@1, errors.problem@1) from the pinned
++ * openvibe-contracts devDependency. Hooks inject what the mock does not model (429, lost answers).
++ */
++const assert = require('node:assert/strict');
++const contracts = require('openvibe-contracts');
++const { run } = require('./helpers');
++const { createClient, OpenVibeError, isOpenVibeError } = require('../src/core');
++const { createServiceTokenClient } = require('../src/auth');
++const { createToolsClient, ToolRunError, isToolRunError } = require('../src/tools');
++const { createJobsClient } = require('../src/jobs');
++const { createMockPlatform } = require('../src/testing');
++
++const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';
++const PROBER = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TS';
++const OTHER = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TT';
++const RUNNER = ['tools.tool.run', 'tools.job.create', 'tools.job.read', 'tools.job.cancel'];
++
++/** A job tool descriptor for tests (tools.tool@1). */
++const jobTool = (id, extra = {}) => ({
++ id, family: 'docs', name: id, summary: `The ${id} test tool.`, status: 'stable', execution: 'job', api: true,
++ run: { method: 'POST', path: `/api/v1/tools/${id}/run`, job: { type: 'docs.process', operation: id } },
++ input: { type: 'object', properties: {} }, files: null, output: { kind: 'json', schema: { type: 'object' } },
++ limits: { timeoutMs: 60000 }, auth: { anonymous: false, capability: 'tools.tool.run' }, quotaClass: 'tools-job', cost: 2, egress: false,
++ hosts: [], docs: `https://openvibe.tools/tool/${id}`, ...extra,
++});
++const syncTool = (id, extra = {}) => ({ ...jobTool(id), family: 'dev', execution: 'sync', run: { method: 'POST', path: `/api/v1/tools/${id}/run`, job: null }, auth: { anonymous: true, capability: 'tools.tool.run' }, quotaClass: 'tools-run', ...extra });
++
++/**
++ * standIn({ tools, …platform options }, { before, after }) -> { platform, fetch, violations, runs, responses, client(), anon(), user() }
++ * before(req, url) may answer instead of the mock; after(req, url, res) may replace its answer.
++ */
++function standIn(opts = {}, { before, after } = {}) {
++ const { tools = {}, apps = {}, ...rest } = opts;
++ const platform = createMockPlatform({
++ tools: { stepMs: 2, ...tools },
++ users: [{ username: 'ana' }],
++ apps: {
++ [APP]: { env: 'production', secret: 'a', grants: RUNNER },
++ [PROBER]: { env: 'production', secret: 'p', grants: ['tools.net.probe', 'tools.tool.run'] },
++ [OTHER]: { env: 'production', secret: 'o', grants: RUNNER },
++ ...apps,
++ },
++ ...rest,
++ });
++ const violations = [];
++ const runs = [];
++ const responses = [];
++ const check = (ref, value, where) => {
++ const r = ref === 'list' ? contracts.tools.checkList(value) : ref === 'descriptor' ? contracts.tools.checkDescriptor(value) : contracts.validate(ref, value);
++ if (!r.valid) violations.push(`${where}: ${JSON.stringify(r.errors)}`);
++ };
++
++ async function fetch(input, init) {
++ const req = new Request(input, init);
++ const url = new URL(req.url);
++ const isRun = req.method === 'POST' && /^\/api\/v1\/tools\/[^/]+\/run$/.test(url.pathname);
++ if (isRun) {
++ const copy = req.clone();
++ const multipart = (copy.headers.get('content-type') || '').startsWith('multipart/form-data');
++ const body = {};
++ const uploads = [];
++ if (multipart) {
++ for (const [k, v] of (await copy.formData()).entries()) {
++ if (typeof v !== 'string') uploads.push({ part: k, name: v.name, type: v.type, text: await v.text() });
++ else body[k] = k === 'wait_ms' ? Number(v) : k === 'input' || k === 'files' ? JSON.parse(v) : v;
++ }
++ } else Object.assign(body, await copy.json());
++ runs.push({ tool: url.pathname.split('/')[4], key: req.headers.get('idempotency-key'), auth: req.headers.get('authorization'), multipart, body, uploads });
++ check('tools.run-request@1', body, `request ${url.pathname}`);
++ }
++ let res = before ? await before(req, url) : null;
++ if (!res) res = await platform.fetch(req);
++ if (after) res = (await after(req, url, res)) || res;
++ responses.push({ method: req.method, path: url.pathname, status: res.status });
++ if (/json/.test(res.headers.get('content-type') || '')) {
++ const data = await res.clone().json();
++ const where = `${req.method} ${url.pathname} ${res.status}`;
++ if (!res.ok) check('errors.problem@1', data, where);
++ else if (url.pathname === '/api/v1/tools') check('list', data, where);
++ else if (/^\/api\/v1\/tools\/[^/]+$/.test(url.pathname)) check('descriptor', data, where);
++ else if (isRun) check('tools.run@1', data, where);
++ else if (/^\/api\/v1\/jobs(\/|$)/.test(url.pathname)) check('tools.job@1', data, where);
++ }
++ return res;
++ }
++
++ const make = (extra) => createClient({ fetch, retryDelayMs: 5, ...extra });
++ const appClient = (id = APP, secret = 'a', extra = {}) => make({ tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }), ...extra });
++ return {
++ platform, fetch, violations, runs, responses,
++ client: appClient,
++ anon: (extra) => make(extra),
++ user: (extra) => make({ token: platform.signUserToken([...platform.state.users.values()][0]), ...extra }),
++ };
++}
++
++const statusOf = (s, path) => s.responses.filter((r) => r.path === path).map((r) => r.status);
++
++run([
++ ['registry: list (tools.tool-list@1, filters), get (tools.tool@1), schema; 404 -> null', async () => {
++ const s = standIn();
++ for (const d of s.platform.state.tools.values()) assert.deepEqual(contracts.tools.checkDescriptor(d).errors, [], `mock default ${d.id}`);
++ const tools = createToolsClient(s.client());
++
++ const all = await tools.list();
++ assert.equal(all.count, 6);
++ assert.deepEqual(all.tools.map((t) => t.id).sort(), ['dns', 'jsonminify', 'png', 'port', 'protectpdf', 'yt']);
++ assert.deepEqual(all.tools.find((t) => t.id === 'png').input, { $ref: 'https://openvibe.tools/api/v1/tools/png/schema#/$defs/input' }, 'the list $refs the schemas');
++ assert.ok(all.families.some((f) => f.id === 'net' && f.count === 2));
++ assert.deepEqual((await tools.list({ family: 'net' })).tools.map((t) => t.id), ['dns', 'port']);
++ assert.deepEqual((await tools.list({ api: false })).tools.map((t) => t.id), ['yt']);
++ assert.deepEqual((await tools.list({ execution: 'job', q: 'PNG' })).tools.map((t) => t.id), ['png']);
++ const reads = s.platform.stats.requests.filter((r) => r.url.includes('/api/v1/tools'));
++ assert.ok(reads.some((r) => r.url === 'https://openvibe.tools/api/v1/tools?api=false'), 'api=false is sent, not dropped');
++ assert.ok(reads.every((r) => !r.headers.authorization), 'registry reads are anonymous by default');
++
++ const png = await tools.get('png');
++ assert.equal(png.execution, 'job');
++ assert.equal(png.input.type, 'object', 'get embeds the schemas');
++ assert.deepEqual(png.run.job, { type: 'img.process', operation: 'convert', preset: { format: 'png' } });
++ assert.equal(await tools.get('nope'), null);
++ const schema = await tools.schema('dns');
++ assert.equal(schema.$id, 'https://openvibe.tools/api/v1/tools/dns/schema');
++ assert.deepEqual(schema.$defs.input.required, ['target']);
++ assert.deepEqual(Object.keys(schema.$defs.output.properties), ['target', 'type', 'records']);
++ assert.equal(await tools.schema('nope'), null);
++ await assert.rejects(tools.get('Not An Id'), TypeError);
++ await assert.rejects(tools.schema('../x'), TypeError);
++
++ await createToolsClient(s.client(), { anonymousReads: false }).get('dns');
++ assert.match(s.platform.stats.requests.at(-1).headers.authorization, /^Bearer /, 'anonymousReads: false sends the token');
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['sync run: finished inline; the body is tools.run-request@1 with a generated Idempotency-Key', async () => {
++ const s = standIn();
++ const anon = createToolsClient(s.anon());
++ const out = await anon.run('jsonminify', { text: '{ "a": [1, 2] }' });
++ assert.deepEqual({ ...out, took_ms: 0 }, { state: 'succeeded', tool: 'jsonminify', result: { text: '{"a":[1,2]}' }, took_ms: 0, idempotencyKey: out.idempotencyKey, replayed: false });
++ assert.equal(typeof out.took_ms, 'number');
++ assert.equal(await out.wait(), out, 'a finished run waits for nothing');
++ assert.ok(!('wait' in JSON.parse(JSON.stringify(out))), 'wait is not data');
++ assert.deepEqual(s.runs[0].body, { input: { text: '{ "a": [1, 2] }' } });
++ assert.equal(s.runs[0].multipart, false);
++ assert.equal(s.runs[0].auth, null, 'no token: anonymous tier');
++ assert.match(s.runs[0].key, /^idem_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.equal(s.runs[0].key, out.idempotencyKey);
++ assert.deepEqual(statusOf(s, '/api/v1/tools/jsonminify/run'), [200]);
++
++ const tools = createToolsClient(s.client());
++ const dns = await tools.run('dns', { target: 'example.com', type: 'MX' });
++ assert.deepEqual(dns.result.data, { target: 'example.com', type: 'MX', records: ['10 mail.example.com'] });
++ assert.equal(dns.job, undefined, 'an inline run has no job');
++ await assert.rejects(tools.run('dns', { type: 'A' }), (err) => {
++ assert.ok(isOpenVibeError(err) && !isToolRunError(err), 'a refusal before the tool ran is a plain OpenVibeError');
++ assert.equal(err.status, 422);
++ assert.equal(err.code, 'tools.input.invalid');
++ assert.deepEqual(err.errors, [{ path: '/target', message: 'is required' }]);
++ return true;
++ });
++ await assert.rejects(tools.run('yt', {}), { status: 404, code: 'tools.tool.not_runnable' });
++ await assert.rejects(tools.run('nope', {}), { status: 404, code: 'tools.tool.not_found' });
++ await assert.rejects(createToolsClient(s.client(APP, 'a', { retries: 0 })).run('protectpdf', { password: 'x' }), { status: 503, code: 'tools.tool.unavailable' });
++ await assert.rejects(tools.run('dns', 'example.com'), TypeError);
++ await assert.rejects(tools.run('dns', {}, { waitMs: 60001 }), TypeError);
++
++ // Tiers: probes need tools.net.probe; a person uses the page, not the API.
++ const probe = { host: 'example.com', ports: [22, 443] };
++ await assert.rejects(anon.run('port', probe), { status: 401, code: 'token.missing' });
++ await assert.rejects(createToolsClient(s.user()).run('port', probe), { status: 403, code: 'capability.denied' });
++ await assert.rejects(tools.run('port', probe), { status: 403, code: 'capability.denied' });
++ const ok = await createToolsClient(s.client(PROBER, 'p')).run('port', probe);
++ assert.deepEqual(ok.result.data.results.map((r) => r.port), [22, 443]);
++ assert.equal((await createToolsClient(s.user()).run('dns', { target: 'example.org' })).state, 'succeeded');
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['job run: 202 -> { state, job, location } -> wait() -> the result; waitMs answers finished', async () => {
++ const s = standIn();
++ const client = s.client();
++ const tools = createToolsClient(client);
++ const r = await tools.run('png', { quality: 80 }, { files: [{ name: 'photo.jpg', data: Buffer.from('JPEG'), type: 'image/jpeg' }] });
++ assert.equal(r.state, 'queued');
++ assert.equal(r.tool, 'png');
++ assert.match(r.job.id, /^job_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.equal(r.job.tool, 'png');
++ assert.equal(r.job.type, 'img.process');
++ assert.equal(r.location, `/api/v1/jobs/${r.job.id}`);
++ assert.equal(r.replayed, false);
++ assert.deepEqual(statusOf(s, '/api/v1/tools/png/run'), [202]);
++ assert.equal(s.runs[0].multipart, true);
++ assert.deepEqual(s.runs[0].body, { input: { quality: 80 } });
++ assert.deepEqual(s.runs[0].uploads, [{ part: 'file', name: 'photo.jpg', type: 'image/jpeg', text: 'JPEG' }]);
++ assert.deepEqual(s.platform.state.jobs.get(r.job.id).input, { quality: 80, format: 'png', tool: 'convert' }, 'the preset and the operation win');
++
++ const events = [];
++ const done = await r.wait({ onEvent: (e) => events.push(e.event) });
++ assert.equal(done.state, 'succeeded');
++ assert.deepEqual(done.result.data, { format: 'png' });
++ assert.deepEqual(done.result.files.map((f) => [f.name, f.mime, f.size, f.storage]), [['photo.png', 'image/png', 4, 'local']]);
++ assert.equal(done.job.state, 'succeeded');
++ assert.equal(done.location, r.location);
++ assert.equal(typeof done.took_ms, 'number');
++ assert.deepEqual(events.slice(0, 2), ['job.queued', 'job.running']);
++ assert.equal(events.at(-1), 'job.succeeded');
++
++ // The same job through the jobs client: the gateway facade, the default origin.
++ assert.equal((await tools.jobs.wait(r.job.id)).state, 'succeeded');
++ assert.equal(Buffer.from(await (await tools.jobs.file(r.job.id, 0)).arrayBuffer()).toString(), 'JPEG');
++ assert.equal((await createJobsClient(client).get(r.job.id)).tool, 'png');
++ const jobCalls = s.platform.stats.requests.filter((q) => q.url.includes('/api/v1/jobs/'));
++ assert.ok(jobCalls.length && jobCalls.every((q) => q.url.startsWith('https://openvibe.tools/api/v1/jobs/')), 'jobs go to the gateway');
++
++ const quick = await tools.run('png', {}, { files: [{ name: 'b.gif', data: 'GIF', type: 'image/gif' }], waitMs: 5000 });
++ assert.equal(quick.state, 'succeeded', 'finished within wait_ms: 200 with the result');
++ assert.equal(quick.job.state, 'succeeded');
++ assert.equal(quick.location, `/api/v1/jobs/${quick.job.id}`);
++ assert.equal(await quick.wait(), quick);
++ assert.equal(s.runs.at(-1).body.wait_ms, 5000);
++ assert.deepEqual(statusOf(s, '/api/v1/tools/png/run'), [202, 200]);
++
++ await assert.rejects(createToolsClient(s.anon()).run('png', {}, { files: [{ name: 'a.png', data: 'x', type: 'image/png' }] }), { status: 401, code: 'token.missing' });
++ await assert.rejects(tools.run('png', {}), { status: 400, code: 'tools.run.invalid' });
++ await assert.rejects(tools.run('png', {}, { files: [{ name: 'a.pdf', data: 'x', type: 'application/pdf' }] }), { status: 415, code: 'tools.file.unsupported_type' });
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['a failed run throws ToolRunError (problem+json mapped); a failed job is retried with jobs.retry', async () => {
++ let calls = 0;
++ const s = standIn({
++ tools: {
++ descriptors: [jobTool('flaky')],
++ handlers: {
++ flaky: async ({ progress }) => {
++ await progress(10, 'trying');
++ if (++calls === 1) throw Object.assign(new Error('The engine was busy'), { code: 'tools.flaky.busy', status: 503, retryable: true });
++ return { data: { attempt: calls } };
++ },
++ },
++ },
++ });
++ const tools = createToolsClient(s.client());
++
++ const err = await tools.run('jsonminify', { text: '{' }).then(() => null, (e) => e);
++ assert.ok(err instanceof ToolRunError && err instanceof OpenVibeError && isOpenVibeError(err) && isToolRunError(err));
++ assert.equal(err.name, 'OpenVibeError');
++ assert.equal(err.code, 'tools.jsonminify.invalid_json');
++ assert.equal(err.status, 422, 'the problem status, not the HTTP 200');
++ assert.equal(err.title, 'Unprocessable Content');
++ assert.match(err.detail, /^Not valid JSON/);
++ assert.equal(err.state, 'failed');
++ assert.equal(err.tool, 'jsonminify');
++ assert.equal(err.job, null);
++ assert.equal(err.run.state, 'failed');
++ assert.equal(err.problem.code, 'tools.jsonminify.invalid_json');
++ assert.equal(err.retryable, false);
++
++ const pending = await tools.run('flaky', {});
++ const failed = await pending.wait().then(() => null, (e) => e);
++ assert.ok(isToolRunError(failed));
++ assert.equal(failed.code, 'tools.flaky.busy');
++ assert.equal(failed.status, 503);
++ assert.equal(failed.retryable, true);
++ assert.equal(failed.job.state, 'failed');
++ assert.equal(failed.job.links.retry, `/api/v1/jobs/${failed.job.id}/retry`);
++ assert.equal(failed.run, null, 'it came from the job, not from a run answer');
++
++ const again = await tools.jobs.retry(failed.job.id);
++ assert.equal(again.replayed, false);
++ assert.equal(again.job.retry_of, failed.job.id);
++ assert.equal(again.job.tool, 'flaky');
++ const twice = await tools.jobs.retry(failed.job.id);
++ assert.equal(twice.replayed, true, 'asking again returns the same retry');
++ assert.equal(twice.job.id, again.job.id);
++ assert.deepEqual(statusOf(s, `/api/v1/jobs/${failed.job.id}/retry`), [202, 200]);
++ const fixed = await tools.jobs.wait(again.job.id);
++ assert.equal(fixed.state, 'succeeded');
++ assert.deepEqual(fixed.result.data, { attempt: 2 });
++ assert.equal((await tools.jobs.get(failed.job.id)).retried_by, again.job.id);
++ await assert.rejects(tools.jobs.retry(again.job.id), { status: 409, code: 'tools.job.not_failed' });
++
++ calls = 0;
++ const direct = await tools.run('flaky', {}, { waitMs: 5000 }).then(() => null, (e) => e);
++ assert.ok(isToolRunError(direct), 'failed within wait_ms: thrown from the 200 answer');
++ assert.equal(direct.job.state, 'failed');
++ assert.equal(direct.run.job.id, direct.job.id);
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['429 with Retry-After is retried after the delay, with the same Idempotency-Key', async () => {
++ let throttled = 0;
++ const s = standIn({}, {
++ before: async (req, url) => {
++ if (!url.pathname.endsWith('/run') || throttled++) return null;
++ return new Response(JSON.stringify({ type: 'https://openvibe.network/problems/quota.exceeded', title: 'Too Many Requests', status: 429, code: 'quota.exceeded', detail: 'tools-fetch: 30 per minute' }), {
++ status: 429, headers: { 'Content-Type': 'application/problem+json', 'Retry-After': '1' },
++ });
++ },
++ });
++ const tools = createToolsClient(s.anon());
++ const t0 = Date.now();
++ const out = await tools.run('dns', { target: 'example.com' });
++ const took = Date.now() - t0;
++ assert.equal(out.state, 'succeeded');
++ assert.ok(took >= 900, `waited for Retry-After (${took} ms)`);
++ assert.deepEqual(statusOf(s, '/api/v1/tools/dns/run'), [429, 200]);
++ assert.equal(s.runs.length, 2);
++ assert.equal(s.runs[0].key, s.runs[1].key);
++ assert.equal(out.idempotencyKey, s.runs[0].key);
++
++ throttled = 0;
++ await assert.rejects(createToolsClient(s.anon({ retries: 0 })).run('dns', { target: 'example.com' }), { status: 429, code: 'quota.exceeded', retryable: true });
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['idempotent replay: the same key gets the same job; a lost answer is retried with the same key', async () => {
++ let lose = false;
++ const s = standIn({}, {
++ after: async (req, url, res) => {
++ if (!lose || !url.pathname.endsWith('/run')) return null;
++ lose = false; // the job was created, but its answer never arrived
++ return new Response(JSON.stringify({ type: 'https://openvibe.network/problems/http.503', title: 'Service Unavailable', status: 503, code: 'upstream.unavailable' }), { status: 503, headers: { 'Content-Type': 'application/problem+json' } });
++ },
++ });
++ const tools = createToolsClient(s.client());
++ const file = { name: 'a.png', data: Buffer.from('PNG'), type: 'image/png' };
++ const a = await tools.run('png', { quality: 50 }, { files: [file], idempotencyKey: 'idem_example_key_0001' });
++ const b = await tools.run('png', { quality: 50 }, { files: [file], idempotencyKey: 'idem_example_key_0001' });
++ assert.equal(a.replayed, false);
++ assert.equal(b.replayed, true);
++ assert.equal(b.job.id, a.job.id);
++ assert.equal(b.idempotencyKey, 'idem_example_key_0001');
++ assert.deepEqual(s.runs.map((r) => r.key), ['idem_example_key_0001', 'idem_example_key_0001']);
++ assert.deepEqual(statusOf(s, '/api/v1/tools/png/run'), [202, 200]);
++ await assert.rejects(tools.run('png', { quality: 60 }, { files: [file], idempotencyKey: 'idem_example_key_0001' }), { status: 409, code: 'tools.job.idempotency_conflict' });
++ await assert.rejects(tools.run('png', {}, { idempotencyKey: 'short' }), TypeError);
++
++ const before = s.platform.state.jobs.size;
++ lose = true;
++ const c = await tools.run('png', {}, { files: [file] });
++ assert.equal(c.replayed, true, 'the retry found the job the lost answer created');
++ assert.equal(s.platform.state.jobs.size, before + 1, 'one job, not two');
++ const [first, second] = s.runs.slice(-2);
++ assert.equal(first.key, second.key, 'the generated key is reused by the retry');
++ assert.equal(first.key, c.idempotencyKey);
++ assert.equal((await c.wait()).state, 'succeeded');
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['abort: a signal cancels a pending run and a wait', async () => {
++ let release;
++ const gate = new Promise((r) => { release = r; });
++ const s = standIn({
++ tools: {
++ descriptors: [syncTool('slow'), jobTool('slowjob')],
++ handlers: {
++ slow: ({ signal }) => new Promise((resolve) => { const t = setTimeout(() => resolve({ data: {} }), 10000); signal.addEventListener('abort', () => { clearTimeout(t); resolve({ data: {} }); }); }),
++ slowjob: async ({ progress }) => { await progress(1, 'waiting'); await gate; return { data: {} }; },
++ },
++ },
++ });
++ const tools = createToolsClient(s.client());
++ const ctrl = new AbortController();
++ setTimeout(() => ctrl.abort(), 30);
++ const t0 = Date.now();
++ await assert.rejects(tools.run('slow', {}, { signal: ctrl.signal }), { code: 'sdk.aborted' });
++ assert.ok(Date.now() - t0 < 2000, 'aborted promptly');
++ await assert.rejects(tools.run('slow', {}, { signal: AbortSignal.abort() }), { code: 'sdk.aborted' });
++
++ const r = await tools.run('slowjob', {});
++ const stop = new AbortController();
++ setTimeout(() => stop.abort(), 40);
++ await assert.rejects(r.wait({ signal: stop.signal }), { code: 'sdk.aborted' });
++ assert.equal((await tools.jobs.get(r.job.id)).state, 'running', 'aborting the wait leaves the job alone');
++ release();
++ assert.equal((await r.wait()).state, 'succeeded');
++ assert.deepEqual(s.violations, []);
++ }],
++
++ ['files: { media_id } and { job_id, index } references in JSON, beside uploads in multipart', async () => {
++ const s = standIn({
++ tools: {
++ descriptors: [jobTool('merge', { files: { min: 2, max: 5, accept: ['text/plain', 'image/*'], maxBytes: 1024 } })],
++ handlers: { merge: async ({ files }) => ({ data: { parts: files.map((f) => `${f.name}:${f.bytes.toString()}`) } }) },
++ },
++ });
++ const mid = s.platform.addMediaObject({ name: 'logo.gif', type: 'image/gif', data: 'GIF89a' });
++ const tools = createToolsClient(s.client());
++
++ const fromMedia = await (await tools.run('png', {}, { files: [{ media_id: mid }] })).wait();
++ assert.deepEqual(s.runs[0].body, { input: {}, files: [{ media_id: mid }] });
++ assert.equal(s.runs[0].multipart, false, 'references alone: a JSON body');
++ assert.equal(fromMedia.result.files[0].name, 'logo.png');
++
++ const chained = await (await tools.run('png', {}, { files: { job_id: fromMedia.job.id, index: 0 } })).wait();
++ assert.deepEqual(s.runs[1].body, { input: {}, files: [{ job_id: fromMedia.job.id, index: 0 }] });
++ assert.equal(Buffer.from(await (await tools.jobs.file(chained.job.id, 0)).arrayBuffer()).toString(), 'GIF89a', "one tool's output feeds the next");
++
++ const merged = await (await tools.run('merge', {}, { files: [{ media_id: mid }, { name: 'a.txt', data: 'A', type: 'text/plain' }, { job_id: chained.job.id, index: 0 }], waitMs: 0 })).wait();
++ const sent = s.runs[2];
++ assert.equal(sent.multipart, true);
++ assert.deepEqual(sent.body, { input: {}, files: [{ media_id: mid }, { job_id: chained.job.id, index: 0 }], wait_ms: 0 });
++ assert.deepEqual(sent.uploads, [{ part: 'file', name: 'a.txt', type: 'text/plain', text: 'A' }]);
++ assert.deepEqual(merged.result.data.parts, ['a.txt:A', 'logo.gif:GIF89a', 'logo.png:GIF89a'], 'uploads first, then the references in order');
++
++ await assert.rejects(tools.run('png', {}, { files: [{ media_id: 'med_01K5WZX7S7Q4D2B8N3M6V1C9TR' }] }), { status: 404, code: 'tools.run.file_not_found' });
++ await assert.rejects(createToolsClient(s.client(OTHER, 'o')).run('png', {}, { files: [{ job_id: chained.job.id, index: 0 }] }), { status: 404, code: 'tools.run.file_not_found' }, "another caller's job is not readable");
++ await assert.rejects(tools.run('png', {}, { files: [{ job_id: chained.job.id, index: 7 }] }), { status: 404, code: 'tools.run.file_not_found' });
++ for (const bad of [{ media_id: 'x' }, { job_id: chained.job.id, index: -1 }, { job_id: 'job_1', index: 0 }, { foo: 1 }, 42]) {
++ await assert.rejects(tools.run('png', {}, { files: [bad] }), TypeError, JSON.stringify(bad));
++ }
++ assert.deepEqual(s.violations, []);
++ }],
++]);
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 09557eb..5f85b8a 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -6,6 +6,7 @@ import * as realtime from './realtime';
import * as media from './media';
import * as community from './community';
import * as jobs from './jobs';
++import * as tools from './tools';
import * as projects from './projects';
import * as core from './core';
@@@ -17,10 +18,11 @@ export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
export * as jobs from './jobs';
++export * as tools from './tools';
export * as projects from './projects';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; jobs: typeof jobs; projects: typeof projects;
++ media: typeof media; community: typeof community; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
};
export default sdk;
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex c6ac852..0dcb71d 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -1,5 +1,6 @@
--/* Copied verbatim from openvibe-contracts v0.28.0 generated/typescript/index.d.ts (the SDK does not
-- * require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
++/* Copied verbatim from openvibe-contracts v0.33.0 generated/typescript/index.d.ts (the SDK does not
++ * require the package at runtime; it is a devDependency for the tests). test/types.test.js checks
++ * these against it. */
/* eslint-disable */
/** [email protected] (owner: network) */
@@@ -181,7 +182,7 @@ export interface EventEnvelope {
/** [email protected] (owner: network) */
/**
-- * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).
++ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.
*/
export interface ModuleNamespace {
namespace: string;
@@@ -309,3 +310,452 @@ export interface AppManifest {
sdk?: string;
};
}
++
++/** [email protected] (owner: tools) */
++/**
++ * One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. RULES this schema enforces: api false has no run, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets) and files.min <= files.max.
++ */
++export interface ToolDescriptor {
++ /**
++ * The tool's catalogue id, which is its subdomain label (png, jsminify, dns, pdf2jpg). It never changes.
++ */
++ id: string;
++ /**
++ * The catalogue family: net, dev, img, audio, docs, text, media, places, pastes…
++ */
++ family: string;
++ name: string;
++ /**
++ * One plain sentence: what the tool does.
++ */
++ summary: string;
++ /**
++ * stable: works, and its input and output only grow. beta: works; input or output may still change in a minor. preview: works with known gaps (limits or output not final). unavailable: listed, but runs are refused with 503 tools.tool.unavailable and the page says so (statusReason).
++ */
++ status: "stable" | "beta" | "preview" | "unavailable";
++ /**
++ * Why the status is what it is, for people (e.g. 'needs qpdf on the host'). Required when unavailable.
++ */
++ statusReason?: string;
++ /**
++ * Where the engine runs for the tool's page: client (the browser), sync (a server request answered inline), job (an asynchronous job).
++ */
++ execution: "client" | "sync" | "job";
++ /**
++ * POST /api/v1/tools/{id}/run exposes the tool. false for a client tool without a server engine and for page-only tools (yt).
++ */
++ api: boolean;
++ /**
++ * How to call it; null when api is false.
++ */
++ run: {
++ method: "POST";
++ /**
++ * /api/v1/tools/{id}/run on https://openvibe.tools; the body is tools.run-request@1, the answer tools.run@1.
++ */
++ path: string;
++ /**
++ * For a job tool: the job the run creates. null for tools answered inline.
++ */
++ job: {
++ /**
++ * Job type, e.g. img.process.
++ */
++ type: string;
++ /**
++ * The job input's tool field: convert, compress, trim, mergepdf…
++ */
++ operation: string;
++ /**
++ * Input fields the tool itself fixes, applied over the caller's input (png: { format: png }).
++ */
++ preset?: {};
++ } | null;
++ /**
++ * Older routes that run the same engine (GET /api/net/dns/:target, POST /api/process…). They keep working until their Sunset date and answer with Deprecation, Sunset and Link: rel=successor-version pointing at path.
++ *
++ * @maxItems 32
++ */
++ legacy?: string[];
++ } | null;
++ /**
++ * JSON Schema (2020-12) of the run request's input: embedded (an object schema), or { $ref } to $defs/input of GET /api/v1/tools/:id/schema. null only for a tool without an API.
++ */
++ input:
++ | {
++ $ref: string;
++ }
++ | {
++ type: "object";
++ }
++ | null;
++ /**
++ * The files a run takes (multipart parts, or tools.run-request@1 files references); null when it takes none.
++ */
++ files: {
++ min: number;
++ max: number;
++ /**
++ * Accepted media types, checked against the bytes, not the client's Content-Type (image/png, image/*, application/pdf…).
++ *
++ * @minItems 1
++ */
++ accept: [string, ...string[]];
++ /**
++ * Largest accepted file.
++ */
++ maxBytes: number;
++ } | null;
++ output: {
++ /**
++ * json: result.data; text: result.text; file or files: result.files (job result files).
++ */
++ kind: "json" | "file" | "files" | "text";
++ /**
++ * JSON Schema of result.data (kind json): embedded, or { $ref } to $defs/output of GET /api/v1/tools/:id/schema.
++ */
++ schema?:
++ | {
++ $ref: string;
++ }
++ | {
++ type: "object";
++ };
++ /**
++ * Media types the result files can have (kind file or files).
++ *
++ * @minItems 1
++ */
++ mime?: [string, ...string[]];
++ };
++ limits: {
++ /**
++ * A run (or its job) longer than this fails with tools.job.timeout or 504.
++ */
++ timeoutMs: number;
++ /**
++ * Longest audio or video input.
++ */
++ maxDurationSec?: number;
++ /**
++ * Largest image input (width × height).
++ */
++ maxPixels?: number;
++ /**
++ * Most PDF pages.
++ */
++ maxPages?: number;
++ /**
++ * Largest input as JSON (text to transform, lists of hosts…).
++ */
++ maxInputBytes?: number;
++ /**
++ * Egress tools: most runs per minute against one target host or address, across all callers.
++ */
++ perTargetPerMinute?: number;
++ };
++ /**
++ * Caller tiers: anonymous < session < user < app/service.
++ */
++ auth: {
++ /**
++ * true: a caller with no token and no browser session may run it (keyed by IP, IPv6 by /64). false: a browser session (the ov_tools_jobs cookie), a signed-in person or a token is needed.
++ */
++ anonymous: boolean;
++ /**
++ * What an app or service token must hold. tools.tool.run is public. tools.net.probe (partner) covers network probes: only principals holding it run these through the API; people use their pages, which have a per-target throttle.
++ */
++ capability: "tools.tool.run" | "tools.net.probe";
++ };
++ /**
++ * The quota bucket a run counts against, with allowances per caller tier. Names describe the work, never a tier or a price (tools-run, tools-job, tools-probe, tools-fetch…), so a paid tier later adds allowances, not names.
++ */
++ quotaClass: string;
++ /**
++ * Relative weight of one run within its quota class (1 = a cheap lookup or transform). Quotas count weight, not requests.
++ */
++ cost: number;
++ /**
++ * The server fetches a host or URL the caller chose (DNS, WHOIS, headers, Open Graph, ports…), always through the SSRF guard.
++ */
++ egress: boolean;
++ /**
++ * The tool's public hostnames, canonical first (png.openvibe.tools, a custom domain…); empty for a tool that lives on another service.
++ *
++ * @maxItems 50
++ */
++ hosts: string[];
++ /**
++ * Where a person reads about the tool.
++ */
++ docs: string;
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * The answer of GET /api/v1/tools on openvibe.tools (capability tools.tool.read; ADR-027): every tool's descriptor (tools.tool@1), with input and output schemas in the { $ref } form (GET /api/v1/tools/:id has them embedded). Filters: ?family=, ?execution=client|sync|job, ?api=true|false, ?status=, ?q= (name, summary and keywords). Planned placeholders are not tools and are not listed. Cacheable (ETag); updated_at changes when any descriptor does.
++ */
++export interface ToolList {
++ tools: ToolDescriptor[];
++ /**
++ * The number of tools in this answer.
++ */
++ count: number;
++ updated_at: string;
++ /**
++ * The families of the tools in this answer.
++ */
++ families?: {
++ id: string;
++ name: string;
++ count: number;
++ }[];
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * Body of POST /api/v1/tools/{id}/run on openvibe.tools (capability tools.tool.run, or tools.net.probe for network probes; ADR-027). JSON, or multipart/form-data like POST /api/v1/jobs: the text parts input (JSON text), wait_ms and idempotency_key, and uploaded files as file or files parts. The tool gets the uploaded files first, then the files references, in order; the count must fit the descriptor's files.min and files.max. input must match the tool's input schema (tools.tool@1 input). A job tool's job input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). The Idempotency-Key header wins over idempotency_key; ?wait_ms= on the query does the same as the field. Answers tools.run@1: 200 finished (succeeded, or failed with the tool's own error), 202 + Location while a job is queued or running, 200 + Idempotent-Replayed: true for a replay. Refusals are problem+json: 400 tools.run.invalid (not JSON, a bad field, wrong file count), 401 token.*, 403 capability.denied, 404 tools.tool.not_found, 404 tools.tool.not_runnable (api false: a page-only tool), 404 tools.run.file_not_found (a reference the caller cannot read), 409 tools.job.idempotency_conflict, 413 tools.file.too_large, 415 tools.file.unsupported_type (checked against the bytes), 422 tools.input.invalid (errors[] with JSON pointers), 429 quota.exceeded or tools.job.too_many_active (Retry-After), 503 tools.tool.unavailable.
++ */
++export interface ToolsRunRequest {
++ /**
++ * The tool's parameters, matching its input schema. Default {}.
++ */
++ input?: {};
++ /**
++ * Files that are already somewhere, instead of or besides uploaded parts.
++ *
++ * @maxItems 50
++ */
++ files?: (
++ | {
++ media_id: string;
++ }
++ | {
++ job_id: string;
++ index: number;
++ }
++ )[];
++ /**
++ * Job tools: wait up to this long for the job to finish before answering (200 finished, else 202 with the job). Default 0. Inline tools always answer finished.
++ */
++ wait_ms?: number;
++ /**
++ * Job tools: the same (caller, key) and request returns the same job; a different request under it is 409 tools.job.idempotency_conflict. Inline tools ignore it.
++ */
++ idempotency_key?: string;
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * The answer of POST /api/v1/tools/{id}/run (tools.run-request@1; ADR-027). Finished: { state: succeeded, tool, result, took_ms } or { state: failed|cancelled, tool, error, took_ms }, with job set when the tool ran as a job. Not finished within wait_ms: { state: queued|running, tool, job, location }, sent with 202 and a Location header; follow the job there (GET, its events, its files; tools.job@1). result carries data (kind json, matching the descriptor's output schema), text (kind text) or files (kind file or files: the job's result files, downloaded from their url). took_ms runs from the request to the answer for an inline run, and from created_at to finished_at for a job. error is problem+json from the tool: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…). A request refused before the tool ran is a problem+json answer, not a run.
++ */
++export type ToolsRun =
++ | {
++ state: "succeeded";
++ tool: string;
++ result: {
++ /**
++ * Output data; matches the descriptor's output.schema when output.kind is json.
++ */
++ data?: {};
++ /**
++ * Output text (output.kind text).
++ */
++ text?: string;
++ /**
++ * Result files (output.kind file or files), exactly as the job lists them.
++ *
++ * @maxItems 100
++ */
++ files?: {
++ name: string;
++ mime: string;
++ size: number;
++ sha256: string;
++ /**
++ * media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires.
++ */
++ storage: "local" | "media";
++ /**
++ * The Media object, when storage is media.
++ */
++ media: {
++ media_id: string;
++ role?: string;
++ namespace?: string;
++ size_bytes?: number;
++ content_hash?: string;
++ mime_type?: string;
++ /**
++ * The object's Media lifecycle status when it was stored (ready).
++ */
++ status?: string;
++ } | null;
++ /**
++ * Present when storing the file in Media failed; the file stayed local.
++ */
++ media_error?: string;
++ /**
++ * Where the owner downloads it.
++ */
++ url: string;
++ }[];
++ };
++ took_ms: number;
++ job?: ToolsJob | null;
++ }
++ | {
++ state: "failed" | "cancelled";
++ tool: string;
++ error: Problem;
++ took_ms: number;
++ job?: ToolsJob | null;
++ }
++ | {
++ state: "queued" | "running";
++ tool: string;
++ job: ToolsJob;
++ /**
++ * The job (also the Location header): /api/v1/jobs/:id on the gateway.
++ */
++ location: string;
++ };
++
++/** [email protected] (owner: tools) */
++/**
++ * A Tools job as the jobs API answers it (OpenVibe.Tools apps/_shared/jobs/http.js, system.view; ADR-027): POST /api/v1/jobs (202 + Location, or 200 + Idempotent-Replayed: true), GET and DELETE /api/v1/jobs/:id, POST /api/v1/jobs/:id/retry, PUT and DELETE /api/v1/jobs/:id/references/:ref, the data of every SSE event on GET /api/v1/jobs/:id/events, and the job member of tools.run@1. A job is visible only to its owner (a person, a service/app principal, or one browser session); anyone else gets the same 404 tools.job.not_found as for a job that does not exist. result is null until the job succeeded; each result file is downloaded from its url (GET /api/v1/jobs/:id/files/:n, ?inline=1 for previews) and is stored as a private OpenVibe.Media object (storage media) or on the satellite until the job expires (storage local). expires_at is null while a reference keeps the result. error is problem+json: tools.job.failed, tools.job.timeout, tools.job.cancelled, or a code the tool chose (tools.…), with server paths removed.
++ */
++export interface ToolsJob {
++ id: string;
++ object: "tools.job";
++ /**
++ * The Tools satellite that runs the job (img, audio, docs…).
++ */
++ service: string;
++ /**
++ * The tool (tools.tool@1 id) whose run created the job, when it came through POST /api/v1/tools/{id}/run. Absent for a job submitted to POST /api/v1/jobs directly.
++ */
++ tool?: string;
++ /**
++ * Job type, e.g. img.process.
++ */
++ type: string;
++ type_version: number;
++ /**
++ * Terminal: succeeded, failed, cancelled.
++ */
++ state: "queued" | "running" | "succeeded" | "failed" | "cancelled";
++ progress: {
++ percent: number | null;
++ message: string | null;
++ };
++ attempts: number;
++ max_attempts: number;
++ /**
++ * DELETE asked a running job to stop; it ends cancelled.
++ */
++ cancel_requested: boolean;
++ created_at: string;
++ started_at: string | null;
++ finished_at: string | null;
++ /**
++ * When the job, its events and its result files are pruned; null while a reference keeps the result.
++ */
++ expires_at: string | null;
++ result: {
++ /**
++ * @maxItems 100
++ */
++ files: {
++ name: string;
++ mime: string;
++ size: number;
++ sha256: string;
++ /**
++ * media: a private OpenVibe.Media object (TOOLS_JOB_RESULTS=media); local: kept on the satellite until the job expires.
++ */
++ storage: "local" | "media";
++ /**
++ * The Media object, when storage is media.
++ */
++ media: {
++ media_id: string;
++ role?: string;
++ namespace?: string;
++ size_bytes?: number;
++ content_hash?: string;
++ mime_type?: string;
++ /**
++ * The object's Media lifecycle status when it was stored (ready).
++ */
++ status?: string;
++ } | null;
++ /**
++ * Present when storing the file in Media failed; the file stayed local.
++ */
++ media_error?: string;
++ /**
++ * Where the owner downloads it.
++ */
++ url: string;
++ }[];
++ /**
++ * The tool's output data (dimensions, durations, page counts…); {} when it has none.
++ */
++ data: {};
++ } | null;
++ error: Problem | null;
++ /**
++ * Retrying may succeed (a timeout, a restart); POST links.retry makes a new job.
++ */
++ retryable: boolean;
++ /**
++ * The failed job this one retries.
++ */
++ retry_of: string | null;
++ /**
++ * The job that retried this failed one; asking again returns it.
++ */
++ retried_by: string | null;
++ /**
++ * What keeps the result (PUT /api/v1/jobs/:id/references/:ref), e.g. community:paste:p_123.
++ *
++ * @maxItems 50
++ */
++ references: {
++ ref: string;
++ created_at: string;
++ }[];
++ links: {
++ self: string;
++ events: string;
++ /**
++ * DELETE it while the job is queued or running.
++ */
++ cancel: string | null;
++ /**
++ * POST it when the job failed.
++ */
++ retry: string | null;
++ retried_by: string | null;
++ };
++}
++
++/** [email protected] (owner: tools) */
++/**
++ * Body of POST /api/v1/jobs on the Tools satellites that run jobs (img, audio, docs; capability tools.job.create; OpenVibe.Tools apps/_shared/jobs/http.js). JSON, or multipart/form-data with the same fields as text parts (input as JSON text) and the input files as file or files parts. The Idempotency-Key header wins over idempotency_key: the same (owner, key) and request returns the same job (200 + Idempotent-Replayed: true), a different request under it is 409 tools.job.idempotency_conflict. Types: img.process (input { tool: convert|compress|resize|crop, … }, one image), audio.process ({ tool, … } as /api/process takes them, one audio or video file), docs.process ({ tool, … }, one PDF, or several files for merge and img2pdf). A format host fills in its format (webp.openvibe.tools converts to WebP). Answers tools.job@1 (202 + Location). Refusals are problem+json: 400 tools.job.invalid (input not a JSON object or over 16 KB, wrong file count, the type's own validation) or tools.job.unknown_type, 401 token.* or tools.job.no_owner, 403 capability.denied, 413 tools.job.too_large, 429 tools.job.too_many_active (unfinished jobs per owner) or a rate limit, 503 tools.job.unavailable.
++ */
++export interface ToolsJobRequest {
++ /**
++ * Job type: img.process, audio.process, docs.process.
++ */
++ type: string;
++ /**
++ * The job type's input; { tool: <operation>, …options }. Default {}; at most 16 KB as JSON.
++ */
++ input?: {};
++ /**
++ * 8-200 printable ASCII characters. The Idempotency-Key header is used when both are present.
++ */
++ idempotency_key?: string;
++}
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 4378cc7..1464a9a 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -8,4 +8,5 @@ export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
export * as jobs from './jobs';
++export * as tools from './tools';
export * as projects from './projects';
ddiff --git a/types/jobs.d.ts b/types/jobs.d.ts
iindex 9a0ded0..641d1fc 100644
---- a/types/jobs.d.ts
++++ b/types/jobs.d.ts
@@@ -1,10 +1,13 @@
import type { OpenVibeClient } from './core';
export type JobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
++/** A Tools job (tools.job@1; ToolsJob in ./contracts is the exact contract type). */
export interface Job {
id: string;
object: 'tools.job';
service: string;
++ /** The tool whose run created the job (POST /api/v1/tools/:id/run). */
++ tool?: string;
type: string;
type_version: number;
state: JobState;
@@@ -17,9 +20,16 @@ export interface Job {
finished_at?: string | null;
expires_at?: string | null;
result: { files: Array<{ name: string; mime: string; size: number; url: string; [k: string]: unknown }>; data: Record<string, unknown> } | null;
-- error: { code: string; detail?: string; [k: string]: unknown } | null;
++ /** problem+json (errors.problem@1): type, title, status, code, detail. */
++ error: { code: string; status?: number; title?: string; type?: string; detail?: string; [k: string]: unknown } | null;
retryable: boolean;
-- links: { self: string; events: string; cancel: string | null };
++ /** The failed job this one retries. */
++ retry_of?: string | null;
++ /** The job that retried this failed one. */
++ retried_by?: string | null;
++ /** What keeps the result (expires_at is null while any remains). */
++ references?: Array<{ ref: string; created_at: string }>;
++ links: { self: string; events: string; cancel: string | null; retry?: string | null; retried_by?: string | null };
[field: string]: unknown;
}
export interface JobEvent { id: number | null; event: string; job: Job | null; }
@@@ -41,8 +51,21 @@ export interface JobsClient {
wait(id: string, opts?: JobEventsOptions & { onEvent?: (e: JobEvent) => void | Promise<void> }): Promise<Job | null>;
/** The raw Response of a result file (body unread). */
file(id: string, n?: number, opts?: { signal?: AbortSignal; inline?: boolean }): Promise<Response>;
++ /**
++ * POST /api/v1/jobs/:id/retry: a failed job as a new job (retry_of). Asking again returns the same
++ * retry (`replayed: true`). 409 tools.job.not_failed for other states.
++ */
++ retry(id: string, opts?: { signal?: AbortSignal }): Promise<{ job: Job; replayed: boolean }>;
++ /** PUT /api/v1/jobs/:id/references/:ref (<service>:<kind>:<id>): keep a succeeded job's result. Idempotent. */
++ reference(id: string, ref: string, opts?: { signal?: AbortSignal }): Promise<Job>;
++ /** DELETE /api/v1/jobs/:id/references/:ref: after the last reference the result expires again. Idempotent. */
++ unreference(id: string, ref: string, opts?: { signal?: AbortSignal }): Promise<Job>;
}
--/** baseUrl: the satellite that runs the job type (e.g. https://img.openvibe.tools); default the registry's `tools` origin. */
++/**
++ * Default origin: the registry's `tools` origin (https://openvibe.tools), whose gateway fronts every
++ * satellite's jobs under /api/v1/jobs (ADR-027; needs the Tools run-API release, S6). Until then,
++ * or to reach one satellite directly, pass baseUrl (e.g. https://img.openvibe.tools).
++ */
export declare function createJobsClient(client: OpenVibeClient, opts?: { baseUrl?: string; service?: string; audience?: string }): JobsClient;
export declare function isTerminal(job: Pick<Job, 'state'> | null | undefined): boolean;
export declare const TERMINAL_STATES: JobState[];
ddiff --git a/types/testing.d.ts b/types/testing.d.ts
iindex 89574bd..0ec8b98 100644
---- a/types/testing.d.ts
++++ b/types/testing.d.ts
@@@ -1,4 +1,5 @@
import type { FetchLike, EventEnvelope, Capability, ServiceManifest, ModuleNamespace } from './core';
++import type { ToolDescriptor } from './contracts';
type Origin = 'network' | 'events' | 'media' | 'community' | 'tools';
export interface MockGrant { capability: string; audience: string; namespaces?: string[]; }
@@@ -33,6 +34,17 @@ export interface MockJobContext {
progress(percent: number, message?: string | null): Promise<void>;
readonly cancelled: boolean;
}
++export interface MockToolContext {
++ input: Record<string, unknown>;
++ files: Array<{ name: string; type: string; bytes: Uint8Array }>;
++ /** Who runs it: owner null for an anonymous caller; tier anonymous | user | service | app. */
++ caller: { owner: string | null; tier: string };
++ signal: AbortSignal;
++}
++/** An inline tool's handler returns { data } or { text } (a string is text); a job tool's handler is a job handler. */
++export type MockToolHandler =
++ | ((ctx: MockToolContext) => Promise<{ data?: Record<string, unknown>; text?: string } | string>)
++ | ((ctx: MockJobContext) => Promise<{ data?: Record<string, unknown>; files?: Array<{ name?: string; mime?: string; bytes?: Uint8Array | string }> } | void>);
export interface MockPlatformOptions {
origins?: Partial<Record<Origin, string>>;
issuer?: string;
@@@ -64,8 +76,20 @@ export interface MockPlatformOptions {
mediaSignedUrlTtlS?: number;
/** Tools satellites that also answer /api/v1/jobs. Default: DEFAULT_TOOLS_SATELLITES. */
toolsSatellites?: string[];
-- /** Serve Tools jobs at origins.tools and the satellites (each keeps its own jobs). */
++ /** Serve Tools jobs at origins.tools (the gateway facade: it also sees the satellites' jobs) and the satellites (each keeps its own jobs). */
jobs?: boolean | { stepMs?: number; handlers?: Record<string, (ctx: MockJobContext) => Promise<{ data?: Record<string, unknown>; files?: Array<{ name?: string; mime?: string; bytes?: Uint8Array | string; data?: Uint8Array | string }> } | void>> };
++ /**
++ * The Tools platform API on origins.tools (implies the jobs facade there): GET /api/v1/tools[/:id[/schema]]
++ * and POST /api/v1/tools/:id/run. Defaults: dns, jsonminify, png, port, yt, protectpdf.
++ */
++ tools?: boolean | {
++ stepMs?: number;
++ /** Added to the defaults; the same id replaces one. */
++ descriptors?: ToolDescriptor[];
++ handlers?: Record<string, MockToolHandler>;
++ /** What { media_id } references read. */
++ mediaObjects?: Record<string, { name?: string; type?: string; data?: Uint8Array | string }>;
++ };
/** fetch used by deliverEvents() (default: the global fetch). */
deliveryFetch?: FetchLike;
}
@@@ -73,8 +97,12 @@ export interface DeliveryAttempt { subscription_id: string; event_id: string; se
export interface MockPlatform {
fetch: FetchLike;
origins: Record<Origin, string>;
-- /** Every origin that answers /api/v1/jobs: origins.tools and the satellites. */
++ /** Every origin that answers /api/v1/jobs: origins.tools (the gateway facade) and the satellites. */
toolsOrigins: string[];
++ /** Add or replace a tool (with { tools }); returns the stored descriptor. */
++ addTool(descriptor: ToolDescriptor, handler?: MockToolHandler): ToolDescriptor;
++ /** A Media object that { media_id } run references read -> its med_ id. */
++ addMediaObject(obj?: { id?: string; name?: string; type?: string; data?: Uint8Array | string }): string;
issuer: string;
keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
@@@ -98,6 +126,7 @@ export interface MockPlatform {
files: Map<string, any>;
mediaTenants: Map<string, { id: string; project_id: string; env: 'sandbox' | 'production'; quota_bytes: number }>;
users: Map<string, MockUser>; checkpoints: Map<string, { cursor: number; updated_at: string }>; apps: Map<string, any>; projects: Map<string, any>; jobs: Map<string, any>;
++ tools: Map<string, ToolDescriptor>;
};
/**
* Store an event as if published. A publisher `app:<id>` of a registered app stores it as that
ddiff --git a/types/tools.d.ts b/types/tools.d.ts
nnew file mode 100644
iindex 0000000..139eb63
---- /dev/null
++++ b/types/tools.d.ts
@@@ -0,0 +1,121 @@
++import type { OpenVibeClient, OpenVibeError } from './core';
++import type { ToolDescriptor, ToolList, ToolsRun, ToolsJob } from './contracts';
++import type { JobsClient, JobFile, JobEventsOptions, JobEvent } from './jobs';
++
++export type { ToolDescriptor, ToolList, ToolsRunRequest, ToolsRun, ToolsJob } from './contracts';
++
++/** A file that is already somewhere (tools.run-request@1 files). */
++export type ToolFileRef =
++ /** A Media object the caller may read. */
++ | { media_id: string }
++ /** Result file `index` of a job the same caller owns: one tool's output feeds the next. */
++ | { job_id: string; index: number };
++/** An upload (multipart `file` part) or a reference. */
++export type ToolRunFile = JobFile | ToolFileRef;
++
++/** GET /api/v1/tools/:id/schema: what the list's `$ref`s point at. */
++export interface ToolSchema {
++ $schema?: string;
++ $id?: string;
++ $defs: { input: Record<string, unknown> | null; output: Record<string, unknown> | null };
++}
++
++export interface ToolListQuery {
++ family?: string;
++ /** Name, summary and keywords. */
++ q?: string;
++ execution?: ToolDescriptor['execution'];
++ /** Sent as api=true|false. */
++ api?: boolean;
++ status?: ToolDescriptor['status'];
++ signal?: AbortSignal;
++}
++
++export interface ToolRunOptions {
++ /** Uploads first, then references, in order; the count must fit the descriptor's files.min..max. */
++ files?: ToolRunFile | ToolRunFile[];
++ /** Job tools: wait up to this long (0..60000 ms) before answering; 200 finished, else 202. */
++ waitMs?: number;
++ /** 8-200 printable ASCII characters; generated when omitted (returned as `idempotencyKey`). */
++ idempotencyKey?: string;
++ signal?: AbortSignal;
++ /** Per attempt. Default: the client's, raised to waitMs or the tool's known limits.timeoutMs, plus 10 s. */
++ timeoutMs?: number;
++}
++
++export type ToolRunWaitOptions = JobEventsOptions & { onEvent?: (e: JobEvent) => void | Promise<void> };
++
++/** tools.run@1 `result`: data (output kind json), text (kind text) or files (kind file or files: the job's result files). */
++export type ToolRunResult = Extract<ToolsRun, { state: 'succeeded' }>['result'];
++
++interface ToolRunBase {
++ tool: string;
++ /** The key the run was sent with (yours, or generated). */
++ idempotencyKey: string;
++ /** The answer was a replay of an earlier run under the same key (Idempotent-Replayed). */
++ replayed: boolean;
++ /**
++ * Not enumerable. A finished run resolves to itself; a job follows its events and resolves to the
++ * succeeded run, or throws ToolRunError (failed, cancelled). An aborted signal throws sdk.aborted.
++ */
++ wait(opts?: ToolRunWaitOptions): Promise<ToolRunSucceeded>;
++}
++export interface ToolRunSucceeded extends ToolRunBase {
++ state: 'succeeded';
++ result: ToolRunResult;
++ took_ms: number;
++ /** Set when the tool ran as a job. */
++ job?: ToolsJob;
++ /** The job on the gateway (/api/v1/jobs/:id), when the tool ran as a job. */
++ location?: string;
++}
++export interface ToolRunPending extends ToolRunBase {
++ state: 'queued' | 'running';
++ job: ToolsJob;
++ /** The job, as the server sent it (a path on the gateway, /api/v1/jobs/:id). */
++ location: string;
++}
++export type ToolRunOutcome = ToolRunSucceeded | ToolRunPending;
++
++/**
++ * A run that finished `failed` or `cancelled`, with the tool's own problem+json mapped like any
++ * OpenVibeError. `status` is the problem's status (422, 504…), not the HTTP status (200).
++ * `isOpenVibeError(err)` is true.
++ */
++export declare class ToolRunError extends OpenVibeError {
++ state: 'failed' | 'cancelled';
++ tool: string;
++ /** The job, when the tool ran as one (`jobs.retry(err.job.id)` when `retryable`). */
++ job: ToolsJob | null;
++ /** The tools.run@1 answer, when the failure came in one (null when it came from the job). */
++ run: ToolsRun | null;
++ constructor(init: { state: 'failed' | 'cancelled'; tool: string; error?: unknown; job?: ToolsJob | null; run?: ToolsRun | null; requestId?: string; traceId?: string });
++}
++export declare function isToolRunError(err: unknown): err is ToolRunError;
++
++export interface ToolsClient {
++ /** GET /api/v1/tools: tools.tool-list@1, schemas as { $ref }. */
++ list(query?: ToolListQuery): Promise<ToolList>;
++ /** GET /api/v1/tools/:id: the descriptor with its schemas embedded, or null. */
++ get(id: string, opts?: { signal?: AbortSignal }): Promise<ToolDescriptor | null>;
++ /** GET /api/v1/tools/:id/schema, or null. */
++ schema(id: string, opts?: { signal?: AbortSignal }): Promise<ToolSchema | null>;
++ /**
++ * POST /api/v1/tools/:id/run. Resolves finished (succeeded) or pending (a job still queued or
++ * running); throws ToolRunError when the run failed or was cancelled, OpenVibeError when it was
++ * refused (404 tools.tool.not_found | not_runnable, 422 tools.input.invalid, 403, 429, 503…).
++ */
++ run(id: string, input?: Record<string, unknown>, opts?: ToolRunOptions): Promise<ToolRunOutcome>;
++ /** openvibe-sdk/jobs on the same origin and credentials: the gateway's /api/v1/jobs facade. */
++ jobs: JobsClient;
++}
++
++export interface ToolsClientOptions {
++ /** Default: the `tools` origin from the platform descriptor (https://openvibe.tools). */
++ baseUrl?: string;
++ service?: string;
++ audience?: string;
++ /** Registry reads send no token (default true); false sends the client's token. */
++ anonymousReads?: boolean;
++}
++export declare function createToolsClient(client: OpenVibeClient, opts?: ToolsClientOptions): ToolsClient;