openvibers/openvibe.contracts Commits

v0.32.0: media.job.*@1 payloads are an event projection of the job, not the jobs API answer: id, app_id, object_id, type, status, attempts, max_attempts, error_code, cancel_requested, has_result and ISO 8601 UTC times (run_after, decided_at, created_at, updated_at, started_at, finished_at)

OP OpenVibers committed 23 Sept 2026 · e67de9d670 · 55 files, +967 −1223

As first written (ccfecb7) the seven contracts carried queue.jobPublic: the tenant's params (object.split passes through up to 16 KB of whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler/ffmpeg text), created_by/decided_by and owner_user_id (tenant-local user ids) and the result, which for thumbnail.regenerate is the thumbnail URL even of a private VOD. Events travel beyond the tenant and nothing consumes media.job.* yet, so the payload is narrowed before the tag: those fields are gone (additionalProperties false), error_code keeps the stable code, has_result says whether there is a result to GET, and each description says what is left out and why (GET the job with a tenant token for params/result/error). Times are date-time with a UTC pattern (…Z); SQLite times no longer validate. OpenVibe.Media ad2217b emits this shape (queue.jobEvent).

Per transition, from Media's queue.js: proposed status proposed, no error_code, has_result false, not decided, never started or finished; queued (new or approved) status queued, no error_code, has_result false, never started or finished; started status running, attempts >= 1, run_after cleared, started_at set, has_result false; retrying status queued, run_after and started_at set, has_result false; succeeded no error_code, run_after null, started_at and finished_at set; failed run_after null, started_at and finished_at set, has_result for a partial result; cancelled finished_at set, started_at only when it ran.

Fixtures rewritten: valid ones per transition (a tenant-wide scan, a second attempt keeping the last code, a failure without a code, a queued job cancelled without a reason…) and invalid ones carrying params, idempotency_key, created_by/owner_user_id, the error text, the result, decided_by, a SQLite time, a UTC-offset time, a retrying status. test/run.js checks each schema is exactly the projection, leaves the tenant's data out, has ISO UTC times and finished_at only on outcomes. Checked against payloads Media builds: 25 events over all seven transitions validate. generated/ regenerated; CHANGELOG 0.32.0 describes the projection.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d2706921..1cd76456 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -36,9 +36,18 @@ Additive: `compat.js` reports no breaking change against v0.31.0.
internal): the moderation queue and log, hide and restore. The Tips manifest (0.4.0) lists all three.
- `media.job.proposed|queued|started|retrying|succeeded|failed|cancelled@1`: OpenVibe.Media's job
transitions (`server/events.js` `recordJob`), one contract each like `tools.job.*`. The payload is
-- the job as `GET /api/v2/:app/jobs/:id` answers it (`queue.jobPublic`, every field always present,
-- times as SQLite `YYYY-MM-DD HH:MM:SS` UTC); a result over 8 KB is `{ omitted: true, reason }`.
-- `retrying` carries status `queued`, `started` status `running`. The Media manifest (0.2.0) lists them.
++ an event projection of the job (`queue.jobEvent`), every field always present: `id`, `app_id`,
++ `object_id`, `type`, `status`, `attempts`, `max_attempts`, `error_code` (the stable code only),
++ `cancel_requested`, `has_result`, and `run_after`, `decided_at`, `created_at`, `updated_at`,
++ `started_at`, `finished_at` as ISO 8601 UTC (`…Z`) or null. Deliberately left out, because events
++ travel beyond the tenant: the tenant's `params`, the caller's `idempotency_key`, the free-text
++ `error`, the `result` (a thumbnail URL, of a private VOD too), `created_by`/`decided_by` and
++ `owner_user_id` (tenant-local user ids). A consumer GETs the job with a tenant token for those
++ (`GET /api/v2/:app/jobs/:id`, unchanged); `has_result` says whether there is a result to fetch.
++ `retrying` carries status `queued`, `started` status `running`; `finished_at` is set exactly on
++ `succeeded`, `failed` and `cancelled`, `started_at` on everything that ran, `run_after` always on
++ `retrying`. No service consumed `media.job.*` before this narrowing. The Media manifest (0.2.0)
++ lists them.
- `live.stream.started@1` and `live.stream.ended@1` (public: stream id, channel username, display
name, URL and user subject when Live knows it, title, category or null, protocol, NSFW flag,
times; `ended` adds `ended_at` and `duration_seconds`) and `live.release.deployed@1` (internal:
ddiff --git a/contracts/events/payloads/media.job.cancelled.v1.json b/contracts/events/payloads/media.job.cancelled.v1.json
iindex fc050408..fb232fd4 100644
---- a/contracts/events/payloads/media.job.cancelled.v1.json
++++ b/contracts/events/payloads/media.job.cancelled.v1.json
@@@ -2,32 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.cancelled.v1.json",
"title": "MediaJobCancelledPayload",
-- "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_at set, error_code cancelled when a reason was given); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
"status": { "const": "cancelled" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "attempts": { "type": "integer", "minimum": 0, "description": "Attempts started so far." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it." },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "started_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "null when the job never ran." },
-- "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ "has_result": { "type": "boolean", "description": "A stopped running job left a partial result. The result itself is never in the event: GET the job with a tenant token for it." },
++ "run_after": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "started_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "The first attempt's start; null when the job was cancelled before it ever ran." },
++ "finished_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" }
}
}
ddiff --git a/contracts/events/payloads/media.job.failed.v1.json b/contracts/events/payloads/media.job.failed.v1.json
iindex 2a0acf63..384177ec 100644
---- a/contracts/events/payloads/media.job.failed.v1.json
++++ b/contracts/events/payloads/media.job.failed.v1.json
@@@ -2,32 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.failed.v1.json",
"title": "MediaJobFailedPayload",
-- "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error_code says why when the handler gave a code (GET the job for the message). A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
"status": { "const": "failed" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": "string", "maxLength": 1000, "description": "The failure's message, free text from the handler." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "attempts": { "type": "integer", "minimum": 1, "description": "Attempts started, the last failed one included." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it." },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ "has_result": { "type": "boolean", "description": "The handler left a partial result. The result itself is never in the event: GET the job with a tenant token for it." },
++ "run_after": { "type": "null" },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "started_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "The first attempt's start." },
++ "finished_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" }
}
}
ddiff --git a/contracts/events/payloads/media.job.proposed.v1.json b/contracts/events/payloads/media.job.proposed.v1.json
iindex d9e73f2b..a17e03d0 100644
---- a/contracts/events/payloads/media.job.proposed.v1.json
++++ b/contracts/events/payloads/media.job.proposed.v1.json
@@@ -2,31 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.proposed.v1.json",
"title": "MediaJobProposedPayload",
-- "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
"status": { "const": "proposed" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "attempts": { "type": "integer", "minimum": 0, "description": "Attempts started so far." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": "null" },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "has_result": { "const": false, "description": "A proposal has no result." },
++ "run_after": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "decided_at": { "type": "null", "description": "Nobody has decided yet." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
"started_at": { "type": "null" },
"finished_at": { "type": "null" }
}
ddiff --git a/contracts/events/payloads/media.job.queued.v1.json b/contracts/events/payloads/media.job.queued.v1.json
iindex ac9a505a..5fc32028 100644
---- a/contracts/events/payloads/media.job.queued.v1.json
++++ b/contracts/events/payloads/media.job.queued.v1.json
@@@ -2,31 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.queued.v1.json",
"title": "MediaJobQueuedPayload",
-- "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. A failed attempt that is queued again is media.job.retrying, not this. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
"status": { "const": "queued" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "attempts": { "type": "integer", "minimum": 0, "description": "Attempts started so far." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": "null" },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "has_result": { "const": false, "description": "A job that never ran has no result." },
++ "run_after": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
"started_at": { "type": "null" },
"finished_at": { "type": "null" }
}
ddiff --git a/contracts/events/payloads/media.job.retrying.v1.json b/contracts/events/payloads/media.job.retrying.v1.json
iindex 4fcf4854..c67684bb 100644
---- a/contracts/events/payloads/media.job.retrying.v1.json
++++ b/contracts/events/payloads/media.job.retrying.v1.json
@@@ -2,32 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.retrying.v1.json",
"title": "MediaJobRetryingPayload",
-- "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again (status queued) after a backoff (run_after); error_code says why when the handler gave a code. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
-- "status": { "const": "queued" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": "string", "maxLength": 1000, "description": "The failure's message, free text from the handler." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "status": { "const": "queued", "description": "retrying carries the job's state, queued." },
++ "attempts": { "type": "integer", "minimum": 1, "description": "Attempts started so far, the failed one included." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "Not before this time (the retry backoff)." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it." },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "has_result": { "const": false, "description": "A job waiting for its retry has no result." },
++ "run_after": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "Not before this time (the retry backoff)." },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "started_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "The first attempt's start." },
"finished_at": { "type": "null" }
}
}
ddiff --git a/contracts/events/payloads/media.job.started.v1.json b/contracts/events/payloads/media.job.started.v1.json
iindex a52a90ac..9efeffbf 100644
---- a/contracts/events/payloads/media.job.started.v1.json
++++ b/contracts/events/payloads/media.job.started.v1.json
@@@ -2,32 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.started.v1.json",
"title": "MediaJobStartedPayload",
-- "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running (status running): attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again; error_code is still the previous attempt's until the job finishes. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
-- "status": { "const": "running" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
-- "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
-- "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "status": { "const": "running", "description": "started carries the job's state, running." },
++ "attempts": { "type": "integer", "minimum": 1, "description": "Attempts started so far, this one included." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it." },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "has_result": { "const": false, "description": "A running job has no result yet." },
++ "run_after": { "type": "null", "description": "Cleared when the job is claimed." },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "started_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "The first attempt's start." },
"finished_at": { "type": "null" }
}
}
ddiff --git a/contracts/events/payloads/media.job.succeeded.v1.json b/contracts/events/payloads/media.job.succeeded.v1.json
iindex f75a30f4..97e8d054 100644
---- a/contracts/events/payloads/media.job.succeeded.v1.json
++++ b/contracts/events/payloads/media.job.succeeded.v1.json
@@@ -2,32 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.succeeded.v1.json",
"title": "MediaJobSucceededPayload",
-- "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished. Its result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }) is not in the event: has_result says whether there is one to GET. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
-- "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "required": ["id", "app_id", "object_id", "type", "status", "attempts", "max_attempts", "error_code", "cancel_requested", "has_result", "run_after", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
"additionalProperties": false,
"properties": {
-- "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it." },
"app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
"object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
"type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
"status": { "const": "succeeded" },
-- "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
-- "result": { "type": ["object", "null"], "description": "The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it)." },
-- "error": { "type": "null" },
-- "error_code": { "type": "null" },
-- "attempts": { "type": "integer", "minimum": 0 },
++ "attempts": { "type": "integer", "minimum": 1, "description": "Attempts started, the successful one included." },
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
-- "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "error_code": { "type": "null", "description": "A success clears the last failure." },
"cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
-- "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
-- "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
-- "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
-- "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
-- "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
-- "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ "has_result": { "type": "boolean", "description": "The job holds a result. The result itself is never in the event: GET the job with a tenant token for it." },
++ "run_after": { "type": "null" },
++ "decided_at": { "type": ["string", "null"], "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise." },
++ "created_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "updated_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" },
++ "started_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$", "description": "The first attempt's start." },
++ "finished_at": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$" }
}
}
ddiff --git a/fixtures/media.job.cancelled/invalid/names-the-canceller.json b/fixtures/media.job.cancelled/invalid/names-the-canceller.json
nnew file mode 100644
iindex 00000000..0d657f11
---- /dev/null
++++ b/fixtures/media.job.cancelled/invalid/names-the-canceller.json
@@@ -0,0 +1,19 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "cancelled",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": "cancelled",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": "2026-09-23T18:10:40.000Z",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:10:40.000Z",
++ "started_at": null,
++ "finished_at": "2026-09-23T18:10:40.000Z",
++ "decided_by": "app:live:user:57"
++}
ddiff --git a/fixtures/media.job.cancelled/invalid/unfinished.json b/fixtures/media.job.cancelled/invalid/unfinished.json
nnew file mode 100644
iindex 00000000..6c599365
---- /dev/null
++++ b/fixtures/media.job.cancelled/invalid/unfinished.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "cancelled",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": "cancelled",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": "2026-09-23T18:10:40.000Z",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:10:40.000Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.cancelled/valid/queued-cancelled-without-reason.json b/fixtures/media.job.cancelled/valid/queued-cancelled-without-reason.json
nnew file mode 100644
iindex 00000000..a7a43590
---- /dev/null
++++ b/fixtures/media.job.cancelled/valid/queued-cancelled-without-reason.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "thumbnail.regenerate",
++ "status": "cancelled",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": "2026-09-23T18:02:30.000Z",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:30.000Z",
++ "started_at": null,
++ "finished_at": "2026-09-23T18:02:30.000Z"
++}
ddiff --git a/fixtures/media.job.cancelled/valid/running-stopped.json b/fixtures/media.job.cancelled/valid/running-stopped.json
iindex 7015265d..11219395 100644
---- a/fixtures/media.job.cancelled/valid/running-stopped.json
++++ b/fixtures/media.job.cancelled/valid/running-stopped.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "cancelled",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": "cancelled by its owner",
-- "error_code": "cancelled",
"attempts": 1,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": "cancelled",
"cancel_requested": true,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": "app:live:user:57",
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:04:00",
-- "started_at": "2026-09-23 18:02:12",
-- "finished_at": "2026-09-23 18:04:00"
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:04:00.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:04:00.000Z"
}
ddiff --git a/fixtures/media.job.cancelled/valid/withdrawn-proposal.json b/fixtures/media.job.cancelled/valid/withdrawn-proposal.json
iindex a2cd3993..c6072b61 100644
---- a/fixtures/media.job.cancelled/valid/withdrawn-proposal.json
++++ b/fixtures/media.job.cancelled/valid/withdrawn-proposal.json
@@@ -1,32 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "cancelled",
-- "params": {
-- "source_size_bytes": 9663676416,
-- "target_bytes": 2147483648,
-- "max_bytes": 4294967296,
-- "reason": "public_object_too_large",
-- "parts": 5,
-- "segment_seconds": 2160,
-- "source_duration_seconds": 10800
-- },
-- "result": null,
-- "error": "the object no longer breaks the public size policy",
-- "error_code": "cancelled",
"attempts": 0,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": "cancelled",
"cancel_requested": false,
-- "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
-- "created_by": "system:invariant.scan",
-- "owner_user_id": null,
-- "decided_by": "system:invariant.scan",
-- "decided_at": "2026-09-24 04:00:03",
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-24 04:00:03",
++ "has_result": false,
++ "run_after": null,
++ "decided_at": "2026-09-24T04:00:03.000Z",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-24T04:00:03.000Z",
"started_at": null,
-- "finished_at": "2026-09-24 04:00:03"
++ "finished_at": "2026-09-24T04:00:03.000Z"
}
ddiff --git a/fixtures/media.job.failed/invalid/carries-error-text.json b/fixtures/media.job.failed/invalid/carries-error-text.json
nnew file mode 100644
iindex 00000000..fa7113a8
---- /dev/null
++++ b/fixtures/media.job.failed/invalid/carries-error-text.json
@@@ -0,0 +1,19 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "failed",
++ "attempts": 3,
++ "max_attempts": 3,
++ "error_code": "media_unavailable",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:02:13.000Z",
++ "error": "The source bytes are unavailable (no local file and no cloud copy)"
++}
ddiff --git a/fixtures/media.job.failed/invalid/sqlite-finish-time.json b/fixtures/media.job.failed/invalid/sqlite-finish-time.json
nnew file mode 100644
iindex 00000000..baf723b2
---- /dev/null
++++ b/fixtures/media.job.failed/invalid/sqlite-finish-time.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "failed",
++ "attempts": 3,
++ "max_attempts": 3,
++ "error_code": "media_unavailable",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23 18:02:13"
++}
ddiff --git a/fixtures/media.job.failed/invalid/unfinished.json b/fixtures/media.job.failed/invalid/unfinished.json
iindex d0ccdc4b..d82ac05d 100644
---- a/fixtures/media.job.failed/invalid/unfinished.json
++++ b/fixtures/media.job.failed/invalid/unfinished.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "failed",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": "The source bytes are unavailable (no local file and no cloud copy)",
-- "error_code": "media_unavailable",
"attempts": 3,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": "media_unavailable",
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:13",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.failed/valid/media-unavailable.json b/fixtures/media.job.failed/valid/media-unavailable.json
iindex 69d27b73..a120b082 100644
---- a/fixtures/media.job.failed/valid/media-unavailable.json
++++ b/fixtures/media.job.failed/valid/media-unavailable.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "failed",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": "The source bytes are unavailable (no local file and no cloud copy)",
-- "error_code": "media_unavailable",
"attempts": 3,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": "media_unavailable",
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:13",
-- "started_at": "2026-09-23 18:02:12",
-- "finished_at": "2026-09-23 18:02:13"
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:02:13.000Z"
}
ddiff --git a/fixtures/media.job.failed/valid/no-code-partial-result.json b/fixtures/media.job.failed/valid/no-code-partial-result.json
nnew file mode 100644
iindex 00000000..a7888459
---- /dev/null
++++ b/fixtures/media.job.failed/valid/no-code-partial-result.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "failed",
++ "attempts": 3,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": true,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:12:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:12:13.000Z"
++}
ddiff --git a/fixtures/media.job.proposed/invalid/already-started.json b/fixtures/media.job.proposed/invalid/already-started.json
iindex 9c02148b..56523003 100644
---- a/fixtures/media.job.proposed/invalid/already-started.json
++++ b/fixtures/media.job.proposed/invalid/already-started.json
@@@ -1,32 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "proposed",
-- "params": {
-- "source_size_bytes": 9663676416,
-- "target_bytes": 2147483648,
-- "max_bytes": 4294967296,
-- "reason": "public_object_too_large",
-- "parts": 5,
-- "segment_seconds": 2160,
-- "source_duration_seconds": 10800
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
"attempts": 0,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
-- "created_by": "system:invariant.scan",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:11",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.proposed/invalid/carries-checkpoint.json b/fixtures/media.job.proposed/invalid/carries-checkpoint.json
ddeleted file mode 100644
iindex f606bf8b..00000000
---- a/fixtures/media.job.proposed/invalid/carries-checkpoint.json
++++ /dev/null
@@@ -1,35 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "type": "object.split",
-- "status": "proposed",
-- "params": {
-- "source_size_bytes": 9663676416,
-- "target_bytes": 2147483648,
-- "max_bytes": 4294967296,
-- "reason": "public_object_too_large",
-- "parts": 5,
-- "segment_seconds": 2160,
-- "source_duration_seconds": 10800
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
-- "attempts": 0,
-- "max_attempts": 3,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
-- "created_by": "system:invariant.scan",
-- "owner_user_id": null,
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:11",
-- "started_at": null,
-- "finished_at": null,
-- "checkpoint": {
-- "part": 2
-- }
--}
ddiff --git a/fixtures/media.job.proposed/invalid/carries-creator.json b/fixtures/media.job.proposed/invalid/carries-creator.json
nnew file mode 100644
iindex 00000000..0926557c
---- /dev/null
++++ b/fixtures/media.job.proposed/invalid/carries-creator.json
@@@ -0,0 +1,20 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "proposed",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "created_by": "system:invariant.scan",
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split"
++}
ddiff --git a/fixtures/media.job.proposed/invalid/carries-params.json b/fixtures/media.job.proposed/invalid/carries-params.json
nnew file mode 100644
iindex 00000000..6acd1c1d
---- /dev/null
++++ b/fixtures/media.job.proposed/invalid/carries-params.json
@@@ -0,0 +1,24 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "proposed",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "reason": "public_object_too_large",
++ "parts": 5
++ }
++}
ddiff --git a/fixtures/media.job.proposed/valid/invariant-split.json b/fixtures/media.job.proposed/valid/invariant-split.json
iindex 02958d45..7fb6fc5a 100644
---- a/fixtures/media.job.proposed/valid/invariant-split.json
++++ b/fixtures/media.job.proposed/valid/invariant-split.json
@@@ -1,32 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "proposed",
-- "params": {
-- "source_size_bytes": 9663676416,
-- "target_bytes": 2147483648,
-- "max_bytes": 4294967296,
-- "reason": "public_object_too_large",
-- "parts": 5,
-- "segment_seconds": 2160,
-- "source_duration_seconds": 10800
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
"attempts": 0,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
-- "created_by": "system:invariant.scan",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:11",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
"started_at": null,
"finished_at": null
}
ddiff --git a/fixtures/media.job.proposed/valid/remux-for-a-project.json b/fixtures/media.job.proposed/valid/remux-for-a-project.json
nnew file mode 100644
iindex 00000000..62ac8cfe
---- /dev/null
++++ b/fixtures/media.job.proposed/valid/remux-for-a-project.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.remux",
++ "status": "proposed",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/invalid/already-ran.json b/fixtures/media.job.queued/invalid/already-ran.json
nnew file mode 100644
iindex 00000000..e4abfc28
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/already-ran.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/invalid/carries-idempotency-key.json b/fixtures/media.job.queued/invalid/carries-idempotency-key.json
nnew file mode 100644
iindex 00000000..2db8a326
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/carries-idempotency-key.json
@@@ -0,0 +1,19 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "idempotency_key": "thumb-4211-2026-09-23"
++}
ddiff --git a/fixtures/media.job.queued/invalid/carries-owner-user.json b/fixtures/media.job.queued/invalid/carries-owner-user.json
nnew file mode 100644
iindex 00000000..91c59590
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/carries-owner-user.json
@@@ -0,0 +1,20 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "thumbnail.regenerate",
++ "status": "queued",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null,
++ "created_by": "app:live:user:57",
++ "owner_user_id": 57
++}
ddiff --git a/fixtures/media.job.queued/invalid/iso-timestamps.json b/fixtures/media.job.queued/invalid/iso-timestamps.json
ddeleted file mode 100644
iindex b373b7ed..00000000
---- a/fixtures/media.job.queued/invalid/iso-timestamps.json
++++ /dev/null
@@@ -1,26 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "type": "object.split",
-- "status": "queued",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
-- "attempts": 0,
-- "max_attempts": 3,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23T18:02:11Z",
-- "updated_at": "2026-09-23T18:02:11Z",
-- "started_at": null,
-- "finished_at": null
--}
ddiff --git a/fixtures/media.job.queued/invalid/network-subject-as-owner.json b/fixtures/media.job.queued/invalid/network-subject-as-owner.json
ddeleted file mode 100644
iindex 6b3a604f..00000000
---- a/fixtures/media.job.queued/invalid/network-subject-as-owner.json
++++ /dev/null
@@@ -1,26 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "type": "object.split",
-- "status": "queued",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
-- "attempts": 0,
-- "max_attempts": 3,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:11",
-- "started_at": null,
-- "finished_at": null
--}
ddiff --git a/fixtures/media.job.queued/invalid/sqlite-timestamps.json b/fixtures/media.job.queued/invalid/sqlite-timestamps.json
nnew file mode 100644
iindex 00000000..8da9322d
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/sqlite-timestamps.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/valid/approved-proposal.json b/fixtures/media.job.queued/valid/approved-proposal.json
iindex 042fa21f..16608823 100644
---- a/fixtures/media.job.queued/valid/approved-proposal.json
++++ b/fixtures/media.job.queued/valid/approved-proposal.json
@@@ -1,32 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "queued",
-- "params": {
-- "source_size_bytes": 9663676416,
-- "target_bytes": 2147483648,
-- "max_bytes": 4294967296,
-- "reason": "public_object_too_large",
-- "parts": 5,
-- "segment_seconds": 2160,
-- "source_duration_seconds": 10800
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
"attempts": 0,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
-- "created_by": "system:invariant.scan",
-- "owner_user_id": null,
-- "decided_by": "app:live:user:57",
-- "decided_at": "2026-09-23 18:10:40",
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:10:40",
++ "has_result": false,
++ "run_after": null,
++ "decided_at": "2026-09-23T18:10:40.000Z",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:10:40.000Z",
"started_at": null,
"finished_at": null
}
ddiff --git a/fixtures/media.job.queued/valid/tenant-wide-scan.json b/fixtures/media.job.queued/valid/tenant-wide-scan.json
nnew file mode 100644
iindex 00000000..98150e03
---- /dev/null
++++ b/fixtures/media.job.queued/valid/tenant-wide-scan.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": null,
++ "type": "invariant.scan",
++ "status": "queued",
++ "attempts": 0,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/valid/thumbnail-for-a-user.json b/fixtures/media.job.queued/valid/thumbnail-for-a-user.json
ddeleted file mode 100644
iindex 36c33d81..00000000
---- a/fixtures/media.job.queued/valid/thumbnail-for-a-user.json
++++ /dev/null
@@@ -1,27 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": null,
-- "type": "thumbnail.regenerate",
-- "status": "queued",
-- "params": {
-- "kind": "vod",
-- "id": 4211
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
-- "attempts": 0,
-- "max_attempts": 2,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": "thumb-4211-2026-09-23",
-- "created_by": "app:live:user:57",
-- "owner_user_id": 57,
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:11",
-- "started_at": null,
-- "finished_at": null
--}
ddiff --git a/fixtures/media.job.queued/valid/thumbnail.json b/fixtures/media.job.queued/valid/thumbnail.json
nnew file mode 100644
iindex 00000000..c7fc1ad0
---- /dev/null
++++ b/fixtures/media.job.queued/valid/thumbnail.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "thumbnail.regenerate",
++ "status": "queued",
++ "attempts": 0,
++ "max_attempts": 2,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:11.000Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.retrying/invalid/carries-error-text.json b/fixtures/media.job.retrying/invalid/carries-error-text.json
nnew file mode 100644
iindex 00000000..393d89f9
---- /dev/null
++++ b/fixtures/media.job.retrying/invalid/carries-error-text.json
@@@ -0,0 +1,19 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": "interrupted",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": "2026-09-23T18:05:42.000Z",
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:05:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null,
++ "error": "interrupted by a restart"
++}
ddiff --git a/fixtures/media.job.retrying/invalid/no-backoff.json b/fixtures/media.job.retrying/invalid/no-backoff.json
iindex 9143f723..26bad24a 100644
---- a/fixtures/media.job.retrying/invalid/no-backoff.json
++++ b/fixtures/media.job.retrying/invalid/no-backoff.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "queued",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": "interrupted by a restart",
-- "error_code": "interrupted",
"attempts": 1,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": "interrupted",
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:05:12",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:05:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.retrying/invalid/status-retrying.json b/fixtures/media.job.retrying/invalid/status-retrying.json
nnew file mode 100644
iindex 00000000..5e4978f6
---- /dev/null
++++ b/fixtures/media.job.retrying/invalid/status-retrying.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "retrying",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": "interrupted",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": "2026-09-23T18:05:42.000Z",
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:05:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.retrying/valid/interrupted.json b/fixtures/media.job.retrying/valid/interrupted.json
iindex 6bfa053c..e199ca85 100644
---- a/fixtures/media.job.retrying/valid/interrupted.json
++++ b/fixtures/media.job.retrying/valid/interrupted.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "queued",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": "interrupted by a restart",
-- "error_code": "interrupted",
"attempts": 1,
"max_attempts": 3,
-- "run_after": "2026-09-23 18:05:42",
++ "error_code": "interrupted",
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": "2026-09-23T18:05:42.000Z",
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:05:12",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:05:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.retrying/valid/no-code.json b/fixtures/media.job.retrying/valid/no-code.json
nnew file mode 100644
iindex 00000000..e0c11db7
---- /dev/null
++++ b/fixtures/media.job.retrying/valid/no-code.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": "2026-09-23T18:02:43.000Z",
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:13.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/invalid/no-start-time.json b/fixtures/media.job.started/invalid/no-start-time.json
nnew file mode 100644
iindex 00000000..97b78902
---- /dev/null
++++ b/fixtures/media.job.started/invalid/no-start-time.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "running",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:12.000Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/invalid/offset-time.json b/fixtures/media.job.started/invalid/offset-time.json
nnew file mode 100644
iindex 00000000..459eb826
---- /dev/null
++++ b/fixtures/media.job.started/invalid/offset-time.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "running",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:12.000Z",
++ "started_at": "2026-09-23T20:02:12.000+02:00",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/invalid/wrong-status.json b/fixtures/media.job.started/invalid/wrong-status.json
iindex f58ab3aa..fea60d58 100644
---- a/fixtures/media.job.started/invalid/wrong-status.json
++++ b/fixtures/media.job.started/invalid/wrong-status.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "started",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
"attempts": 1,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:12",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.started/valid/cancel-asked-before-retry.json b/fixtures/media.job.started/valid/cancel-asked-before-retry.json
nnew file mode 100644
iindex 00000000..e214e09a
---- /dev/null
++++ b/fixtures/media.job.started/valid/cancel-asked-before-retry.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "running",
++ "attempts": 2,
++ "max_attempts": 3,
++ "error_code": "media_unavailable",
++ "cancel_requested": true,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:06:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/valid/first-attempt.json b/fixtures/media.job.started/valid/first-attempt.json
iindex 44b3da94..ce2da696 100644
---- a/fixtures/media.job.started/valid/first-attempt.json
++++ b/fixtures/media.job.started/valid/first-attempt.json
@@@ -1,26 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "running",
-- "params": {
-- "parts": 3
-- },
-- "result": null,
-- "error": null,
-- "error_code": null,
"attempts": 1,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": false,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:02:12",
-- "started_at": "2026-09-23 18:02:12",
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
"finished_at": null
}
ddiff --git a/fixtures/media.job.started/valid/second-attempt.json b/fixtures/media.job.started/valid/second-attempt.json
nnew file mode 100644
iindex 00000000..5510afcb
---- /dev/null
++++ b/fixtures/media.job.started/valid/second-attempt.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "running",
++ "attempts": 2,
++ "max_attempts": 3,
++ "error_code": "interrupted",
++ "cancel_requested": false,
++ "has_result": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:06:12.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.succeeded/invalid/carries-result.json b/fixtures/media.job.succeeded/invalid/carries-result.json
nnew file mode 100644
iindex 00000000..a99184c3
---- /dev/null
++++ b/fixtures/media.job.succeeded/invalid/carries-result.json
@@@ -0,0 +1,23 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "thumbnail.regenerate",
++ "status": "succeeded",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": true,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:02:14.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:02:14.000Z",
++ "result": {
++ "url": "/t/vod-4211-1.jpg",
++ "kind": "vod",
++ "id": 4211
++ }
++}
ddiff --git a/fixtures/media.job.succeeded/invalid/with-error-code.json b/fixtures/media.job.succeeded/invalid/with-error-code.json
nnew file mode 100644
iindex 00000000..c2691ae4
---- /dev/null
++++ b/fixtures/media.job.succeeded/invalid/with-error-code.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "succeeded",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": "interrupted",
++ "cancel_requested": false,
++ "has_result": true,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:31:02.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:31:02.000Z"
++}
ddiff --git a/fixtures/media.job.succeeded/invalid/with-error.json b/fixtures/media.job.succeeded/invalid/with-error.json
ddeleted file mode 100644
iindex 02f4dc08..00000000
---- a/fixtures/media.job.succeeded/invalid/with-error.json
++++ /dev/null
@@@ -1,38 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "type": "object.split",
-- "status": "succeeded",
-- "params": {
-- "parts": 3
-- },
-- "result": {
-- "source_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "parts": [
-- {
-- "part": 1,
-- "object_id": "med_01JAB3C4D5E6F7G8H9J0K1MNPR",
-- "start_seconds": 0,
-- "duration_seconds": 3600,
-- "size_bytes": 1932735283
-- }
-- ],
-- "method": "split"
-- },
-- "error": "transient",
-- "error_code": "interrupted",
-- "attempts": 1,
-- "max_attempts": 3,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:31:02",
-- "started_at": "2026-09-23 18:02:12",
-- "finished_at": "2026-09-23 18:31:02"
--}
ddiff --git a/fixtures/media.job.succeeded/invalid/without-has-result.json b/fixtures/media.job.succeeded/invalid/without-has-result.json
nnew file mode 100644
iindex 00000000..39ed7c3b
---- /dev/null
++++ b/fixtures/media.job.succeeded/invalid/without-has-result.json
@@@ -0,0 +1,17 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "succeeded",
++ "attempts": 1,
++ "max_attempts": 3,
++ "error_code": null,
++ "cancel_requested": false,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:31:02.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:31:02.000Z"
++}
ddiff --git a/fixtures/media.job.succeeded/valid/result-omitted.json b/fixtures/media.job.succeeded/valid/result-omitted.json
ddeleted file mode 100644
iindex 54dd6368..00000000
---- a/fixtures/media.job.succeeded/valid/result-omitted.json
++++ /dev/null
@@@ -1,29 +0,0 @@
--{
-- "app_id": "live",
-- "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
-- "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "type": "object.split",
-- "status": "succeeded",
-- "params": {
-- "parts": 3
-- },
-- "result": {
-- "omitted": true,
-- "reason": "larger than the event limit; GET the job"
-- },
-- "error": null,
-- "error_code": null,
-- "attempts": 1,
-- "max_attempts": 3,
-- "run_after": null,
-- "cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
-- "decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:31:02",
-- "started_at": "2026-09-23 18:02:12",
-- "finished_at": "2026-09-23 18:31:02"
--}
ddiff --git a/fixtures/media.job.succeeded/valid/split.json b/fixtures/media.job.succeeded/valid/split.json
iindex 8a6e8cd3..0bc9b243 100644
---- a/fixtures/media.job.succeeded/valid/split.json
++++ b/fixtures/media.job.succeeded/valid/split.json
@@@ -1,40 +1,18 @@
{
-- "app_id": "live",
"id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
"object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"type": "object.split",
"status": "succeeded",
-- "params": {
-- "parts": 3
-- },
-- "result": {
-- "source_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
-- "method": "split",
-- "parts": [
-- {
-- "part": 1,
-- "object_id": "med_01JAB3C4D5E6F7G8H9J0K1MNPR",
-- "start_seconds": 0,
-- "duration_seconds": 3600,
-- "size_bytes": 1932735283
-- }
-- ],
-- "oversized_parts": [],
-- "source_unchanged": true
-- },
-- "error": null,
-- "error_code": null,
"attempts": 1,
"max_attempts": 3,
-- "run_after": null,
++ "error_code": null,
"cancel_requested": false,
-- "idempotency_key": null,
-- "created_by": "svc:live",
-- "owner_user_id": null,
-- "decided_by": null,
++ "has_result": true,
++ "run_after": null,
"decided_at": null,
-- "created_at": "2026-09-23 18:02:11",
-- "updated_at": "2026-09-23 18:31:02",
-- "started_at": "2026-09-23 18:02:12",
-- "finished_at": "2026-09-23 18:31:02"
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:31:02.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:31:02.000Z"
}
ddiff --git a/fixtures/media.job.succeeded/valid/thumbnail-after-a-retry.json b/fixtures/media.job.succeeded/valid/thumbnail-after-a-retry.json
nnew file mode 100644
iindex 00000000..07a70827
---- /dev/null
++++ b/fixtures/media.job.succeeded/valid/thumbnail-after-a-retry.json
@@@ -0,0 +1,18 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "thumbnail.regenerate",
++ "status": "succeeded",
++ "attempts": 2,
++ "max_attempts": 2,
++ "error_code": null,
++ "cancel_requested": false,
++ "has_result": true,
++ "run_after": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11.000Z",
++ "updated_at": "2026-09-23T18:03:02.000Z",
++ "started_at": "2026-09-23T18:02:12.000Z",
++ "finished_at": "2026-09-23T18:03:02.000Z"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 18034154..ab88c492 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -8588,7 +8588,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.proposed.v1.json",
"title": "MediaJobProposedPayload",
-- "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -8596,18 +8596,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -8618,7 +8612,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -8643,97 +8638,49 @@
"status": {
"const": "proposed"
},
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 1000,
-- "description": "The last failure's message (free text), or null."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
-- },
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 0,
++ "description": "Attempts started so far."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "error_code": {
++ "type": "null"
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
-- },
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ "has_result": {
++ "const": false,
++ "description": "A proposal has no result."
},
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
-- },
-- "decided_by": {
++ "run_after": {
"type": [
"string",
"null"
],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
},
"decided_at": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "type": "null",
++ "description": "Nobody has decided yet."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "null"
@@@ -8752,7 +8699,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.queued.v1.json",
"title": "MediaJobQueuedPayload",
-- "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. A failed attempt that is queued again is media.job.retrying, not this. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -8760,18 +8707,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -8782,7 +8723,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -8807,97 +8749,54 @@
"status": {
"const": "queued"
},
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 1000,
-- "description": "The last failure's message (free text), or null."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
-- },
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 0,
++ "description": "Attempts started so far."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "error_code": {
++ "type": "null"
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ "has_result": {
++ "const": false,
++ "description": "A job that never ran has no result."
},
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
-- },
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
-- },
-- "decided_by": {
++ "run_after": {
"type": [
"string",
"null"
],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "null"
@@@ -8916,7 +8815,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.started.v1.json",
"title": "MediaJobStartedPayload",
-- "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running (status running): attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again; error_code is still the previous attempt's until the job finishes. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -8924,18 +8823,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -8946,7 +8839,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -8969,103 +8863,62 @@
"description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
},
"status": {
-- "const": "running"
-- },
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 1000,
-- "description": "The last failure's message (free text), or null."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ "const": "running",
++ "description": "started carries the job's state, running."
},
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 1,
++ "description": "Attempts started so far, this one included."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
++ "error_code": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it."
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
-- },
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
-- },
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ "has_result": {
++ "const": false,
++ "description": "A running job has no result yet."
},
-- "decided_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "run_after": {
++ "type": "null",
++ "description": "Cleared when the job is claimed."
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "The first attempt's start."
},
"finished_at": {
"type": "null"
@@@ -9081,7 +8934,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.retrying.v1.json",
"title": "MediaJobRetryingPayload",
-- "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again (status queued) after a backoff (run_after); error_code says why when the handler gave a code. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -9089,18 +8942,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -9111,7 +8958,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -9134,97 +8982,64 @@
"description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
},
"status": {
-- "const": "queued"
-- },
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": "string",
-- "maxLength": 1000,
-- "description": "The failure's message, free text from the handler."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ "const": "queued",
++ "description": "retrying carries the job's state, queued."
},
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 1,
++ "description": "Attempts started so far, the failed one included."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
-- "type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "Not before this time (the retry backoff)."
-- },
-- "cancel_requested": {
-- "type": "boolean",
-- "description": "Its owner asked a running job to stop."
-- },
-- "idempotency_key": {
++ "error_code": {
"type": [
"string",
"null"
],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it."
},
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
},
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ "has_result": {
++ "const": false,
++ "description": "A job waiting for its retry has no result."
},
-- "decided_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "run_after": {
++ "type": "string",
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "Not before this time (the retry backoff)."
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "The first attempt's start."
},
"finished_at": {
"type": "null"
@@@ -9240,7 +9055,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.succeeded.v1.json",
"title": "MediaJobSucceededPayload",
-- "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished. Its result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }) is not in the event: has_result says whether there is one to GET. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -9248,18 +9063,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -9270,7 +9079,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -9295,96 +9105,60 @@
"status": {
"const": "succeeded"
},
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it)."
-- },
-- "error": {
-- "type": "null"
-- },
-- "error_code": {
-- "type": "null"
-- },
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 1,
++ "description": "Attempts started, the successful one included."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "error_code": {
++ "type": "null",
++ "description": "A success clears the last failure."
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
-- },
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
-- },
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ "has_result": {
++ "type": "boolean",
++ "description": "The job holds a result. The result itself is never in the event: GET the job with a tenant token for it."
},
-- "decided_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "run_after": {
++ "type": "null"
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "The first attempt's start."
},
"finished_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
}
}
}
@@@ -9397,7 +9171,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.failed.v1.json",
"title": "MediaJobFailedPayload",
-- "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error_code says why when the handler gave a code (GET the job for the message). A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -9405,18 +9179,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -9427,7 +9195,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -9452,102 +9221,63 @@
"status": {
"const": "failed"
},
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": "string",
-- "maxLength": 1000,
-- "description": "The failure's message, free text from the handler."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
-- },
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 1,
++ "description": "Attempts started, the last failed one included."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
++ "error_code": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it."
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
-- },
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
-- },
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ "has_result": {
++ "type": "boolean",
++ "description": "The handler left a partial result. The result itself is never in the event: GET the job with a tenant token for it."
},
-- "decided_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "run_after": {
++ "type": "null"
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "The first attempt's start."
},
"finished_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
}
}
}
@@@ -9560,7 +9290,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/media.job.cancelled.v1.json",
"title": "MediaJobCancelledPayload",
-- "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_at set, error_code cancelled when a reason was given); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
"type": "object",
"required": [
"id",
@@@ -9568,18 +9298,12 @@
"object_id",
"type",
"status",
-- "params",
-- "result",
-- "error",
-- "error_code",
"attempts",
"max_attempts",
-- "run_after",
++ "error_code",
"cancel_requested",
-- "idempotency_key",
-- "created_by",
-- "owner_user_id",
-- "decided_by",
++ "has_result",
++ "run_after",
"decided_at",
"created_at",
"updated_at",
@@@ -9590,7 +9314,8 @@
"properties": {
"id": {
"type": "string",
-- "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it."
},
"app_id": {
"type": "string",
@@@ -9615,109 +9340,72 @@
"status": {
"const": "cancelled"
},
-- "params": {
-- "type": "object",
-- "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
-- },
-- "result": {
-- "type": [
-- "object",
-- "null"
-- ],
-- "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
-- },
-- "error": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 1000,
-- "description": "The last failure's message (free text), or null."
-- },
-- "error_code": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
-- },
"attempts": {
"type": "integer",
-- "minimum": 0
++ "minimum": 0,
++ "description": "Attempts started so far."
},
"max_attempts": {
"type": "integer",
"minimum": 1,
"maximum": 10
},
-- "run_after": {
++ "error_code": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "A queued job waits until then (retry backoff); null otherwise."
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it."
},
"cancel_requested": {
"type": "boolean",
"description": "Its owner asked a running job to stop."
},
-- "idempotency_key": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "maxLength": 200,
-- "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
-- },
-- "created_by": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
-- },
-- "owner_user_id": {
-- "type": [
-- "integer",
-- "null"
-- ],
-- "minimum": 1,
-- "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ "has_result": {
++ "type": "boolean",
++ "description": "A stopped running job left a partial result. The result itself is never in the event: GET the job with a tenant token for it."
},
-- "decided_by": {
++ "run_after": {
"type": [
"string",
"null"
],
-- "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
},
"decided_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "When its owner approved it, or cancelled it while it was proposed or queued; null otherwise."
},
"created_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"updated_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
},
"started_at": {
"type": [
"string",
"null"
],
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
-- "description": "null when the job never ran."
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$",
++ "description": "The first attempt's start; null when the job was cancelled before it ever ran."
},
"finished_at": {
"type": "string",
-- "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ "format": "date-time",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,3})?Z$"
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 3ebaf833..f6a4d9de 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -7054,9 +7054,12 @@ export interface LiveReleaseDeployedPayload {
/** [email protected] (owner: media) */
/**
-- * media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ * media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
*/
export interface MediaJobProposedPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7072,48 +7075,27 @@ export interface MediaJobProposedPayload {
type: string;
status: "proposed";
/**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
-- /**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
-- */
-- result: {} | null;
-- /**
-- * The last failure's message (free text), or null.
-- */
-- error: string | null;
-- /**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ * Attempts started so far.
*/
-- error_code: string | null;
attempts: number;
max_attempts: number;
-- /**
-- * A queued job waits until then (retry backoff); null otherwise.
-- */
-- run_after: string | null;
++ error_code: null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ * A proposal has no result.
*/
-- idempotency_key: string | null;
++ has_result: false;
/**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
-- /**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * A queued job waits until then (retry backoff); null otherwise.
*/
-- owner_user_id: number | null;
++ run_after: string | null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * Nobody has decided yet.
*/
-- decided_by: string | null;
-- decided_at: string | null;
++ decided_at: null;
created_at: string;
updated_at: string;
started_at: null;
@@@ -7122,9 +7104,12 @@ export interface MediaJobProposedPayload {
/** [email protected] (owner: media) */
/**
-- * media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ * media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. A failed attempt that is queued again is media.job.retrying, not this. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
*/
export interface MediaJobQueuedPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7140,47 +7125,26 @@ export interface MediaJobQueuedPayload {
type: string;
status: "queued";
/**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
-- /**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ * Attempts started so far.
*/
-- result: {} | null;
-- /**
-- * The last failure's message (free text), or null.
-- */
-- error: string | null;
-- /**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
-- */
-- error_code: string | null;
attempts: number;
max_attempts: number;
-- /**
-- * A queued job waits until then (retry backoff); null otherwise.
-- */
-- run_after: string | null;
++ error_code: null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ * A job that never ran has no result.
*/
-- idempotency_key: string | null;
++ has_result: false;
/**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
-- /**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * A queued job waits until then (retry backoff); null otherwise.
*/
-- owner_user_id: number | null;
++ run_after: string | null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
@@@ -7190,9 +7154,12 @@ export interface MediaJobQueuedPayload {
/** [email protected] (owner: media) */
/**
-- * media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ * media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running (status running): attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again; error_code is still the previous attempt's until the job finishes. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
*/
export interface MediaJobStartedPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7206,61 +7173,52 @@ export interface MediaJobStartedPayload {
* Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
*/
type: string;
-- status: "running";
-- /**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
/**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ * started carries the job's state, running.
*/
-- result: {} | null;
-- /**
-- * The last failure's message (free text), or null.
-- */
-- error: string | null;
++ status: "running";
/**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ * Attempts started so far, this one included.
*/
-- error_code: string | null;
attempts: number;
max_attempts: number;
/**
-- * A queued job waits until then (retry backoff); null otherwise.
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it.
*/
-- run_after: string | null;
++ error_code: string | null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ * A running job has no result yet.
*/
-- idempotency_key: string | null;
-- /**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
++ has_result: false;
/**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * Cleared when the job is claimed.
*/
-- owner_user_id: number | null;
++ run_after: null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
++ /**
++ * The first attempt's start.
++ */
started_at: string;
finished_at: null;
}
/** [email protected] (owner: media) */
/**
-- * media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ * media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again (status queued) after a backoff (run_after); error_code says why when the handler gave a code. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
*/
export interface MediaJobRetryingPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7274,61 +7232,52 @@ export interface MediaJobRetryingPayload {
* Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
*/
type: string;
-- status: "queued";
-- /**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
-- /**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
-- */
-- result: {} | null;
/**
-- * The failure's message, free text from the handler.
++ * retrying carries the job's state, queued.
*/
-- error: string;
++ status: "queued";
/**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ * Attempts started so far, the failed one included.
*/
-- error_code: string | null;
attempts: number;
max_attempts: number;
/**
-- * Not before this time (the retry backoff).
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it.
*/
-- run_after: string;
++ error_code: string | null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ * A job waiting for its retry has no result.
*/
-- idempotency_key: string | null;
-- /**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
++ has_result: false;
/**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * Not before this time (the retry backoff).
*/
-- owner_user_id: number | null;
++ run_after: string;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
++ /**
++ * The first attempt's start.
++ */
started_at: string;
finished_at: null;
}
/** [email protected] (owner: media) */
/**
-- * media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ * media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished. Its result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }) is not in the event: has_result says whether there is one to GET. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
*/
export interface MediaJobSucceededPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7344,53 +7293,44 @@ export interface MediaJobSucceededPayload {
type: string;
status: "succeeded";
/**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
-- /**
-- * The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it).
++ * Attempts started, the successful one included.
*/
-- result: {} | null;
-- error: null;
-- error_code: null;
attempts: number;
max_attempts: number;
/**
-- * A queued job waits until then (retry backoff); null otherwise.
++ * A success clears the last failure.
*/
-- run_after: string | null;
++ error_code: null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
-- */
-- idempotency_key: string | null;
-- /**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
-- /**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * The job holds a result. The result itself is never in the event: GET the job with a tenant token for it.
*/
-- owner_user_id: number | null;
++ has_result: boolean;
++ run_after: null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
++ /**
++ * The first attempt's start.
++ */
started_at: string;
finished_at: string;
}
/** [email protected] (owner: media) */
/**
-- * media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ * media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error_code says why when the handler gave a code (GET the job for the message). A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
*/
export interface MediaJobFailedPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7406,59 +7346,44 @@ export interface MediaJobFailedPayload {
type: string;
status: "failed";
/**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
-- */
-- params: {};
-- /**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ * Attempts started, the last failed one included.
*/
-- result: {} | null;
-- /**
-- * The failure's message, free text from the handler.
-- */
-- error: string;
-- /**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
-- */
-- error_code: string | null;
attempts: number;
max_attempts: number;
/**
-- * A queued job waits until then (retry backoff); null otherwise.
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it.
*/
-- run_after: string | null;
++ error_code: string | null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
-- */
-- idempotency_key: string | null;
-- /**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
-- */
-- created_by: string | null;
-- /**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * The handler left a partial result. The result itself is never in the event: GET the job with a tenant token for it.
*/
-- owner_user_id: number | null;
++ has_result: boolean;
++ run_after: null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
++ /**
++ * The first attempt's start.
++ */
started_at: string;
finished_at: string;
}
/** [email protected] (owner: media) */
/**
-- * media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ * media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_at set, error_code cancelled when a reason was given); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is an event projection of the job (queue.jobEvent): its identity, state, counters, stable error_code, times and has_result. Deliberately left out, because events travel beyond the tenant: the tenant's params (object.split keeps whatever the caller sent), the caller's idempotency_key, the free-text error (raw handler or ffmpeg output), the result (thumbnail.regenerate's is the thumbnail URL, of a private VOD too), created_by/decided_by and owner_user_id (tenant-local user ids), and the handler's checkpoint, lease and request hash. GET the job with a tenant token for params/result/error (GET /api/v2/:app/jobs/:id with a token for app_id; has_result says whether there is a result to fetch). Times are ISO 8601 UTC (YYYY-MM-DDTHH:MM:SS.sssZ) or null. Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
*/
export interface MediaJobCancelledPayload {
++ /**
++ * The job. GET /api/v2/:app/jobs/:id with a token for app_id answers the rest of it.
++ */
id: string;
/**
* The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
@@@ -7474,52 +7399,34 @@ export interface MediaJobCancelledPayload {
type: string;
status: "cancelled";
/**
-- * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ * Attempts started so far.
*/
-- params: {};
-- /**
-- * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
-- */
-- result: {} | null;
-- /**
-- * The last failure's message (free text), or null.
-- */
-- error: string | null;
-- /**
-- * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
-- */
-- error_code: string | null;
attempts: number;
max_attempts: number;
/**
-- * A queued job waits until then (retry backoff); null otherwise.
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null when there was none or the handler gave no code. Never the message: GET the job for it.
*/
-- run_after: string | null;
++ error_code: string | null;
/**
* Its owner asked a running job to stop.
*/
cancel_requested: boolean;
/**
-- * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
-- */
-- idempotency_key: string | null;
-- /**
-- * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ * A stopped running job left a partial result. The result itself is never in the event: GET the job with a tenant token for it.
*/
-- created_by: string | null;
++ has_result: boolean;
/**
-- * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ * A queued job waits until then (retry backoff); null otherwise.
*/
-- owner_user_id: number | null;
++ run_after: string | null;
/**
-- * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ * When its owner approved it, or cancelled it while it was proposed or queued; null otherwise.
*/
-- decided_by: string | null;
decided_at: string | null;
created_at: string;
updated_at: string;
/**
-- * null when the job never ran.
++ * The first attempt's start; null when the job was cancelled before it ever ran.
*/
started_at: string | null;
finished_at: string;
ddiff --git a/test/run.js b/test/run.js
iindex b6182c55..2e43b81c 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -146,13 +146,24 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
// ── Producers' payloads added in v0.32 (Tips moderation, Media jobs, Live, user modules) ──
{
-- // Media: one contract per server/events.js JOB_TRANSITIONS entry; the payload is queue.jobPublic, every field always present.
++ // Media: one contract per server/events.js JOB_TRANSITIONS entry; the payload is the event projection queue.jobEvent,
++ // every field always present. The tenant's params, result, error text, idempotency key and user ids stay behind GET the job.
const status = { proposed: 'proposed', queued: 'queued', started: 'running', retrying: 'queued', succeeded: 'succeeded', failed: 'failed', cancelled: 'cancelled' };
++ const jobFields = ['id', 'app_id', 'object_id', 'type', 'status', 'attempts', 'max_attempts', 'error_code', 'cancel_requested', 'has_result',
++ 'run_after', 'decided_at', 'created_at', 'updated_at', 'started_at', 'finished_at'];
++ const jobTimes = jobFields.filter(k => k === 'run_after' || k.endsWith('_at'));
for (const [t, st] of Object.entries(status)) {
const s = contracts.schema(`media.job.${t}`);
ok(s.properties.status.const === st, `media.job.${t} carries status ${st}`);
ok(JSON.stringify([...s.required].sort()) === JSON.stringify(Object.keys(s.properties).sort()), `media.job.${t} requires every field it names`);
++ ok(JSON.stringify(Object.keys(s.properties)) === JSON.stringify(jobFields) && s.additionalProperties === false, `media.job.${t} is exactly the event projection`);
++ ok(['params', 'result', 'error', 'idempotency_key', 'created_by', 'decided_by', 'owner_user_id', 'checkpoint'].every(k => !(k in s.properties)), `media.job.${t} leaves the tenant's data out`);
++ ok(jobTimes.every(k => s.properties[k].type === 'null' || (s.properties[k].format === 'date-time' && /Z\$$/.test(s.properties[k].pattern))), `media.job.${t} times are ISO 8601 UTC or null`);
++ const outcome = ['succeeded', 'failed', 'cancelled'].includes(t);
++ ok(s.properties.finished_at.type === (outcome ? 'string' : 'null'), `media.job.${t} has finished_at ${outcome ? 'always' : 'never'}`);
}
++ const sqliteTime = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/media.job.queued/valid/thumbnail.json'), 'utf8'));
++ ok(!contracts.validate('media.job.queued@1', { ...sqliteTime, created_at: '2026-09-23 18:02:11' }).valid, 'a SQLite time is not a media.job time');
// Live: live.stream.ended is live.stream.started plus ended_at and duration_seconds.
const started = contracts.schema('live.stream.started'), ended = contracts.schema('live.stream.ended');
ok(JSON.stringify(Object.keys(ended.properties)) === JSON.stringify([...Object.keys(started.properties), 'ended_at', 'duration_seconds']), 'live.stream.ended = started + ended_at, duration_seconds');