openvibers/openvibe.tools Commits

Document worker placement API and execution receipts (#292)

OP OpenVibers committed 2 days ago · 0e32f7438f · 4 files, +20 −3

Changes

ddiff --git a/apps/gateway/server/openapi.js b/apps/gateway/server/openapi.js
iindex 8a5e25b..2d241be 100644
---- a/apps/gateway/server/openapi.js
++++ b/apps/gateway/server/openapi.js
@@@ -33,7 +33,7 @@ function runPath(t) {
`Runs as a ${t.execution === 'job' ? `job (${t.run && t.run.job ? t.run.job.type : 'job'}): 202 with the job unless it finishes within wait_ms` : 'direct call'}; timeout ${t.limits.timeoutMs} ms.`,
t.egress ? 'Reaches out to the host you name; throttled per target.' : null].filter(Boolean).join(' '),
security,
-- parameters: [{ name: 'Idempotency-Key', in: 'header', required: false, schema: { type: 'string', maxLength: 200 } }],
++ parameters: [{ name: 'X-Tools-Prefer', in: 'header', required: false, description: 'host keeps work on the host; home is advisory and respects owner order. Fetch-based jobs always stay on the host.', schema: { type: 'string', enum: ['auto', 'home', 'host'], default: 'auto' } }, { name: 'Idempotency-Key', in: 'header', required: false, schema: { type: 'string', maxLength: 200 } }],
requestBody: { required: true, content: body },
responses: {
200: { description: 'Finished (tools.run@1: state succeeded)', content: { 'application/json': { schema: ref('run') } } },
@@@ -87,6 +87,7 @@ function openapi(snapshot, site) {
responses: { 200: { description: 'The image', content: { 'image/svg+xml': {}, 'image/png': {} } }, 422: { description: 'Why the text cannot be drawn (text/plain)' }, 429: { description: 'Too many requests (Retry-After)' } },
} };
for (const t of tools) paths[t.run.path] = runPath(t);
++ paths['/api/v1/jobs/{id}'].get.description = 'Read the owned job. Completed jobs include result.data.execution; failed/cancelled jobs include error.execution (ToolsExecutionReceipt).';
return {
openapi: '3.1.0',
info: {
@@@ -96,7 +97,7 @@ function openapi(snapshot, site) {
},
servers: [{ url: site }],
tags: [{ name: 'registry' }, { name: 'jobs' }, ...[...new Set([...tools.map((t) => t.family), 'codes'])].sort().map((f) => ({ name: f }))],
-- components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or app token for audience openvibe.tools (tools.tool.run; tools.net.probe for probes)' } } },
++ components: { schemas: { ToolsExecutionReceipt: { type: 'object', description: 'Durable jobs: result.data.execution on success, error.execution on failure or cancellation. Times include all attempts, excluding final result storage.', required: ['worker', 'location', 'took_ms', 'attempts'], properties: { worker: { type: 'string' }, location: { enum: ['home', 'host'] }, took_ms: { type: 'integer', minimum: 0 }, attempts: { type: 'array', items: { type: 'object', required: ['worker', 'location', 'took_ms', 'state'], properties: { worker: { type: 'string' }, location: { enum: ['home', 'host'] }, took_ms: { type: 'integer', minimum: 0 }, state: { enum: ['succeeded', 'failed', 'cancelled', 'disconnected'] } } } } } } }, securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or app token for audience openvibe.tools (tools.tool.run; tools.net.probe for probes)' } } },
paths,
};
}
ddiff --git a/apps/gateway/server/pages/site.js b/apps/gateway/server/pages/site.js
iindex 1eabf59..726b652 100644
---- a/apps/gateway/server/pages/site.js
++++ b/apps/gateway/server/pages/site.js
@@@ -655,6 +655,15 @@ const out = await tools.run('jsonminify', { text: '{ "a": 1 }' });
const job = await tools.run('png', { format: 'png' }, { files: [fileBlob] });
const done = await job.wait(); // any tool, sync or job`)}
<p class="sec-p">The SDK is <a href="https://github.com/OpenVibers/OpenVibe.SDK">openvibe-sdk</a> (this site pins v0.67.0). It retries on 429 after <code>Retry-After</code> and generates idempotency keys for you.</p></section>
++<section id="worker-routing"><h2>Where jobs run</h2>
++<p>Send <code>X-Tools-Prefer: auto</code>, <code>home</code> or <code>host</code> on a run request. <code>host</code> keeps work on the host. <code>home</code> is advisory and respects the owner's routing order. Worker routing is off until the owner enables it. URL-fetching jobs always stay on the host.</p>
++<p>File jobs can use an eligible worker, ranked by the owner's order, free slots and recent latency. A disconnected job gets one retry elsewhere; without a worker, the host runs it.</p>
++<p>Read <code>result.data.execution</code> on a completed job, or <code>error.execution</code> on failure/cancellation: worker id, location, total execution milliseconds and each attempt. Poll the same job with <code>tools.jobs.get(id)</code>.</p>
++${code(`const run = await tools.run('png', {}, { files: [fileBlob], prefer: 'host' });
++const done = await run.wait();
++const job = await tools.jobs.get(done.job.id);
++job.result.data.execution;`)}
++<p>The <code>prefer</code> SDK option is staged for the next SDK release. Until that release, send the HTTP header directly. The <a href="/api/v1/openapi.json">OpenAPI document</a> includes the header and receipt schema.</p></section>
<section><div class="sec-h"><h2>Access and limits</h2></div>
<ul><li><b>Anonymous</b>: most tools, on the lowest quota tier, per address. Tools that handle files people should not share anonymously (audio, PDF) need a browser session or a token.</li>
<li><b>Apps and services</b>: a token from <a href="https://openvibe.network">OpenVibe.Network</a> for audience <code>openvibe.tools</code> with <code>tools.tool.run</code> (developer sandbox apps get it by default) raises the tier.</li>
ddiff --git a/apps/gateway/test/openapi.test.js b/apps/gateway/test/openapi.test.js
iindex e7de8d8..939c27b 100644
---- a/apps/gateway/test/openapi.test.js
++++ b/apps/gateway/test/openapi.test.js
@@@ -12,6 +12,7 @@ const withApi = snap.tools.filter((t) => t.api && t.run && t.status !== 'unavail
for (const t of withApi) {
const op = doc.paths[t.run.path] && doc.paths[t.run.path].post;
assert.ok(op, `${t.id} has ${t.run.path}`);
++ assert.deepStrictEqual(op.parameters.find(p => p.name === 'X-Tools-Prefer').schema.enum, ['auto', 'home', 'host']);
assert.ok(op.responses['200'] && op.responses['202'] && op.responses['429'], `${t.id} documents 200/202/429`);
}
for (const t of snap.tools.filter((x) => !x.api)) assert.ok(!doc.paths[`/api/v1/tools/${t.id}/run`], `${t.id} has no API, so no run path`);
@@@ -23,4 +24,9 @@ assert.deepStrictEqual(json.schema.properties.input.required, ['text'], 'the too
// SEO-3: /openapi.json, where agents look first, redirects to this document.
const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'index.js'), 'utf8');
assert.match(src, /app\.get\('\/openapi\.json', \(req, res\) => res\.redirect\(308, '\/api\/v1\/openapi\.json'\)\);/);
++assert.ok(doc.components.schemas.ToolsExecutionReceipt.properties.attempts);
++assert.match(doc.paths['/api/v1/jobs/{id}'].get.description, /result.data.execution/);
++const pageSource = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'pages', 'site.js'), 'utf8');
++assert.match(pageSource, /id="worker-routing"/);
++assert.match(pageSource, /tools.jobs.get\(done.job.id\)/);
console.log(`openapi: ${withApi.length} run paths, all checks passed`);
ddiff --git a/package.json b/package.json
iindex 5a7abbc..48fd789 100644
---- a/package.json
++++ b/package.json
@@@ -19,7 +19,8 @@
"test:worker": "node apps/worker/test/worker.test.js",
"test:suite": "node --unhandled-rejections=strict test/run.js",
"test:worker-security": "node apps/gateway/test/security-ssrf.test.js",
-- "test:routing": "node apps/worker/test/routing.test.js && node apps/worker/test/worker.test.js && node apps/_shared/test/jobs.test.js && node apps/gateway/test/security-ssrf.test.js"
++ "test:routing": "node apps/worker/test/routing.test.js && node apps/worker/test/worker.test.js && node apps/_shared/test/jobs.test.js && node apps/gateway/test/security-ssrf.test.js",
++ "test:worker-docs": "node apps/gateway/test/openapi.test.js"
},
"license": "MIT",
"engines": {