openvibers/openvibe.tools Commits
Project usage rollups (roadmap WS-N task 4; Contracts v0.63.0 tools.usage.recorded, common.usage-recorded@1): a developer app's job (it carries a project) that ends is counted in the transaction that records its end (apps/_shared/jobs/usage.js, table tool_job_usage in the satellite's jobs.db), per project, environment, capability (tools.tool.run for a tool run, tools.job.create for POST /api/v1/jobs), job type or tool and UTC hour: jobs that ended (succeeded or failed; cancelled ones are not counted) and the failed ones by problem code, with the last ten failures' job id, status and the trace id of the request that submitted the job (new tool_jobs.trace_id, kept by submit({ traceId }) from jobs/http.js and tools/run.js). A minute after an hour closes, the pruner's timer writes each rollup once to the satellite's outbox as tools.usage.recorded (subject the project, visibility internal, priority low) in the transaction that marks it sent; a job ending in an hour already sent re-sends it as revision 2. Sandbox jobs count under env sandbox. No owner, session, address, input, file name or output leaves. Inert without EVENTS_URL, like the job events; GET /api/health shows jobs.usage. openvibe-tools-shared 1.0.0 -> 1.1.0 (additive). Pins: openvibe-contracts 0.49.0 -> 0.63.0 (audio, docs, gateway, img). test: apps/_shared/test/job-usage.test.js
Changes
@@@ -245,6 +245,17 @@ reload reattaches.creates no job and no event. The relay uses the `tools` client (`OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`)with `events.event.publish` on audience `openvibe.events`. Without `EVENTS_URL` (or with `EVENTS_PUBLISH=off`, orno client secret) nothing is written or sent. `GET /api/health` shows `jobs.events` (pending, rejected).++- **Project usage to OpenVibe.Events** on the same outbox (`apps/_shared/jobs/usage.js`, roadmap WS-N task 4): a++ developer app's job (it carries a project) that ends is counted in the transaction that records its end, per++ project, environment, capability (`tools.tool.run` for a tool run, `tools.job.create` for `POST /api/v1/jobs`),++ job type or tool and UTC hour: the jobs that ended (succeeded or failed; cancelled ones are not counted) and the++ failed ones by problem code, with the last ten failures' job id, status and the trace id of the request that++ submitted the job (`tool_jobs.trace_id`). A minute after an hour closes, the pruner's timer writes each rollup++ once as `tools.usage.recorded` (`common.usage-recorded@1`, openvibe-contracts 0.63.0; subject the project,++ visibility `internal`, priority `low`) in the transaction that marks it sent. Sandbox jobs count under++ `env: sandbox`. No owner, session, address, input, file name or output leaves. OpenVibe.Network adds the rollups++ up for the project's usage page on openvibe.codes. Off without `EVENTS_URL`, like the job events;++ `GET /api/health` shows `jobs.usage` (pending, invalid).Environment (all in `/etc/openvibe/tools.env`): `TOOLS_WORKERS`, `TOOLS_WORKERS_<APP>`, `TOOLS_WORKER_MEMORY_MB`,`TOOLS_WORKER_MEMORY_MB_<APP>`, `TOOLS_WORKER_QUEUE`, `TOOLS_WORKER_IDLE_MS`, `TOOLS_JOBS_CONCURRENCY`, `TOOLS_JOBS_CONCURRENCY_<APP>`,@@@ -4,6 +4,18 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openviits `package.json`, and refuses to boot when this checkout does not satisfy it (`version.js`). Abreaking change bumps the major and every app's range in the same commit.++## 1.1.0 — 2026-09-26++++Additive (roadmap WS-N task 4):++++- `jobs/usage.js`: a developer project's jobs that end are counted per project, environment,++ capability (`tools.job.create`, `tools.tool.run`), job type or tool and UTC hour, in the transaction++ that records the end, and each closed hour goes to the outbox once as `tools.usage.recorded`++ (openvibe-contracts 0.63.0). Inert without an outbox, like the job events.++- `jobs/`: `submit({ traceId })` keeps the submit request's trace id in `tool_jobs.trace_id`++ (added when missing); `jobs/http.js` and `tools/run.js` pass `req.ov.traceId`. `stats().usage`,++ `usage` and `flushUsage()` on the job system.++## 1.0.0 — 2026-09-26The first versioned release (roadmap WS-L task 3). It contains what every satellite already took from@@@ -174,7 +174,7 @@ function mountJobRoutes(app, o) {if (!ok) { for (const f of files) if (f.path) fs.unlink(f.path, () => {}); return; }}try {-- const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w), env: w.env || 'production', ipKey: w.caller ? w.caller.ipKey : null, project: w.kind === 'principal' && w.claims && w.claims.actor_type === 'app' ? w.claims.project_id : null });++ const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w), env: w.env || 'production', ipKey: w.caller ? w.caller.ipKey : null, project: w.kind === 'principal' && w.claims && w.claims.actor_type === 'app' ? w.claims.project_id : null, traceId: req.ov && req.ov.traceId });res.set('Location', `/api/v1/jobs/${job.id}`);if (replayed) res.set('Idempotent-Replayed', 'true');return res.status(replayed ? 200 : 202).json(system.view(job));@@@ -3,7 +3,8 @@// Tools job runtime (roadmap Wave 11). Shared by the satellites the way apps/_shared always is:// required by relative path, no dependencies of its own — each app passes its better-sqlite3,// openvibe-contracts and openvibe-sdk. See system.js (lifecycle), http.js (routes), media.js (results--// in Media), events.js (tools.job.* to OpenVibe.Events), client.js (browser helper).++// in Media), events.js (tools.job.* to OpenVibe.Events), usage.js (tools.usage.recorded, a developer++// project's jobs per hour), client.js (browser helper).//// Environment (one /etc/openvibe/tools.env for every unit):// TOOLS_JOBS_CONCURRENCY jobs running at once per satellite (default 2)@@@ -70,6 +70,8 @@ const ADDED_COLUMNS = [['tool', 'TEXT'], // the tool (tools.tool@1 id) whose run created the job (POST /api/v1/tools/:id/run)['project_id', 'TEXT'], // a developer app's job: its project (prj_…), so its results go to Media under the// project's namespace, metered per project (WS-L task 5)++ ['trace_id', 'TEXT'], // the trace id of the request that submitted the job, sampled with a developer++ // project's failures in its usage rollups (./usage.js, WS-N task 4)];const parse = (s, fallback) => { if (s == null) return fallback; try { return JSON.parse(s); } catch { return fallback; } };@@@ -83,8 +85,8 @@ function createStore(db) {db.exec("CREATE INDEX IF NOT EXISTS tool_jobs_ip ON tool_jobs(ip_key, state) WHERE ip_key IS NOT NULL");const q = {-- insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at, env, retry_of, ip_key, tool, project_id)-- VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now, @env, @retry_of, @ip_key, @tool, @project_id)`),++ insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at, env, retry_of, ip_key, tool, project_id, trace_id)++ VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now, @env, @retry_of, @ip_key, @tool, @project_id, @trace_id)`),markRetried: db.prepare("UPDATE tool_jobs SET retried_by = @next, updated_at = @now WHERE id = @id AND state = 'failed' AND retried_by IS NULL"),get: db.prepare('SELECT * FROM tool_jobs WHERE id = ?'),byIdem: db.prepare('SELECT * FROM tool_jobs WHERE owner = ? AND idempotency_key = ?'),@@@ -129,7 +131,7 @@ function createStore(db) {return {db,TERMINAL, STATES,-- insert(row) { q.insert.run({ retry_of: null, ip_key: null, tool: null, project_id: null, ...row }); },++ insert(row) { q.insert.run({ retry_of: null, ip_key: null, tool: null, project_id: null, trace_id: null, ...row }); },byResultMedia: (owner, mediaId) => q.byResultMedia.all(owner, `%"media_id":"${String(mediaId).replace(/[%"\\]/g, '')}"%`),markRetried: (id, next, now = Date.now()) => q.markRetried.run({ id, next, now }).changes === 1,get: (id) => q.get.get(id) || null,@@@ -27,6 +27,10 @@// started, succeeded and failed are also announced to OpenVibe.Events as tools.job.*, each written// in the transaction that records the transition. Without one, nothing is announced.//++// Usage (WS-N task 4): with an outbox, a developer project's job that ends is also counted in the++// transaction that records its end (./usage.js), and each closed hour goes out as tools.usage.recorded++// from the pruner's timer.++//// No dependencies of its own: the app passes its better-sqlite3 handle and openvibe-contracts.// ═══════════════════════════════════════════════════════════════@@@ -36,6 +40,7 @@ const path = require('path');const crypto = require('crypto');const { createStore, TERMINAL } = require('./store');const { createJobEvents } = require('./events');++const { createJobUsage } = require('./usage');const HOUR = 60 * 60 * 1000;@@@ -112,6 +117,13 @@ function createJobSystem(o) {const diskBudget = o.diskBudgetBytes > 0 ? o.diskBudgetBytes : 0;const disk = { bytes: 0, at: 0, scanning: null };const announce = createJobEvents({ contracts, service: o.service, outbox: o.outbox || null, referenceCount: (id) => store.referenceCount(id), log });++ const usage = createJobUsage({ db: o.db, contracts, outbox: o.outbox || null, log });++ /** Inside the transaction that recorded the end: tools.job.succeeded|failed and the project's usage. */++ function ended(id) {++ const row = store.get(id);++ announce.finished(row);++ usage.finished(row);++ }const types = new Map();const active = new Map(); // id → { ctrl, reason, last: { pct, msg, at } }@@@ -212,7 +224,7 @@ function createJobSystem(o) {// Developer-app sandbox jobs: fewer at a time, kept briefly, results never leave this server.const SANDBOX_MAX_ACTIVE = Math.max(1, o.sandboxMaxActivePerOwner || 2);const SANDBOX_TTL_MS = 30 * 60 * 1000;-- async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR, env = 'production', ipKey = null, tool = null, project = null }) {++ async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR, env = 'production', ipKey = null, tool = null, project = null, traceId = null }) {if (env !== 'sandbox') env = 'production';if (env === 'sandbox') ttlMs = Math.min(ttlMs, SANDBOX_TTL_MS);if (stopped) throw new JobError(503, 'tools.job.unavailable', 'The job system is shutting down');@@@ -264,6 +276,7 @@ function createJobSystem(o) {ip_key: String(owner).startsWith('session:') && ipKey ? String(ipKey) : null,tool: tool && /^[a-z][a-z0-9-]{0,39}$/.test(String(tool)) ? String(tool) : null,project_id: projectOf(project),++ trace_id: /^[0-9a-f]{32}$/.test(String(traceId || '')) ? String(traceId) : null,});announce.created(store.get(id));})();@@@ -368,7 +381,7 @@ function createJobSystem(o) {let recorded = false;try {// The end state and its tools.job.succeeded|failed event commit together (or neither does).-- store.transaction(() => { if (store.finish(row.id, outcome)) announce.finished(store.get(row.id)); })();++ store.transaction(() => { if (store.finish(row.id, outcome)) ended(row.id); })();recorded = true;emit(row.id, EVENT_FOR[outcome.state]);} catch (err) {@@@ -534,7 +547,7 @@ function createJobSystem(o) {// ── Boot recovery, pruning ─────────────────────────────────/** A job the restart ended as failed: the row and its tools.job.failed event in one transaction. */function failRecovered(id, outcome) {-- store.transaction(() => { if (store.finish(id, outcome)) announce.finished(store.get(id)); })();++ store.transaction(() => { if (store.finish(id, outcome)) ended(id); })();}function recover() {@@@ -657,7 +670,11 @@ function createJobSystem(o) {const r = recover();if (r.requeued || r.failed) log.log(`[Jobs] ${o.service}: ${r.requeued} job(s) re-queued, ${r.failed} failed after restart`);prune().catch(() => {});-- pruneTimer = setInterval(() => prune().catch(err => log.error('[Jobs] prune:', err.message)), o.pruneIntervalMs || 5 * 60 * 1000);++ flushUsage();++ pruneTimer = setInterval(() => {++ prune().catch(err => log.error('[Jobs] prune:', err.message));++ flushUsage();++ }, o.pruneIntervalMs || 5 * 60 * 1000);if (pruneTimer.unref) pruneTimer.unref();kick();return api;@@@ -667,6 +684,11 @@ function createJobSystem(o) {* Stop. Running jobs are left as they are in the database ('running'), which is exactly what a* crash or a restart leaves behind; the next start() recovers them per type.*/++ /** Closed hours of project usage → the outbox (./usage.js); a failure is logged and retried next tick. */++ function flushUsage(at) {++ try { return usage.flush(at); } catch (err) { log.error('[Jobs] usage flush:', err.message); return { queued: 0, invalid: 0 }; }++ }++function stop() {stopped = true;if (pruneTimer) clearInterval(pruneTimer);@@@ -706,7 +728,8 @@ function createJobSystem(o) {types: () => [...types.keys()],media,// running (after the spread) is the store's count of rows in 'running'; executing is this process's.-- stats: () => ({ running: active.size, concurrency, ...store.counts(), executing: active.size, results: media ? 'media' : 'local', events: announce.status() }),++ stats: () => ({ running: active.size, concurrency, ...store.counts(), executing: active.size, results: media ? 'media' : 'local', events: announce.status(), usage: usage.enabled ? usage.status() : { enabled: false } }),++ usage, flushUsage,outbox: o.outbox || null,/** True between start() and stop(): the worker picks up queued jobs. */isRunning: () => started && !stopped,nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Developer-project usage → OpenVibe.Events (roadmap WS-N task 4, ADR-014).++//++// A job submitted with a developer-app token carries its project (tool_jobs.project_id) and env. When++// such a job ends (succeeded or failed; a cancelled job is not counted), the transaction that records++// the end also counts it in `tool_job_usage`, per project, environment, capability, job type or tool++// (dimension) and UTC hour:++//++// capability tools.tool.run for a tool run (POST /api/v1/tools/:id/run; dimension: the tool id),++// tools.job.create for POST /api/v1/jobs (dimension: the job type, img.process)++// unit jobs++// quantity jobs that ended in the hour; errors: the failed ones, by problem code, with the last ten++// failures' job id, status and the trace id of the request that submitted the job++//++// Once an hour has closed (plus a minute), flush() writes each of its rollups to the satellite's++// outbox as tools.usage.recorded (openvibe-contracts common.usage-recorded@1: subject the project,++// visibility internal, priority low, actor service:tools) in the transaction that marks it sent; the++// outbox relay publishes it. A job that ends in an hour already sent (a clock step back) reopens it as++// revision + 1. Only counts leave: no owner, session, address, input, file name or output.++//++// Inert without an outbox (EVENTS_URL unset, like ./events.js): nothing is counted.++// ═══════════════════════════════════════════════════════════════++++const HOUR_MS = 60 * 60 * 1000;++const GRACE_MS = 60 * 1000;++const KEEP_SENT_MS = 7 * 24 * HOUR_MS;++const MAX_CODES = 20;++const MAX_SAMPLES = 10;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const CODE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;++const TRACE_RE = /^[0-9a-f]{32}$/;++const DIMENSION_RE = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS tool_job_usage (++ project_id TEXT NOT NULL,++ env TEXT NOT NULL,++ capability TEXT NOT NULL,++ dimension TEXT NOT NULL,++ window_start INTEGER NOT NULL,++ quantity INTEGER NOT NULL DEFAULT 0,++ errors INTEGER NOT NULL DEFAULT 0,++ error_codes TEXT NOT NULL DEFAULT '{}',++ samples TEXT NOT NULL DEFAULT '[]',++ revision INTEGER NOT NULL DEFAULT 1,++ event_id TEXT,++ emitted_at INTEGER,++ PRIMARY KEY (project_id, env, capability, dimension, window_start)++);++CREATE INDEX IF NOT EXISTS tool_job_usage_open ON tool_job_usage(emitted_at, window_start);++`;++++const hourOf = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS;++const parse = (s, d) => { try { return JSON.parse(s); } catch { return d; } };++++/** The usage key of an ended tool_jobs row, or null when it does not count. */++function keyOf(row) {++ if (!row || !PROJECT_RE.test(String(row.project_id || '')) || (row.state !== 'succeeded' && row.state !== 'failed')) return null;++ const dimension = String(row.tool || row.type || '');++ return {++ project_id: row.project_id,++ env: row.env === 'sandbox' ? 'sandbox' : 'production',++ capability: row.tool ? 'tools.tool.run' : 'tools.job.create',++ dimension: DIMENSION_RE.test(dimension) ? dimension : 'other',++ window_start: hourOf(row.finished_at || Date.now()),++ };++}++++/**++ * @param {object} o++ * @param {object} o.db the jobs database (better-sqlite3), where the outbox lives too++ * @param {object} o.contracts require('openvibe-contracts') (0.63.0+ knows tools.usage.recorded@1)++ * @param {object|null} o.outbox openvibe-sdk outbox on `db` (./events outboxFromEnv); null = inert++ * @param {() => number} [o.now]++ * @param {object} [o.log]++ */++function createJobUsage({ db, contracts, outbox = null, now = () => Date.now(), log = console }) {++ if (!outbox) return { enabled: false, finished() {}, flush: () => ({ queued: 0, invalid: 0 }), pending: () => 0 };++ db.exec(SCHEMA);++ const q = {++ add: db.prepare(`INSERT INTO tool_job_usage (project_id, env, capability, dimension, window_start, quantity, errors)++ VALUES (@project_id, @env, @capability, @dimension, @window_start, 1, @errors)++ ON CONFLICT(project_id, env, capability, dimension, window_start) DO UPDATE SET++ quantity = quantity + 1, errors = errors + excluded.errors,++ revision = revision + (CASE WHEN emitted_at IS NULL THEN 0 ELSE 1 END), emitted_at = NULL`),++ get: db.prepare('SELECT error_codes, samples FROM tool_job_usage WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ?'),++ setErrors: db.prepare('UPDATE tool_job_usage SET error_codes = ?, samples = ? WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ?'),++ due: db.prepare('SELECT * FROM tool_job_usage WHERE emitted_at IS NULL AND window_start <= ? ORDER BY window_start LIMIT 500'),++ sent: db.prepare('UPDATE tool_job_usage SET emitted_at = ?, event_id = ? WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ? AND emitted_at IS NULL'),++ prune: db.prepare('DELETE FROM tool_job_usage WHERE emitted_at IS NOT NULL AND window_start < ?'),++ pending: db.prepare('SELECT COUNT(*) AS n FROM tool_job_usage WHERE emitted_at IS NULL'),++ };++ const stats = { invalid: 0, lastInvalid: null };++++ /** INSIDE the transaction that records the job's end (store.finish), with the row read back. */++ function finished(row) {++ const k = keyOf(row);++ if (!k) return;++ const failed = row.state === 'failed';++ q.add.run({ ...k, errors: failed ? 1 : 0 });++ if (!failed) return;++ const error = parse(row.error_json, null) || {};++ const code = CODE_RE.test(String(error.code || '')) ? String(error.code) : 'tools.job.failed';++ const cur = q.get.get(k.project_id, k.env, k.capability, k.dimension, k.window_start);++ const codes = parse(cur.error_codes, {});++ if (codes[code] || Object.keys(codes).length < MAX_CODES) codes[code] = (codes[code] || 0) + 1;++ const sample = { at: new Date(row.finished_at || now()).toISOString(), code, ref: row.id };++ if (Number.isInteger(error.status) && error.status >= 100 && error.status <= 599) sample.status = error.status;++ if (TRACE_RE.test(String(row.trace_id || ''))) sample.trace_id = row.trace_id;++ const samples = [sample, ...parse(cur.samples, [])].slice(0, MAX_SAMPLES);++ q.setErrors.run(JSON.stringify(codes), JSON.stringify(samples), k.project_id, k.env, k.capability, k.dimension, k.window_start);++ }++++ function payloadOf(row) {++ const p = {++ project_id: row.project_id, env: row.env, capability: row.capability, dimension: row.dimension, unit: 'jobs', window: 'hour',++ window_start: new Date(row.window_start).toISOString(), window_end: new Date(row.window_start + HOUR_MS).toISOString(),++ quantity: row.quantity, errors: row.errors,++ };++ const codes = parse(row.error_codes, {});++ if (Object.keys(codes).length) p.error_codes = codes;++ const samples = parse(row.samples, []);++ if (samples.length) p.samples = samples;++ if (row.revision > 1) p.revision = row.revision;++ return p;++ }++++ /**++ * Write every closed hour's rollups to the outbox (each in the transaction that marks it sent).++ * A payload that fails its contract is logged and left unsent, never thrown.++ */++ function flush(at = now()) {++ let queued = 0;++ for (const row of q.due.all(hourOf(at - GRACE_MS) - HOUR_MS)) {++ const payload = payloadOf(row);++ const v = contracts.validate('tools.usage.recorded@1', payload);++ if (!v.valid) {++ stats.invalid++;++ stats.lastInvalid = `${row.project_id} ${row.capability} ${row.dimension}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`;++ log.error(`[Jobs] tools.usage.recorded not sent (payload does not match the contract): ${stats.lastInvalid}`);++ continue;++ }++ db.transaction(() => {++ const env = outbox.enqueue({++ event_type: 'tools.usage.recorded',++ actor: { type: 'service', id: 'tools' },++ subject: { type: 'project', id: row.project_id },++ visibility: 'internal',++ priority: 'low',++ payload,++ });++ q.sent.run(at, env.event_id, row.project_id, row.env, row.capability, row.dimension, row.window_start);++ })();++ queued++;++ }++ q.prune.run(at - KEEP_SENT_MS);++ if (queued) outbox.kick();++ return { queued, invalid: stats.invalid };++ }++++ return { enabled: true, finished, flush, payloadOf, pending: () => q.pending.get().n, status: () => ({ pending: q.pending.get().n, invalid: stats.invalid, last_invalid: stats.lastInvalid }) };++}++++module.exports = { createJobUsage, keyOf, HOUR_MS, GRACE_MS };@@@ -1,6 +1,6 @@{"name": "openvibe-tools-shared",-- "version": "1.0.0",++ "version": "1.1.0","private": true,"description": "The Tools apps' shared runtime (apps/_shared): guard, egress, jobs, uploads, usage, internal auth, release, graceful stop. No dependencies: its modules take packages from the app that requires them.","license": "MIT"nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++// Developer-project usage (WS-N task 4, jobs/usage.js): a project's job that ends is counted in the++// transaction that records its end, per project, environment, capability, job type or tool and hour,++// failures by code with their job id and submit trace id; nobody else's jobs and no cancelled job++// count; each closed hour is written to the outbox once as tools.usage.recorded (valid against++// openvibe-contracts, no owner, input or file name); a job ending in an hour already sent re-sends it++// as revision 2; without an outbox nothing is counted.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { dep } = require('./deps');++++const Database = dep('better-sqlite3');++const contracts = dep('openvibe-contracts');++const sdk = dep('openvibe-sdk');++const jobs = require('../jobs');++const { outboxFromEnv } = require('../jobs/events');++const { keyOf, HOUR_MS } = require('../jobs/usage');++++const PRJ = 'prj_01JDDDDDDDDDDDDDDDDDDDDDDD';++const APP = 'app_01JCCCCCCCCCCCCCCCCCCCCCCC';++const USER = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';++const TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';++const SECRET_INPUT = 'secret-input-text-xyzzy';++const SECRET_FILE = 'holiday-photo-of-alice.txt';++const silent = { log() {}, warn() {}, error() {} };++const sleep = (ms) => new Promise(r => setTimeout(r, ms));++async function until(fn, what, ms = 5000) {++ const t0 = Date.now();++ for (;;) {++ const v = await fn();++ if (v) return v;++ if (Date.now() - t0 > ms) throw new Error(`timed out waiting for ${what}`);++ await sleep(10);++ }++}++++const ENV = { EVENTS_URL: 'http://events.test/', OV_OAUTH_CLIENT_SECRET: 'tools-secret', OV_NETWORK_INTERNAL_URL: 'http://network.test' };++const fakeFetch = async () => new Response('{}', { status: 503, headers: { 'content-type': 'application/json' } });++++function define(system) {++ system.define({++ type: 'test.upper', maxFiles: 1,++ async run({ input, outDir, signal }) {++ if (input.hold) await new Promise((_, rej) => (signal.aborted ? rej(new Error('aborted')) : signal.addEventListener('abort', () => rej(new Error('aborted')))));++ const out = path.join(outDir, 'secret-output-name.txt');++ fs.writeFileSync(out, String(input.text || '').toUpperCase());++ return { files: [{ path: out, name: 'secret-output-name.txt', mime: 'text/plain' }] };++ },++ });++ system.define({++ type: 'test.boom', maxFiles: 1,++ async run({ files }) { throw Object.assign(new Error(`Cannot read ${files[0].name}`), { status: 422, code: 'tools.test.unreadable' }); },++ });++}++++(async () => {++ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-job-usage-'));++ try {++ // ── Without an outbox: nothing counted, no table ──++ {++ const db = new Database(':memory:');++ const dir = path.join(root, 'inert');++ fs.mkdirSync(dir);++ const system = jobs.createJobSystem({ db, contracts, service: 'img', dataDir: dir, log: silent });++ define(system);++ system.start();++ const { job } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { text: 'x' }, project: PRJ });++ await until(() => system.get(job.id).state === 'succeeded', 'inert job');++ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'tool_job_usage'").get().n, 0);++ assert.deepStrictEqual(system.stats().usage, { enabled: false });++ system.stop(); db.close();++ }++++ const dir = path.join(root, 'a');++ fs.mkdirSync(dir);++ const db = new Database(path.join(dir, 'jobs.db'));++ const { outbox } = outboxFromEnv({ db, sdk, env: ENV, fetch: fakeFetch, log: silent });++ const system = jobs.createJobSystem({ db, contracts, service: 'img', dataDir: dir, concurrency: 1, outbox, progressThrottleMs: 0, log: silent });++ define(system);++ system.start();++ const run = async (o, state) => {++ const { job } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { text: SECRET_INPUT }, ...o });++ await until(() => { const r = system.get(job.id); return r && r.state === state; }, `${job.id} ${state}`);++ return job.id;++ };++++ // ── What counts: a project's ended jobs, per capability, dimension and environment ──++ await run({ project: PRJ }, 'succeeded');++ await run({ project: PRJ, traceId: TRACE }, 'succeeded');++ const failedId = await run({ project: PRJ, type: 'test.boom', traceId: TRACE, files: [{ buffer: Buffer.from('x'), name: SECRET_FILE, mime: 'text/plain' }] }, 'failed');++ await run({ project: PRJ, env: 'sandbox' }, 'succeeded');++ await run({ project: PRJ, tool: 'image-resize' }, 'succeeded');++ await run({ project: null, owner: `user:${USER}` }, 'succeeded'); // a person's job: no project, not counted++ assert.strictEqual(system.get(failedId).trace_id, TRACE, 'the submit request\'s trace id is kept with the job');++ const { job: held } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { hold: true }, project: PRJ });++ await until(() => system.get(held.id).state === 'running', 'held job running');++ system.cancel(held.id);++ await until(() => system.get(held.id).state === 'cancelled', 'held job cancelled');++++ const rows = db.prepare('SELECT * FROM tool_job_usage ORDER BY capability, dimension, env').all();++ const view = rows.map(r => [r.capability, r.dimension, r.env, r.quantity, r.errors]);++ assert.deepStrictEqual(view, [++ ['tools.job.create', 'test.boom', 'production', 1, 1],++ ['tools.job.create', 'test.upper', 'production', 2, 0],++ ['tools.job.create', 'test.upper', 'sandbox', 1, 0],++ ['tools.tool.run', 'image-resize', 'production', 1, 0],++ ], 'a cancelled job and a person\'s job are not counted');++ const boom = rows[0];++ assert.deepStrictEqual(JSON.parse(boom.error_codes), { 'tools.test.unreadable': 1 });++ const [sample] = JSON.parse(boom.samples);++ assert.deepStrictEqual([sample.code, sample.status, sample.trace_id, sample.ref], ['tools.test.unreadable', 422, TRACE, failedId]);++ assert.strictEqual(keyOf({ ...system.get(failedId), state: 'cancelled' }), null);++++ // ── Nothing leaves before the hour closes ──++ const before = db.prepare("SELECT COUNT(*) AS n FROM event_outbox WHERE envelope LIKE '%tools.usage.recorded%'").get().n;++ assert.strictEqual(before, 0);++ assert.strictEqual(system.flushUsage().queued, 0);++++ // ── After the hour: one tools.usage.recorded per rollup, once ──++ const later = rows[0].window_start + HOUR_MS + 2 * 60 * 1000;++ assert.strictEqual(system.flushUsage(later).queued, 4);++ assert.strictEqual(system.flushUsage(later).queued, 0, 'sent once');++ const sent = db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'tools.usage.recorded');++ assert.strictEqual(sent.length, 4);++ for (const env of sent) {++ const v = contracts.validate('tools.usage.recorded@1', env.payload);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.ok(contracts.validate('events.event-envelope@1', env).valid);++ assert.deepStrictEqual([env.source, env.visibility, env.priority, env.subject.type, env.subject.id, env.actor.id], ['tools', 'internal', 'low', 'project', PRJ, 'tools']);++ assert.strictEqual(env.payload.unit, 'jobs');++ assert.strictEqual(Date.parse(env.payload.window_end) - Date.parse(env.payload.window_start), HOUR_MS);++ }++ const text = JSON.stringify(sent);++ for (const s of [APP, USER, SECRET_INPUT, SECRET_FILE, 'secret-output-name', 'Cannot read']) assert.ok(!text.includes(s), `no "${s}" in usage events`);++ const failedRollup = sent.find(e => e.payload.dimension === 'test.boom').payload;++ assert.deepStrictEqual([failedRollup.quantity, failedRollup.errors, failedRollup.samples[0].ref], [1, 1, failedId]);++++ // ── A job that ends in an hour already sent: re-sent as revision 2 with the new totals ──++ db.transaction(() => system.usage.finished({ ...system.get(failedId), state: 'succeeded' }))();++ assert.strictEqual(system.flushUsage(later).queued, 1);++ const resent = db.prepare('SELECT envelope FROM event_outbox ORDER BY id DESC LIMIT 1').get();++ const p = JSON.parse(resent.envelope).payload;++ assert.deepStrictEqual([p.dimension, p.quantity, p.errors, p.revision], ['test.boom', 2, 1, 2]);++ assert.deepStrictEqual(system.stats().usage, { pending: 0, invalid: 0, last_invalid: null });++++ system.stop(); outbox.stop(); db.close();++ console.log('job usage: all checks passed');++ } finally {++ fs.rmSync(root, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -411,6 +411,7 @@ function createRunApi(o) {idempotencyKey: key, ttlMs: who.kind === 'session' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000,env: who.env || 'production', ipKey: who.ipKey, tool: d.id,project: who.kind === 'principal' && who.claims && who.claims.actor_type === 'app' ? who.claims.project_id : null,++ traceId: req.ov && req.ov.traceId,});row = out.job; replayed = out.replayed;} catch (err) {@@@ -19,7 +19,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0"@@@ -1159,9 +1159,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.63.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",++ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -21,7 +21,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","uuid": "^11.1.0"@@@ -18,7 +18,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","pdf-lib": "^1.17.1",@@@ -1238,9 +1238,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.63.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",++ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","pdf-lib": "^1.17.1",@@@ -17,7 +17,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","jsonwebtoken": "^9.0.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"}@@@ -1036,9 +1036,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.63.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",++ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -19,7 +19,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","jsonwebtoken": "^9.0.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"}@@@ -18,7 +18,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","sharp": "^0.33.5",@@@ -1729,9 +1729,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.63.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",++ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","sharp": "^0.33.5",
@@ -245,6 +245,17 @@ reload reattaches.
@@ -245,6 +245,17 @@ reload reattaches.
creates no job and no event. The relay uses the `tools` client (`OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`)
creates no job and no event. The relay uses the `tools` client (`OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`)
with `events.event.publish` on audience `openvibe.events`. Without `EVENTS_URL` (or with `EVENTS_PUBLISH=off`, or
with `events.event.publish` on audience `openvibe.events`. Without `EVENTS_URL` (or with `EVENTS_PUBLISH=off`, or
no client secret) nothing is written or sent. `GET /api/health` shows `jobs.events` (pending, rejected).
no client secret) nothing is written or sent. `GET /api/health` shows `jobs.events` (pending, rejected).
+- **Project usage to OpenVibe.Events** on the same outbox (`apps/_shared/jobs/usage.js`, roadmap WS-N task 4): a
+ developer app's job (it carries a project) that ends is counted in the transaction that records its end, per
+ project, environment, capability (`tools.tool.run` for a tool run, `tools.job.create` for `POST /api/v1/jobs`),
+ job type or tool and UTC hour: the jobs that ended (succeeded or failed; cancelled ones are not counted) and the
+ failed ones by problem code, with the last ten failures' job id, status and the trace id of the request that
+ submitted the job (`tool_jobs.trace_id`). A minute after an hour closes, the pruner's timer writes each rollup
+ once as `tools.usage.recorded` (`common.usage-recorded@1`, openvibe-contracts 0.63.0; subject the project,
+ visibility `internal`, priority `low`) in the transaction that marks it sent. Sandbox jobs count under
+ `env: sandbox`. No owner, session, address, input, file name or output leaves. OpenVibe.Network adds the rollups
+ up for the project's usage page on openvibe.codes. Off without `EVENTS_URL`, like the job events;
+ `GET /api/health` shows `jobs.usage` (pending, invalid).
Environment (all in `/etc/openvibe/tools.env`): `TOOLS_WORKERS`, `TOOLS_WORKERS_<APP>`, `TOOLS_WORKER_MEMORY_MB`,
Environment (all in `/etc/openvibe/tools.env`): `TOOLS_WORKERS`, `TOOLS_WORKERS_<APP>`, `TOOLS_WORKER_MEMORY_MB`,
`TOOLS_WORKER_MEMORY_MB_<APP>`, `TOOLS_WORKER_QUEUE`, `TOOLS_WORKER_IDLE_MS`, `TOOLS_JOBS_CONCURRENCY`, `TOOLS_JOBS_CONCURRENCY_<APP>`,
`TOOLS_WORKER_MEMORY_MB_<APP>`, `TOOLS_WORKER_QUEUE`, `TOOLS_WORKER_IDLE_MS`, `TOOLS_JOBS_CONCURRENCY`, `TOOLS_JOBS_CONCURRENCY_<APP>`,
@@ -4,6 +4,18 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openvi
@@ -4,6 +4,18 @@ The Tools apps' shared runtime. Each app declares the range it needs as `"openvi
its `package.json`, and refuses to boot when this checkout does not satisfy it (`version.js`). A
its `package.json`, and refuses to boot when this checkout does not satisfy it (`version.js`). A
breaking change bumps the major and every app's range in the same commit.
breaking change bumps the major and every app's range in the same commit.
+## 1.1.0 — 2026-09-26
+
+Additive (roadmap WS-N task 4):
+
+- `jobs/usage.js`: a developer project's jobs that end are counted per project, environment,
+ capability (`tools.job.create`, `tools.tool.run`), job type or tool and UTC hour, in the transaction
+ that records the end, and each closed hour goes to the outbox once as `tools.usage.recorded`
+ (openvibe-contracts 0.63.0). Inert without an outbox, like the job events.
+- `jobs/`: `submit({ traceId })` keeps the submit request's trace id in `tool_jobs.trace_id`
+ (added when missing); `jobs/http.js` and `tools/run.js` pass `req.ov.traceId`. `stats().usage`,
+ `usage` and `flushUsage()` on the job system.
+
## 1.0.0 — 2026-09-26
## 1.0.0 — 2026-09-26
The first versioned release (roadmap WS-L task 3). It contains what every satellite already took from
The first versioned release (roadmap WS-L task 3). It contains what every satellite already took from
@@ -174,7 +174,7 @@ function mountJobRoutes(app, o) {
@@ -174,7 +174,7 @@ function mountJobRoutes(app, o) {
if (!ok) { for (const f of files) if (f.path) fs.unlink(f.path, () => {}); return; }
if (!ok) { for (const f of files) if (f.path) fs.unlink(f.path, () => {}); return; }
}
}
try {
try {
- const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w), env: w.env || 'production', ipKey: w.caller ? w.caller.ipKey : null, project: w.kind === 'principal' && w.claims && w.claims.actor_type === 'app' ? w.claims.project_id : null });
+ const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w), env: w.env || 'production', ipKey: w.caller ? w.caller.ipKey : null, project: w.kind === 'principal' && w.claims && w.claims.actor_type === 'app' ? w.claims.project_id : null, traceId: req.ov && req.ov.traceId });
res.set('Location', `/api/v1/jobs/${job.id}`);
res.set('Location', `/api/v1/jobs/${job.id}`);
if (replayed) res.set('Idempotent-Replayed', 'true');
if (replayed) res.set('Idempotent-Replayed', 'true');
return res.status(replayed ? 200 : 202).json(system.view(job));
return res.status(replayed ? 200 : 202).json(system.view(job));
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
// Tools job runtime (roadmap Wave 11). Shared by the satellites the way apps/_shared always is:
// Tools job runtime (roadmap Wave 11). Shared by the satellites the way apps/_shared always is:
// required by relative path, no dependencies of its own — each app passes its better-sqlite3,
// required by relative path, no dependencies of its own — each app passes its better-sqlite3,
// openvibe-contracts and openvibe-sdk. See system.js (lifecycle), http.js (routes), media.js (results
// openvibe-contracts and openvibe-sdk. See system.js (lifecycle), http.js (routes), media.js (results
-// in Media), events.js (tools.job.* to OpenVibe.Events), client.js (browser helper).
+// in Media), events.js (tools.job.* to OpenVibe.Events), usage.js (tools.usage.recorded, a developer
+// project's jobs per hour), client.js (browser helper).
//
//
// Environment (one /etc/openvibe/tools.env for every unit):
// Environment (one /etc/openvibe/tools.env for every unit):
// TOOLS_JOBS_CONCURRENCY jobs running at once per satellite (default 2)
// TOOLS_JOBS_CONCURRENCY jobs running at once per satellite (default 2)
@@ -70,6 +70,8 @@ const ADDED_COLUMNS = [
@@ -70,6 +70,8 @@ const ADDED_COLUMNS = [
['tool', 'TEXT'], // the tool (tools.tool@1 id) whose run created the job (POST /api/v1/tools/:id/run)
['tool', 'TEXT'], // the tool (tools.tool@1 id) whose run created the job (POST /api/v1/tools/:id/run)
['project_id', 'TEXT'], // a developer app's job: its project (prj_…), so its results go to Media under the
['project_id', 'TEXT'], // a developer app's job: its project (prj_…), so its results go to Media under the
// project's namespace, metered per project (WS-L task 5)
// project's namespace, metered per project (WS-L task 5)
+ ['trace_id', 'TEXT'], // the trace id of the request that submitted the job, sampled with a developer
+ // project's failures in its usage rollups (./usage.js, WS-N task 4)
];
];
const parse = (s, fallback) => { if (s == null) return fallback; try { return JSON.parse(s); } catch { return fallback; } };
const parse = (s, fallback) => { if (s == null) return fallback; try { return JSON.parse(s); } catch { return fallback; } };
@@ -83,8 +85,8 @@ function createStore(db) {
@@ -83,8 +85,8 @@ function createStore(db) {
db.exec("CREATE INDEX IF NOT EXISTS tool_jobs_ip ON tool_jobs(ip_key, state) WHERE ip_key IS NOT NULL");
db.exec("CREATE INDEX IF NOT EXISTS tool_jobs_ip ON tool_jobs(ip_key, state) WHERE ip_key IS NOT NULL");
const q = {
const q = {
- insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at, env, retry_of, ip_key, tool, project_id)
- VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now, @env, @retry_of, @ip_key, @tool, @project_id)`),
+ insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at, env, retry_of, ip_key, tool, project_id, trace_id)
+ VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now, @env, @retry_of, @ip_key, @tool, @project_id, @trace_id)`),
markRetried: db.prepare("UPDATE tool_jobs SET retried_by = @next, updated_at = @now WHERE id = @id AND state = 'failed' AND retried_by IS NULL"),
markRetried: db.prepare("UPDATE tool_jobs SET retried_by = @next, updated_at = @now WHERE id = @id AND state = 'failed' AND retried_by IS NULL"),
get: db.prepare('SELECT * FROM tool_jobs WHERE id = ?'),
get: db.prepare('SELECT * FROM tool_jobs WHERE id = ?'),
byIdem: db.prepare('SELECT * FROM tool_jobs WHERE owner = ? AND idempotency_key = ?'),
byIdem: db.prepare('SELECT * FROM tool_jobs WHERE owner = ? AND idempotency_key = ?'),
@@ -129,7 +131,7 @@ function createStore(db) {
@@ -129,7 +131,7 @@ function createStore(db) {
return {
return {
db,
db,
TERMINAL, STATES,
TERMINAL, STATES,
- insert(row) { q.insert.run({ retry_of: null, ip_key: null, tool: null, project_id: null, ...row }); },
+ insert(row) { q.insert.run({ retry_of: null, ip_key: null, tool: null, project_id: null, trace_id: null, ...row }); },
byResultMedia: (owner, mediaId) => q.byResultMedia.all(owner, `%"media_id":"${String(mediaId).replace(/[%"\\]/g, '')}"%`),
byResultMedia: (owner, mediaId) => q.byResultMedia.all(owner, `%"media_id":"${String(mediaId).replace(/[%"\\]/g, '')}"%`),
markRetried: (id, next, now = Date.now()) => q.markRetried.run({ id, next, now }).changes === 1,
markRetried: (id, next, now = Date.now()) => q.markRetried.run({ id, next, now }).changes === 1,
get: (id) => q.get.get(id) || null,
get: (id) => q.get.get(id) || null,
@@ -27,6 +27,10 @@
@@ -27,6 +27,10 @@
// started, succeeded and failed are also announced to OpenVibe.Events as tools.job.*, each written
// started, succeeded and failed are also announced to OpenVibe.Events as tools.job.*, each written
// in the transaction that records the transition. Without one, nothing is announced.
// in the transaction that records the transition. Without one, nothing is announced.
//
//
+// Usage (WS-N task 4): with an outbox, a developer project's job that ends is also counted in the
+// transaction that records its end (./usage.js), and each closed hour goes out as tools.usage.recorded
+// from the pruner's timer.
+//
// No dependencies of its own: the app passes its better-sqlite3 handle and openvibe-contracts.
// No dependencies of its own: the app passes its better-sqlite3 handle and openvibe-contracts.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -36,6 +40,7 @@ const path = require('path');
@@ -36,6 +40,7 @@ const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const { createStore, TERMINAL } = require('./store');
const { createStore, TERMINAL } = require('./store');
const { createJobEvents } = require('./events');
const { createJobEvents } = require('./events');
+const { createJobUsage } = require('./usage');
const HOUR = 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000;
@@ -112,6 +117,13 @@ function createJobSystem(o) {
@@ -112,6 +117,13 @@ function createJobSystem(o) {
const diskBudget = o.diskBudgetBytes > 0 ? o.diskBudgetBytes : 0;
const diskBudget = o.diskBudgetBytes > 0 ? o.diskBudgetBytes : 0;
const disk = { bytes: 0, at: 0, scanning: null };
const disk = { bytes: 0, at: 0, scanning: null };
const announce = createJobEvents({ contracts, service: o.service, outbox: o.outbox || null, referenceCount: (id) => store.referenceCount(id), log });
const announce = createJobEvents({ contracts, service: o.service, outbox: o.outbox || null, referenceCount: (id) => store.referenceCount(id), log });
+ const usage = createJobUsage({ db: o.db, contracts, outbox: o.outbox || null, log });
+ /** Inside the transaction that recorded the end: tools.job.succeeded|failed and the project's usage. */
+ function ended(id) {
+ const row = store.get(id);
+ announce.finished(row);
+ usage.finished(row);
+ }
const types = new Map();
const types = new Map();
const active = new Map(); // id → { ctrl, reason, last: { pct, msg, at } }
const active = new Map(); // id → { ctrl, reason, last: { pct, msg, at } }
@@ -212,7 +224,7 @@ function createJobSystem(o) {
@@ -212,7 +224,7 @@ function createJobSystem(o) {
// Developer-app sandbox jobs: fewer at a time, kept briefly, results never leave this server.
// Developer-app sandbox jobs: fewer at a time, kept briefly, results never leave this server.
const SANDBOX_MAX_ACTIVE = Math.max(1, o.sandboxMaxActivePerOwner || 2);
const SANDBOX_MAX_ACTIVE = Math.max(1, o.sandboxMaxActivePerOwner || 2);
const SANDBOX_TTL_MS = 30 * 60 * 1000;
const SANDBOX_TTL_MS = 30 * 60 * 1000;
- async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR, env = 'production', ipKey = null, tool = null, project = null }) {
+ async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR, env = 'production', ipKey = null, tool = null, project = null, traceId = null }) {
if (env !== 'sandbox') env = 'production';
if (env !== 'sandbox') env = 'production';
if (env === 'sandbox') ttlMs = Math.min(ttlMs, SANDBOX_TTL_MS);
if (env === 'sandbox') ttlMs = Math.min(ttlMs, SANDBOX_TTL_MS);
if (stopped) throw new JobError(503, 'tools.job.unavailable', 'The job system is shutting down');
if (stopped) throw new JobError(503, 'tools.job.unavailable', 'The job system is shutting down');
@@ -264,6 +276,7 @@ function createJobSystem(o) {
@@ -264,6 +276,7 @@ function createJobSystem(o) {
ip_key: String(owner).startsWith('session:') && ipKey ? String(ipKey) : null,
ip_key: String(owner).startsWith('session:') && ipKey ? String(ipKey) : null,
tool: tool && /^[a-z][a-z0-9-]{0,39}$/.test(String(tool)) ? String(tool) : null,
tool: tool && /^[a-z][a-z0-9-]{0,39}$/.test(String(tool)) ? String(tool) : null,
project_id: projectOf(project),
project_id: projectOf(project),
+ trace_id: /^[0-9a-f]{32}$/.test(String(traceId || '')) ? String(traceId) : null,
});
});
announce.created(store.get(id));
announce.created(store.get(id));
})();
})();
@@ -368,7 +381,7 @@ function createJobSystem(o) {
@@ -368,7 +381,7 @@ function createJobSystem(o) {
let recorded = false;
let recorded = false;
try {
try {
// The end state and its tools.job.succeeded|failed event commit together (or neither does).
// The end state and its tools.job.succeeded|failed event commit together (or neither does).
- store.transaction(() => { if (store.finish(row.id, outcome)) announce.finished(store.get(row.id)); })();
+ store.transaction(() => { if (store.finish(row.id, outcome)) ended(row.id); })();
recorded = true;
recorded = true;
emit(row.id, EVENT_FOR[outcome.state]);
emit(row.id, EVENT_FOR[outcome.state]);
} catch (err) {
} catch (err) {
@@ -534,7 +547,7 @@ function createJobSystem(o) {
@@ -534,7 +547,7 @@ function createJobSystem(o) {
// ── Boot recovery, pruning ─────────────────────────────────
// ── Boot recovery, pruning ─────────────────────────────────
/** A job the restart ended as failed: the row and its tools.job.failed event in one transaction. */
/** A job the restart ended as failed: the row and its tools.job.failed event in one transaction. */
function failRecovered(id, outcome) {
function failRecovered(id, outcome) {
- store.transaction(() => { if (store.finish(id, outcome)) announce.finished(store.get(id)); })();
+ store.transaction(() => { if (store.finish(id, outcome)) ended(id); })();
}
}
function recover() {
function recover() {
@@ -657,7 +670,11 @@ function createJobSystem(o) {
@@ -657,7 +670,11 @@ function createJobSystem(o) {
const r = recover();
const r = recover();
if (r.requeued || r.failed) log.log(`[Jobs] ${o.service}: ${r.requeued} job(s) re-queued, ${r.failed} failed after restart`);
if (r.requeued || r.failed) log.log(`[Jobs] ${o.service}: ${r.requeued} job(s) re-queued, ${r.failed} failed after restart`);
prune().catch(() => {});
prune().catch(() => {});
- pruneTimer = setInterval(() => prune().catch(err => log.error('[Jobs] prune:', err.message)), o.pruneIntervalMs || 5 * 60 * 1000);
+ flushUsage();
+ pruneTimer = setInterval(() => {
+ prune().catch(err => log.error('[Jobs] prune:', err.message));
+ flushUsage();
+ }, o.pruneIntervalMs || 5 * 60 * 1000);
if (pruneTimer.unref) pruneTimer.unref();
if (pruneTimer.unref) pruneTimer.unref();
kick();
kick();
return api;
return api;
@@ -667,6 +684,11 @@ function createJobSystem(o) {
@@ -667,6 +684,11 @@ function createJobSystem(o) {
* Stop. Running jobs are left as they are in the database ('running'), which is exactly what a
* Stop. Running jobs are left as they are in the database ('running'), which is exactly what a
* crash or a restart leaves behind; the next start() recovers them per type.
* crash or a restart leaves behind; the next start() recovers them per type.
*/
*/
+ /** Closed hours of project usage → the outbox (./usage.js); a failure is logged and retried next tick. */
+ function flushUsage(at) {
+ try { return usage.flush(at); } catch (err) { log.error('[Jobs] usage flush:', err.message); return { queued: 0, invalid: 0 }; }
+ }
+
function stop() {
function stop() {
stopped = true;
stopped = true;
if (pruneTimer) clearInterval(pruneTimer);
if (pruneTimer) clearInterval(pruneTimer);
@@ -706,7 +728,8 @@ function createJobSystem(o) {
@@ -706,7 +728,8 @@ function createJobSystem(o) {
types: () => [...types.keys()],
types: () => [...types.keys()],
media,
media,
// running (after the spread) is the store's count of rows in 'running'; executing is this process's.
// running (after the spread) is the store's count of rows in 'running'; executing is this process's.
- stats: () => ({ running: active.size, concurrency, ...store.counts(), executing: active.size, results: media ? 'media' : 'local', events: announce.status() }),
+ stats: () => ({ running: active.size, concurrency, ...store.counts(), executing: active.size, results: media ? 'media' : 'local', events: announce.status(), usage: usage.enabled ? usage.status() : { enabled: false } }),
+ usage, flushUsage,
outbox: o.outbox || null,
outbox: o.outbox || null,
/** True between start() and stop(): the worker picks up queued jobs. */
/** True between start() and stop(): the worker picks up queued jobs. */
isRunning: () => started && !stopped,
isRunning: () => started && !stopped,
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Developer-project usage → OpenVibe.Events (roadmap WS-N task 4, ADR-014).
+//
+// A job submitted with a developer-app token carries its project (tool_jobs.project_id) and env. When
+// such a job ends (succeeded or failed; a cancelled job is not counted), the transaction that records
+// the end also counts it in `tool_job_usage`, per project, environment, capability, job type or tool
+// (dimension) and UTC hour:
+//
+// capability tools.tool.run for a tool run (POST /api/v1/tools/:id/run; dimension: the tool id),
+// tools.job.create for POST /api/v1/jobs (dimension: the job type, img.process)
+// unit jobs
+// quantity jobs that ended in the hour; errors: the failed ones, by problem code, with the last ten
+// failures' job id, status and the trace id of the request that submitted the job
+//
+// Once an hour has closed (plus a minute), flush() writes each of its rollups to the satellite's
+// outbox as tools.usage.recorded (openvibe-contracts common.usage-recorded@1: subject the project,
+// visibility internal, priority low, actor service:tools) in the transaction that marks it sent; the
+// outbox relay publishes it. A job that ends in an hour already sent (a clock step back) reopens it as
+// revision + 1. Only counts leave: no owner, session, address, input, file name or output.
+//
+// Inert without an outbox (EVENTS_URL unset, like ./events.js): nothing is counted.
+// ═══════════════════════════════════════════════════════════════
+
+const HOUR_MS = 60 * 60 * 1000;
+const GRACE_MS = 60 * 1000;
+const KEEP_SENT_MS = 7 * 24 * HOUR_MS;
+const MAX_CODES = 20;
+const MAX_SAMPLES = 10;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CODE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
+const TRACE_RE = /^[0-9a-f]{32}$/;
+const DIMENSION_RE = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS tool_job_usage (
+ project_id TEXT NOT NULL,
+ env TEXT NOT NULL,
+ capability TEXT NOT NULL,
+ dimension TEXT NOT NULL,
+ window_start INTEGER NOT NULL,
+ quantity INTEGER NOT NULL DEFAULT 0,
+ errors INTEGER NOT NULL DEFAULT 0,
+ error_codes TEXT NOT NULL DEFAULT '{}',
+ samples TEXT NOT NULL DEFAULT '[]',
+ revision INTEGER NOT NULL DEFAULT 1,
+ event_id TEXT,
+ emitted_at INTEGER,
+ PRIMARY KEY (project_id, env, capability, dimension, window_start)
+);
+CREATE INDEX IF NOT EXISTS tool_job_usage_open ON tool_job_usage(emitted_at, window_start);
+`;
+
+const hourOf = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS;
+const parse = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
+
+/** The usage key of an ended tool_jobs row, or null when it does not count. */
+function keyOf(row) {
+ if (!row || !PROJECT_RE.test(String(row.project_id || '')) || (row.state !== 'succeeded' && row.state !== 'failed')) return null;
+ const dimension = String(row.tool || row.type || '');
+ return {
+ project_id: row.project_id,
+ env: row.env === 'sandbox' ? 'sandbox' : 'production',
+ capability: row.tool ? 'tools.tool.run' : 'tools.job.create',
+ dimension: DIMENSION_RE.test(dimension) ? dimension : 'other',
+ window_start: hourOf(row.finished_at || Date.now()),
+ };
+}
+
+/**
+ * @param {object} o
+ * @param {object} o.db the jobs database (better-sqlite3), where the outbox lives too
+ * @param {object} o.contracts require('openvibe-contracts') (0.63.0+ knows tools.usage.recorded@1)
+ * @param {object|null} o.outbox openvibe-sdk outbox on `db` (./events outboxFromEnv); null = inert
+ * @param {() => number} [o.now]
+ * @param {object} [o.log]
+ */
+function createJobUsage({ db, contracts, outbox = null, now = () => Date.now(), log = console }) {
+ if (!outbox) return { enabled: false, finished() {}, flush: () => ({ queued: 0, invalid: 0 }), pending: () => 0 };
+ db.exec(SCHEMA);
+ const q = {
+ add: db.prepare(`INSERT INTO tool_job_usage (project_id, env, capability, dimension, window_start, quantity, errors)
+ VALUES (@project_id, @env, @capability, @dimension, @window_start, 1, @errors)
+ ON CONFLICT(project_id, env, capability, dimension, window_start) DO UPDATE SET
+ quantity = quantity + 1, errors = errors + excluded.errors,
+ revision = revision + (CASE WHEN emitted_at IS NULL THEN 0 ELSE 1 END), emitted_at = NULL`),
+ get: db.prepare('SELECT error_codes, samples FROM tool_job_usage WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ?'),
+ setErrors: db.prepare('UPDATE tool_job_usage SET error_codes = ?, samples = ? WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ?'),
+ due: db.prepare('SELECT * FROM tool_job_usage WHERE emitted_at IS NULL AND window_start <= ? ORDER BY window_start LIMIT 500'),
+ sent: db.prepare('UPDATE tool_job_usage SET emitted_at = ?, event_id = ? WHERE project_id = ? AND env = ? AND capability = ? AND dimension = ? AND window_start = ? AND emitted_at IS NULL'),
+ prune: db.prepare('DELETE FROM tool_job_usage WHERE emitted_at IS NOT NULL AND window_start < ?'),
+ pending: db.prepare('SELECT COUNT(*) AS n FROM tool_job_usage WHERE emitted_at IS NULL'),
+ };
+ const stats = { invalid: 0, lastInvalid: null };
+
+ /** INSIDE the transaction that records the job's end (store.finish), with the row read back. */
+ function finished(row) {
+ const k = keyOf(row);
+ if (!k) return;
+ const failed = row.state === 'failed';
+ q.add.run({ ...k, errors: failed ? 1 : 0 });
+ if (!failed) return;
+ const error = parse(row.error_json, null) || {};
+ const code = CODE_RE.test(String(error.code || '')) ? String(error.code) : 'tools.job.failed';
+ const cur = q.get.get(k.project_id, k.env, k.capability, k.dimension, k.window_start);
+ const codes = parse(cur.error_codes, {});
+ if (codes[code] || Object.keys(codes).length < MAX_CODES) codes[code] = (codes[code] || 0) + 1;
+ const sample = { at: new Date(row.finished_at || now()).toISOString(), code, ref: row.id };
+ if (Number.isInteger(error.status) && error.status >= 100 && error.status <= 599) sample.status = error.status;
+ if (TRACE_RE.test(String(row.trace_id || ''))) sample.trace_id = row.trace_id;
+ const samples = [sample, ...parse(cur.samples, [])].slice(0, MAX_SAMPLES);
+ q.setErrors.run(JSON.stringify(codes), JSON.stringify(samples), k.project_id, k.env, k.capability, k.dimension, k.window_start);
+ }
+
+ function payloadOf(row) {
+ const p = {
+ project_id: row.project_id, env: row.env, capability: row.capability, dimension: row.dimension, unit: 'jobs', window: 'hour',
+ window_start: new Date(row.window_start).toISOString(), window_end: new Date(row.window_start + HOUR_MS).toISOString(),
+ quantity: row.quantity, errors: row.errors,
+ };
+ const codes = parse(row.error_codes, {});
+ if (Object.keys(codes).length) p.error_codes = codes;
+ const samples = parse(row.samples, []);
+ if (samples.length) p.samples = samples;
+ if (row.revision > 1) p.revision = row.revision;
+ return p;
+ }
+
+ /**
+ * Write every closed hour's rollups to the outbox (each in the transaction that marks it sent).
+ * A payload that fails its contract is logged and left unsent, never thrown.
+ */
+ function flush(at = now()) {
+ let queued = 0;
+ for (const row of q.due.all(hourOf(at - GRACE_MS) - HOUR_MS)) {
+ const payload = payloadOf(row);
+ const v = contracts.validate('tools.usage.recorded@1', payload);
+ if (!v.valid) {
+ stats.invalid++;
+ stats.lastInvalid = `${row.project_id} ${row.capability} ${row.dimension}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`;
+ log.error(`[Jobs] tools.usage.recorded not sent (payload does not match the contract): ${stats.lastInvalid}`);
+ continue;
+ }
+ db.transaction(() => {
+ const env = outbox.enqueue({
+ event_type: 'tools.usage.recorded',
+ actor: { type: 'service', id: 'tools' },
+ subject: { type: 'project', id: row.project_id },
+ visibility: 'internal',
+ priority: 'low',
+ payload,
+ });
+ q.sent.run(at, env.event_id, row.project_id, row.env, row.capability, row.dimension, row.window_start);
+ })();
+ queued++;
+ }
+ q.prune.run(at - KEEP_SENT_MS);
+ if (queued) outbox.kick();
+ return { queued, invalid: stats.invalid };
+ }
+
+ return { enabled: true, finished, flush, payloadOf, pending: () => q.pending.get().n, status: () => ({ pending: q.pending.get().n, invalid: stats.invalid, last_invalid: stats.lastInvalid }) };
+}
+
+module.exports = { createJobUsage, keyOf, HOUR_MS, GRACE_MS };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-tools-shared",
"name": "openvibe-tools-shared",
- "version": "1.0.0",
+ "version": "1.1.0",
"private": true,
"private": true,
"description": "The Tools apps' shared runtime (apps/_shared): guard, egress, jobs, uploads, usage, internal auth, release, graceful stop. No dependencies: its modules take packages from the app that requires them.",
"description": "The Tools apps' shared runtime (apps/_shared): guard, egress, jobs, uploads, usage, internal auth, release, graceful stop. No dependencies: its modules take packages from the app that requires them.",
"license": "MIT"
"license": "MIT"
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+// Developer-project usage (WS-N task 4, jobs/usage.js): a project's job that ends is counted in the
+// transaction that records its end, per project, environment, capability, job type or tool and hour,
+// failures by code with their job id and submit trace id; nobody else's jobs and no cancelled job
+// count; each closed hour is written to the outbox once as tools.usage.recorded (valid against
+// openvibe-contracts, no owner, input or file name); a job ending in an hour already sent re-sends it
+// as revision 2; without an outbox nothing is counted.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { dep } = require('./deps');
+
+const Database = dep('better-sqlite3');
+const contracts = dep('openvibe-contracts');
+const sdk = dep('openvibe-sdk');
+const jobs = require('../jobs');
+const { outboxFromEnv } = require('../jobs/events');
+const { keyOf, HOUR_MS } = require('../jobs/usage');
+
+const PRJ = 'prj_01JDDDDDDDDDDDDDDDDDDDDDDD';
+const APP = 'app_01JCCCCCCCCCCCCCCCCCCCCCCC';
+const USER = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
+const TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';
+const SECRET_INPUT = 'secret-input-text-xyzzy';
+const SECRET_FILE = 'holiday-photo-of-alice.txt';
+const silent = { log() {}, warn() {}, error() {} };
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+async function until(fn, what, ms = 5000) {
+ const t0 = Date.now();
+ for (;;) {
+ const v = await fn();
+ if (v) return v;
+ if (Date.now() - t0 > ms) throw new Error(`timed out waiting for ${what}`);
+ await sleep(10);
+ }
+}
+
+const ENV = { EVENTS_URL: 'http://events.test/', OV_OAUTH_CLIENT_SECRET: 'tools-secret', OV_NETWORK_INTERNAL_URL: 'http://network.test' };
+const fakeFetch = async () => new Response('{}', { status: 503, headers: { 'content-type': 'application/json' } });
+
+function define(system) {
+ system.define({
+ type: 'test.upper', maxFiles: 1,
+ async run({ input, outDir, signal }) {
+ if (input.hold) await new Promise((_, rej) => (signal.aborted ? rej(new Error('aborted')) : signal.addEventListener('abort', () => rej(new Error('aborted')))));
+ const out = path.join(outDir, 'secret-output-name.txt');
+ fs.writeFileSync(out, String(input.text || '').toUpperCase());
+ return { files: [{ path: out, name: 'secret-output-name.txt', mime: 'text/plain' }] };
+ },
+ });
+ system.define({
+ type: 'test.boom', maxFiles: 1,
+ async run({ files }) { throw Object.assign(new Error(`Cannot read ${files[0].name}`), { status: 422, code: 'tools.test.unreadable' }); },
+ });
+}
+
+(async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-job-usage-'));
+ try {
+ // ── Without an outbox: nothing counted, no table ──
+ {
+ const db = new Database(':memory:');
+ const dir = path.join(root, 'inert');
+ fs.mkdirSync(dir);
+ const system = jobs.createJobSystem({ db, contracts, service: 'img', dataDir: dir, log: silent });
+ define(system);
+ system.start();
+ const { job } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { text: 'x' }, project: PRJ });
+ await until(() => system.get(job.id).state === 'succeeded', 'inert job');
+ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'tool_job_usage'").get().n, 0);
+ assert.deepStrictEqual(system.stats().usage, { enabled: false });
+ system.stop(); db.close();
+ }
+
+ const dir = path.join(root, 'a');
+ fs.mkdirSync(dir);
+ const db = new Database(path.join(dir, 'jobs.db'));
+ const { outbox } = outboxFromEnv({ db, sdk, env: ENV, fetch: fakeFetch, log: silent });
+ const system = jobs.createJobSystem({ db, contracts, service: 'img', dataDir: dir, concurrency: 1, outbox, progressThrottleMs: 0, log: silent });
+ define(system);
+ system.start();
+ const run = async (o, state) => {
+ const { job } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { text: SECRET_INPUT }, ...o });
+ await until(() => { const r = system.get(job.id); return r && r.state === state; }, `${job.id} ${state}`);
+ return job.id;
+ };
+
+ // ── What counts: a project's ended jobs, per capability, dimension and environment ──
+ await run({ project: PRJ }, 'succeeded');
+ await run({ project: PRJ, traceId: TRACE }, 'succeeded');
+ const failedId = await run({ project: PRJ, type: 'test.boom', traceId: TRACE, files: [{ buffer: Buffer.from('x'), name: SECRET_FILE, mime: 'text/plain' }] }, 'failed');
+ await run({ project: PRJ, env: 'sandbox' }, 'succeeded');
+ await run({ project: PRJ, tool: 'image-resize' }, 'succeeded');
+ await run({ project: null, owner: `user:${USER}` }, 'succeeded'); // a person's job: no project, not counted
+ assert.strictEqual(system.get(failedId).trace_id, TRACE, 'the submit request\'s trace id is kept with the job');
+ const { job: held } = await system.submit({ owner: `app:${APP}`, type: 'test.upper', input: { hold: true }, project: PRJ });
+ await until(() => system.get(held.id).state === 'running', 'held job running');
+ system.cancel(held.id);
+ await until(() => system.get(held.id).state === 'cancelled', 'held job cancelled');
+
+ const rows = db.prepare('SELECT * FROM tool_job_usage ORDER BY capability, dimension, env').all();
+ const view = rows.map(r => [r.capability, r.dimension, r.env, r.quantity, r.errors]);
+ assert.deepStrictEqual(view, [
+ ['tools.job.create', 'test.boom', 'production', 1, 1],
+ ['tools.job.create', 'test.upper', 'production', 2, 0],
+ ['tools.job.create', 'test.upper', 'sandbox', 1, 0],
+ ['tools.tool.run', 'image-resize', 'production', 1, 0],
+ ], 'a cancelled job and a person\'s job are not counted');
+ const boom = rows[0];
+ assert.deepStrictEqual(JSON.parse(boom.error_codes), { 'tools.test.unreadable': 1 });
+ const [sample] = JSON.parse(boom.samples);
+ assert.deepStrictEqual([sample.code, sample.status, sample.trace_id, sample.ref], ['tools.test.unreadable', 422, TRACE, failedId]);
+ assert.strictEqual(keyOf({ ...system.get(failedId), state: 'cancelled' }), null);
+
+ // ── Nothing leaves before the hour closes ──
+ const before = db.prepare("SELECT COUNT(*) AS n FROM event_outbox WHERE envelope LIKE '%tools.usage.recorded%'").get().n;
+ assert.strictEqual(before, 0);
+ assert.strictEqual(system.flushUsage().queued, 0);
+
+ // ── After the hour: one tools.usage.recorded per rollup, once ──
+ const later = rows[0].window_start + HOUR_MS + 2 * 60 * 1000;
+ assert.strictEqual(system.flushUsage(later).queued, 4);
+ assert.strictEqual(system.flushUsage(later).queued, 0, 'sent once');
+ const sent = db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'tools.usage.recorded');
+ assert.strictEqual(sent.length, 4);
+ for (const env of sent) {
+ const v = contracts.validate('tools.usage.recorded@1', env.payload);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.ok(contracts.validate('events.event-envelope@1', env).valid);
+ assert.deepStrictEqual([env.source, env.visibility, env.priority, env.subject.type, env.subject.id, env.actor.id], ['tools', 'internal', 'low', 'project', PRJ, 'tools']);
+ assert.strictEqual(env.payload.unit, 'jobs');
+ assert.strictEqual(Date.parse(env.payload.window_end) - Date.parse(env.payload.window_start), HOUR_MS);
+ }
+ const text = JSON.stringify(sent);
+ for (const s of [APP, USER, SECRET_INPUT, SECRET_FILE, 'secret-output-name', 'Cannot read']) assert.ok(!text.includes(s), `no "${s}" in usage events`);
+ const failedRollup = sent.find(e => e.payload.dimension === 'test.boom').payload;
+ assert.deepStrictEqual([failedRollup.quantity, failedRollup.errors, failedRollup.samples[0].ref], [1, 1, failedId]);
+
+ // ── A job that ends in an hour already sent: re-sent as revision 2 with the new totals ──
+ db.transaction(() => system.usage.finished({ ...system.get(failedId), state: 'succeeded' }))();
+ assert.strictEqual(system.flushUsage(later).queued, 1);
+ const resent = db.prepare('SELECT envelope FROM event_outbox ORDER BY id DESC LIMIT 1').get();
+ const p = JSON.parse(resent.envelope).payload;
+ assert.deepStrictEqual([p.dimension, p.quantity, p.errors, p.revision], ['test.boom', 2, 1, 2]);
+ assert.deepStrictEqual(system.stats().usage, { pending: 0, invalid: 0, last_invalid: null });
+
+ system.stop(); outbox.stop(); db.close();
+ console.log('job usage: all checks passed');
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -411,6 +411,7 @@ function createRunApi(o) {
@@ -411,6 +411,7 @@ function createRunApi(o) {
idempotencyKey: key, ttlMs: who.kind === 'session' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000,
idempotencyKey: key, ttlMs: who.kind === 'session' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000,
env: who.env || 'production', ipKey: who.ipKey, tool: d.id,
env: who.env || 'production', ipKey: who.ipKey, tool: d.id,
project: who.kind === 'principal' && who.claims && who.claims.actor_type === 'app' ? who.claims.project_id : null,
project: who.kind === 'principal' && who.claims && who.claims.actor_type === 'app' ? who.claims.project_id : null,
+ traceId: req.ov && req.ov.traceId,
});
});
row = out.job; replayed = out.replayed;
row = out.job; replayed = out.replayed;
} catch (err) {
} catch (err) {
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -1159,9 +1159,9 @@
@@ -1159,9 +1159,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.63.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
+ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
@@ -1238,9 +1238,9 @@
@@ -1238,9 +1238,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.63.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
+ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
}
}
@@ -1036,9 +1036,9 @@
@@ -1036,9 +1036,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.63.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
+ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
}
}
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -1729,9 +1729,9 @@
@@ -1729,9 +1729,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.63.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
+ "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",