proto: answer CORE's freeze-blockers before 1.0.0 lands
Three corrections into 1.0.0, all of which would be major bumps once the contract has landed. It has not: main still carries 1.0.0-draft, so these are corrections to an unpublished version rather than changes to a released one. ADR 0010 records that and the reasoning behind each. B1 — TaskError.code was a bare integer, and the integer space in the contract is JSON-RPC's, which is a different thing; TaskError's own description said so while typing its code as one. Freeze TaskErrorCode: a string enum mirroring vdm::Error by name and in order, all 27 failure values, verified against core/include/vdm/util/error.hpp mechanically. ErrorCode says why a call failed; TaskErrorCode says why a download failed, and a download fails while every RPC succeeds. Adds TaskError.cause so max_retries_exhausted names what kept failing. B2 — TaskSummary.segments is now explicitly the effective count in use right now, after the per-host cap and the non-resumable demotion to 1. DownloadSpec.segments and download.update's patch say they are the requested value. B3 — Segment.endByte's "minimum: 0" contradicted the description's own empty-range encoding of startByte - 1, which is -1 for the first segment of every download. Empty ranges are no longer representable and are not needed. The range stays CLOSED and INCLUSIVE, matching the HTTP Range header the two fields are copied into verbatim, and that is now stated in the schema, the README, an ADR, a fixture assertion and a conformance check. CORE asked for half-open and gets a written notice rather than a silent schema edit. Segment state spells 'downloading' as CORE asked, not 'receiving'. check_contract.py now enforces segment contiguity, coverage of exactly [0, sizeBytes-1], downloadedBytes within the range size, and the entry count matching TaskSummary.segments. The download.get fixture claimed 8 segments while carrying 2; it now carries 8 contiguous ones covering the whole file. contracts/proto-answers-m1.md answers every item in core/docs/proto-requests-m1.md, including the ones not being landed now: B2a and F2 accepted as follow-ups, F1 answered with the notify path for M1, F3 already frozen as a Checksum object rather than a string, and D1 left for DAEMON to draft as the three-way ADR it is. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
+40
-2
@@ -5,8 +5,12 @@ Nobody else commits here. Everybody else *generates from* here.
|
||||
|
||||
> ## Status: **v1.0.0 — FROZEN** (2026-09-09)
|
||||
>
|
||||
> The surface below is complete and generated from: 38 methods, 9 events, 25 named types,
|
||||
> 59 fixtures. See `docs/adr/0005-protocol-1.0.0-freeze.md` for the versioning rule.
|
||||
> The surface below is complete and generated from: 38 methods, 9 events, 26 named types,
|
||||
> 59 fixtures. See `docs/adr/0005-protocol-1.0.0-freeze.md` for the versioning rule and
|
||||
> `docs/adr/0010-...` for the failure taxonomy and the segment range convention.
|
||||
>
|
||||
> Lane requests are answered in writing: `contracts/proto-answers-m1.md` responds to
|
||||
> `core/docs/proto-requests-m1.md` point by point.
|
||||
>
|
||||
> **What each lane can rely on, starting now:**
|
||||
>
|
||||
@@ -140,6 +144,40 @@ methods marked `"privileged": true` in the schema are refused over the WebSocket
|
||||
| `event.settings.changed` | `{keys[]}` |
|
||||
| `event.grabber.progress` | `{jobId, found, crawled, done}` |
|
||||
|
||||
## Two error spaces, and why they are not the same
|
||||
|
||||
This trips people up, so it is stated once, loudly:
|
||||
|
||||
| | `ErrorCode` | `TaskErrorCode` |
|
||||
|---|---|---|
|
||||
| Says | why a **call** failed | why a **download** failed |
|
||||
| Space | JSON-RPC integers (`-32xxx`) | strings (`"server_file_changed"`) |
|
||||
| Lives in | the JSON-RPC envelope's `error` | `TaskError.code`, on a task |
|
||||
| Example | `-32602` — your params were malformed | `checksum_mismatch` — the bytes arrived and were wrong |
|
||||
|
||||
**A download fails while every RPC involved succeeds.** That is the normal case. Never put
|
||||
a `-32xxx` into a `TaskError`, and never invent a JSON-RPC code for a transfer failure.
|
||||
|
||||
`TaskErrorCode`'s 27 values mirror `vdm::Error` in `core/include/vdm/util/error.hpp` by
|
||||
name, so DAEMON's projection from the engine taxonomy is lossless and a new engine failure
|
||||
that has no wire spelling is a visible hole rather than a silent collapse to `internal`.
|
||||
|
||||
## Segment ranges are inclusive
|
||||
|
||||
`Segment.startByte` and `Segment.endByte` describe a **closed** range `[startByte,
|
||||
endByte]`: `endByte` is the last byte, not one past it, and the segment covers
|
||||
`endByte - startByte + 1` bytes. The two fields are copied verbatim into
|
||||
`Range: bytes=<startByte>-<endByte>`, which RFC 9110 defines as inclusive, so there is no
|
||||
arithmetic between the wire and the socket and nowhere for an off-by-one to hide.
|
||||
Conformance enforces contiguity and full coverage; a fixture written half-open fails.
|
||||
|
||||
## Requested is not effective
|
||||
|
||||
`DownloadSpec.segments` is what a client **asked for**. `TaskSummary.segments` is what is
|
||||
**in use right now**, after the per-host cap and after the demotion to 1 for a
|
||||
non-resumable source. They are routinely different and the GUI must render the effective
|
||||
one.
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"endByte": 778567679,
|
||||
"downloadedBytes": 402653184,
|
||||
"speedBps": 4089446,
|
||||
"state": "receiving",
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
@@ -51,7 +51,61 @@
|
||||
"endByte": 1557135359,
|
||||
"downloadedBytes": 356515840,
|
||||
"speedBps": 3565158,
|
||||
"state": "receiving",
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"startByte": 1557135360,
|
||||
"endByte": 2335703039,
|
||||
"downloadedBytes": 377487360,
|
||||
"speedBps": 3774874,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"startByte": 2335703040,
|
||||
"endByte": 3114270719,
|
||||
"downloadedBytes": 367001600,
|
||||
"speedBps": 3670016,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"startByte": 3114270720,
|
||||
"endByte": 3892838399,
|
||||
"downloadedBytes": 356515840,
|
||||
"speedBps": 3565158,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"startByte": 3892838400,
|
||||
"endByte": 4671406079,
|
||||
"downloadedBytes": 377487360,
|
||||
"speedBps": 3774874,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"startByte": 4671406080,
|
||||
"endByte": 5449973759,
|
||||
"downloadedBytes": 356515840,
|
||||
"speedBps": 3565158,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"startByte": 5449973760,
|
||||
"endByte": 6228541439,
|
||||
"downloadedBytes": 347013120,
|
||||
"speedBps": 3791872,
|
||||
"state": "downloading",
|
||||
"httpStatus": 206
|
||||
}
|
||||
],
|
||||
@@ -71,7 +125,9 @@
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"segment ranges are contiguous and cover exactly [0, sizeBytes)",
|
||||
"segment ranges are contiguous and cover exactly [0, sizeBytes) with no gaps or overlaps",
|
||||
"startByte and endByte are both INCLUSIVE: segment 0 here covers 778567680 bytes, 0 through 778567679, and is copied verbatim into 'Range: bytes=0-778567679'",
|
||||
"segmentDetail has exactly summary.segments entries",
|
||||
"the GUI draws one bar per entry and is never told what a segment steal is"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event.task.state \u2014 a task fails on a dead link",
|
||||
"description": "Carries the summary so the row repaints in full, and the error whenever the new state is failed or retry_wait.",
|
||||
"name": "event.task.state \u2014 a task fails because the file vanished from the server",
|
||||
"description": "Carries the summary so the row repaints in full, and the error whenever the new state is failed or retry_wait. Note that error.code is a TaskErrorCode string, not a JSON-RPC code: the RPC that reported this failure succeeded.",
|
||||
"notification": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event.task.state",
|
||||
@@ -29,19 +29,21 @@
|
||||
"lastTryAt": "$isoDate",
|
||||
"completedAt": null,
|
||||
"error": {
|
||||
"code": -32013,
|
||||
"message": "HTTP 404 on resume",
|
||||
"code": "not_found",
|
||||
"message": "the server returned 404 when resuming; the file is gone",
|
||||
"httpStatus": 404,
|
||||
"retryable": false,
|
||||
"cause": null,
|
||||
"attempt": 3,
|
||||
"nextRetryAt": null
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"code": -32013,
|
||||
"message": "HTTP 404 on resume",
|
||||
"code": "not_found",
|
||||
"message": "the server returned 404 when resuming; the file is gone",
|
||||
"httpStatus": 404,
|
||||
"retryable": false,
|
||||
"cause": null,
|
||||
"attempt": 3,
|
||||
"nextRetryAt": null
|
||||
}
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"assertions": [
|
||||
"error is present exactly when state is failed or retry_wait",
|
||||
"error.code is a TaskErrorCode, never a JSON-RPC ErrorCode \u2014 the two are different spaces",
|
||||
"retryable false means the scheduler will not pick this up again on its own"
|
||||
]
|
||||
}
|
||||
|
||||
+139
-19
@@ -486,8 +486,10 @@
|
||||
"null"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 32
|
||||
}
|
||||
"maximum": 32,
|
||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload."
|
||||
},
|
||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload."
|
||||
},
|
||||
{
|
||||
"name": "bufferBytes",
|
||||
@@ -1357,7 +1359,7 @@
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "Takes effect on the next start; a running task is not re-segmented underneath the user."
|
||||
"description": "The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented underneath the user."
|
||||
},
|
||||
"bufferBytes": {
|
||||
"type": [
|
||||
@@ -3128,7 +3130,8 @@
|
||||
"null"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 32
|
||||
"maximum": 32,
|
||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload."
|
||||
},
|
||||
"bufferBytes": {
|
||||
"type": [
|
||||
@@ -3672,7 +3675,7 @@
|
||||
"title": "Schedule"
|
||||
},
|
||||
"Segment": {
|
||||
"description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and never learns what a segment steal is.",
|
||||
"description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and is never told what a segment steal is.\n\nRANGE CONVENTION \u2014 READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and endByte is the index of the LAST byte in the range, not one past it. This deliberately matches the HTTP Range header the engine actually sends ('Range: bytes=<startByte>-<endByte>' is a byte-for-byte copy of these two fields, and RFC 9110 ranges are inclusive), so no arithmetic happens between the wire and the socket and there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose inclusive for that reason and this note exists so nobody discovers the difference at integration. A segment always covers at least one byte: endByte >= startByte always holds. An empty range is not representable and is not needed \u2014 a zero-length download carries an empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the bytes it already wrote.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
@@ -3686,20 +3689,23 @@
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 31
|
||||
"maximum": 31,
|
||||
"description": "Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there is no 'i' spelling anywhere in the contract."
|
||||
},
|
||||
"startByte": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"description": "Absolute offset of the first byte of the range. Inclusive."
|
||||
},
|
||||
"endByte": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Inclusive. Equal to startByte - 1 for an empty segment."
|
||||
"description": "Absolute offset of the LAST byte of the range. Inclusive \u2014 this is not one-past-the-end. Always >= startByte."
|
||||
},
|
||||
"downloadedBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"minimum": 0,
|
||||
"description": "Bytes written for this range so far, out of endByte - startByte + 1."
|
||||
},
|
||||
"speedBps": {
|
||||
"type": "integer",
|
||||
@@ -3710,17 +3716,21 @@
|
||||
"enum": [
|
||||
"pending",
|
||||
"connecting",
|
||||
"receiving",
|
||||
"downloading",
|
||||
"stalled",
|
||||
"complete",
|
||||
"failed"
|
||||
]
|
||||
],
|
||||
"description": "'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has been planned but not yet dialled."
|
||||
},
|
||||
"httpStatus": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"minimum": 100,
|
||||
"maximum": 599,
|
||||
"description": "The status this segment's request got. 206 on a healthy ranged fetch."
|
||||
}
|
||||
},
|
||||
"title": "Segment"
|
||||
@@ -3994,7 +4004,8 @@
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Segment"
|
||||
}
|
||||
},
|
||||
"description": "Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps and no overlaps. Empty for a zero-length download, and empty before the task has been segmented."
|
||||
},
|
||||
"headers": {
|
||||
"oneOf": [
|
||||
@@ -4071,7 +4082,7 @@
|
||||
"title": "TaskDetail"
|
||||
},
|
||||
"TaskError": {
|
||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download.",
|
||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download \u2014 the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
@@ -4081,19 +4092,35 @@
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
"$ref": "#/components/schemas/TaskErrorCode"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots."
|
||||
},
|
||||
"httpStatus": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"minimum": 100,
|
||||
"maximum": 599,
|
||||
"description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise."
|
||||
},
|
||||
"retryable": {
|
||||
"type": "boolean"
|
||||
"type": "boolean",
|
||||
"description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit."
|
||||
},
|
||||
"cause": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TaskErrorCode"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up."
|
||||
},
|
||||
"attempt": {
|
||||
"type": [
|
||||
@@ -4113,6 +4140,99 @@
|
||||
},
|
||||
"title": "TaskError"
|
||||
},
|
||||
"TaskErrorCode": {
|
||||
"description": "Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a *download* failed. A task can fail while every RPC involved succeeded. The values mirror vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming or removing one is major, and would desynchronise the engine.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"canceled",
|
||||
"resolve_failed",
|
||||
"connect_failed",
|
||||
"tls_failed",
|
||||
"connection_reset",
|
||||
"timeout",
|
||||
"too_many_redirects",
|
||||
"http_client_error",
|
||||
"http_server_error",
|
||||
"auth_required",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"range_not_satisfiable",
|
||||
"gone",
|
||||
"server_file_changed",
|
||||
"content_length_mismatch",
|
||||
"checksum_mismatch",
|
||||
"disk_full",
|
||||
"io_error",
|
||||
"path_rejected",
|
||||
"permission_denied",
|
||||
"meta_corrupt",
|
||||
"meta_version_unsupported",
|
||||
"probe_failed",
|
||||
"unsupported_url_scheme",
|
||||
"max_retries_exhausted",
|
||||
"internal"
|
||||
],
|
||||
"x-groups": {
|
||||
"cancellation": [
|
||||
"canceled"
|
||||
],
|
||||
"network": [
|
||||
"resolve_failed",
|
||||
"connect_failed",
|
||||
"tls_failed",
|
||||
"connection_reset",
|
||||
"timeout",
|
||||
"too_many_redirects"
|
||||
],
|
||||
"http": [
|
||||
"http_client_error",
|
||||
"http_server_error",
|
||||
"auth_required",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"range_not_satisfiable",
|
||||
"gone"
|
||||
],
|
||||
"content": [
|
||||
"server_file_changed",
|
||||
"content_length_mismatch",
|
||||
"checksum_mismatch"
|
||||
],
|
||||
"localIo": [
|
||||
"disk_full",
|
||||
"io_error",
|
||||
"path_rejected",
|
||||
"permission_denied"
|
||||
],
|
||||
"resumeMetadata": [
|
||||
"meta_corrupt",
|
||||
"meta_version_unsupported"
|
||||
],
|
||||
"probe": [
|
||||
"probe_failed",
|
||||
"unsupported_url_scheme"
|
||||
],
|
||||
"retry": [
|
||||
"max_retries_exhausted"
|
||||
],
|
||||
"internal": [
|
||||
"internal"
|
||||
]
|
||||
},
|
||||
"x-carriesHttpStatus": [
|
||||
"http_client_error",
|
||||
"http_server_error",
|
||||
"auth_required",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"range_not_satisfiable",
|
||||
"gone",
|
||||
"server_file_changed",
|
||||
"probe_failed",
|
||||
"max_retries_exhausted"
|
||||
],
|
||||
"title": "TaskErrorCode"
|
||||
},
|
||||
"TaskFilter": {
|
||||
"description": "Which rows download.list returns. This is the category tree and the All/Unfinished/Finished nodes, expressed on the wire. Absent clauses are not constraints.",
|
||||
"type": "object",
|
||||
@@ -4292,7 +4412,7 @@
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "Connection count. Per-segment detail lives in TaskDetail."
|
||||
"description": "The EFFECTIVE connection count in use right now \u2014 not the number that was requested. It is what remains after the per-host connection cap has been applied and after the demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on legitimately reports 4, or 1. The GUI displays this value and must not assume it equals what download.add asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this type. TaskDetail.segmentDetail always has exactly this many entries."
|
||||
},
|
||||
"categoryId": {
|
||||
"type": [
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# PROTO → CORE — answers to `core/docs/proto-requests-m1.md`
|
||||
|
||||
Status: **answered**. Against `contracts/` at **1.0.0** (`lane/proto`, not yet on `main`).
|
||||
Raised by CORE at `1.0.0-draft`; every freeze-blocker is resolved below.
|
||||
|
||||
Read `docs/adr/0010-task-error-taxonomy-and-segment-ranges.md` for the reasoning on
|
||||
B1/B2/B3. This file is the index and the parts CORE has to act on.
|
||||
|
||||
---
|
||||
|
||||
## Freeze-blockers — all three are in 1.0.0
|
||||
|
||||
### B1 — a frozen wire enum for the task failure code · **done, as a string enum**
|
||||
|
||||
`TaskError.code` was a bare `integer`. It is now
|
||||
[`TaskErrorCode`](schema/types/TaskErrorCode.schema.json): a **string enum with your 27
|
||||
failure values, mirrored by name and in your order**, verified against
|
||||
`core/include/vdm/util/error.hpp` mechanically rather than by eye. `ok` has no wire
|
||||
spelling — a `TaskError` only exists when something failed.
|
||||
|
||||
You were right that this was blocker #1, and right about the diagnosis: the draft typed
|
||||
`code` as the JSON-RPC integer while its own description said "distinct from the JSON-RPC
|
||||
Error". Those are two code spaces. `ErrorCode` says why a **call** failed; `TaskErrorCode`
|
||||
says why a **download** failed, and a download fails while every RPC succeeds.
|
||||
|
||||
Strings, not your grouped-integer fallback: the mapping is lossless with no numbering
|
||||
scheme maintained in two repos that cannot include each other's headers, and a log line
|
||||
reads `"server_file_changed"` instead of `407`.
|
||||
|
||||
Two details you should design against:
|
||||
|
||||
* **`retryable` stays a per-occurrence boolean**, not a property of the code — because your
|
||||
own table has `probe_failed` as "maybe". Emit it per failure.
|
||||
* **`cause`** is new on `TaskError` and carries a `TaskErrorCode`. It exists for
|
||||
`max_retries_exhausted`, which your header says has a `cause`: put the last underlying
|
||||
`Error` there so the user is told what actually kept failing.
|
||||
|
||||
`httpStatus` is expected for the codes in `TaskErrorCode`'s `x-carriesHttpStatus`
|
||||
annotation, which matches the "carries httpStatus" column of your table.
|
||||
|
||||
### B2 — the meaning of `TaskSummary.segments` · **done, frozen as effective**
|
||||
|
||||
> "The **EFFECTIVE** connection count in use right now — not the number that was
|
||||
> requested. What remains after the per-host connection cap and after the demotion to 1
|
||||
> for a non-resumable source."
|
||||
|
||||
The requested value stays in `DownloadSpec.segments`, which now says so on its own
|
||||
description, as does `download.update`'s `patch.segments`. `TaskDetail.segmentDetail`
|
||||
carries exactly `TaskSummary.segments` entries, and conformance checks that.
|
||||
|
||||
### B3 — `Segment` field names, and the range convention · **done, but read this**
|
||||
|
||||
**(a) `index` vs `i`** — settled as `index`, everywhere. There is no `i` spelling in the
|
||||
contract; `event.task.progress`'s per-segment entries use `index` too. Nothing to
|
||||
reconcile, it was already consistent.
|
||||
|
||||
**(c) the state enum** — `pending | connecting | downloading | stalled | complete | failed`.
|
||||
Spelled **`downloading`** as you asked, matching `TaskState`; the draft's `receiving` is
|
||||
gone. `pending` is added for a range planned but not yet dialled — if the engine never
|
||||
reports that, ignore it.
|
||||
|
||||
**(b) the range convention — this is the one that will bite you if you skim.**
|
||||
|
||||
> ### Ranges are CLOSED and INCLUSIVE: `[startByte, endByte]`.
|
||||
> `endByte` is the index of the **last byte**, not one past it.
|
||||
> The segment covers `endByte - startByte + 1` bytes.
|
||||
|
||||
You asked for half-open `[start, end)`. **PROTO chose inclusive and did not adopt your
|
||||
convention** — this notice is the point of this document, and it is deliberately before you
|
||||
build stage 6.
|
||||
|
||||
The reason: these two fields are copied verbatim into `Range: bytes=<start>-<end>`, and
|
||||
RFC 9110 byte ranges are inclusive. Inclusive means no arithmetic at all between the wire
|
||||
and the socket. Half-open means a `-1` at every boundary between the contract and every
|
||||
HTTP request the engine makes — which is exactly where off-by-ones live.
|
||||
|
||||
Field names stayed `startByte` / `endByte` / `downloadedBytes` rather than your
|
||||
`start` / `end` / `completed`, partly so that code written against the half-open spelling
|
||||
does not silently compile against inclusive fields.
|
||||
|
||||
While fixing this we found a real contradiction in the draft: it encoded an empty segment
|
||||
as `endByte == startByte - 1`, which is `-1` at offset 0 — and every download's first
|
||||
segment starts at 0, so the schema's own `minimum: 0` rejected it. **Empty ranges are no
|
||||
longer representable and are not needed.** `endByte >= startByte` always holds; a
|
||||
zero-length download carries an empty `segmentDetail`; a segment that donates its remainder
|
||||
to a steal keeps the bytes it already wrote. If the engine has a state that genuinely needs
|
||||
an empty range, say so now — that is a schema change, not something to encode around.
|
||||
|
||||
`tests/conformance/check_contract.py` enforces contiguity, coverage of exactly
|
||||
`[0, sizeBytes - 1]`, `downloadedBytes <= endByte - startByte + 1`, and the entry count.
|
||||
A fixture flipped to half-open fails it.
|
||||
|
||||
---
|
||||
|
||||
## Not gating the freeze — the follow-up queue
|
||||
|
||||
Agreed with your ranking: these are minor under rule 4 and land as small PRs to
|
||||
`contracts/` alone. They are **not** in 1.0.0. Ranked by when M1 needs them.
|
||||
|
||||
| # | Item | Verdict | Shape |
|
||||
|---|---|---|---|
|
||||
| **B2a** | readable effective buffer size | **accepted** | `effectiveBufferBytes` on `TaskSummary`, next to the effective segment count, so the requested/effective split reads the same way for both. You are right about the `additionalProperties: false` trap — no daemon can tack it on, so it needs a schema PR either way. |
|
||||
| **F2** | credential return path for 401/407 | **accepted as proposed** | `download.provideAuth {taskId, username, password, save?}` → `{ok}`. Unix socket only, privileged: a credential-bearing method must never be reachable from the browser. Secrets go to the Secret Service; `save` only tells DAEMON whether to persist. |
|
||||
| **F1** | "needs user decision" carrier | **the simple option** | `state: paused` + `event.notify` is the intended carrier for M1: CORE reports `server_file_changed`, DAEMON pauses and notifies, GUI offers restart. A dedicated `event.task.decision` + `download.decide` is a real design with a state machine attached, and it should not be invented in a hurry — raise it again in M3 if the notify path proves too thin. A string comparison on `error.code` covers the engine side either way, which is now a `TaskErrorCode` comparison rather than a magic number. |
|
||||
| **F3** | `checksum` string format | **already frozen, differently** | `download.add {checksum}` is **not** a string. It is a `Checksum` object: `{algorithm: "md5"\|"sha1"\|"sha256"\|"sha512", value: "<hex>"}`, with `value` patterned `^[0-9a-fA-F]{32,128}$`. Parse your `"<algo>:<hex>"` form at the CLI or GUI edge, not on the wire. Note `sha512` is accepted by the contract even though the appendix lists MD5/SHA-256 — reject it in the engine if you do not implement it, rather than the contract forbidding it. |
|
||||
|
||||
Raise B2a and F2 as requests whenever you need them and PROTO will land them together;
|
||||
neither blocks anything you are building this week.
|
||||
|
||||
---
|
||||
|
||||
## D1 — state-machine ownership
|
||||
|
||||
Your proposed split is right and PROTO does not dispute any of it: CORE owns
|
||||
`probing → connecting → downloading ⇄ paused → retry_wait → assembling → verifying →
|
||||
complete | failed` plus `cancelled` from anywhere; DAEMON owns `new`, `queued` and
|
||||
pause-for-schedule; `paused` is shared and both sides must be idempotent about it.
|
||||
|
||||
PROTO will not write that ADR alone. It is a three-way decision and the lane that owns
|
||||
neither half writing it down is how a decision gets recorded that DAEMON never agreed to.
|
||||
**DAEMON should draft it, CORE and PROTO review.** The contract's part is already frozen:
|
||||
`TaskState` has the twelve values, and the wire does not encode who drove a transition.
|
||||
|
||||
One thing that *is* PROTO's and worth stating: `event.task.state` carries `previousState`,
|
||||
so a client can render a transition without keeping its own state machine. Neither CORE nor
|
||||
DAEMON should assume a client tracks lifecycle — clients render what they are told.
|
||||
@@ -4,34 +4,99 @@
|
||||
"title": "download.update",
|
||||
"description": "Change a task's mutable fields. Moving saveDir or filename moves the file on disk in the same operation, which is what makes dragging a row onto a category work as one RPC. Privileged: it can name a destination path.",
|
||||
"x-privileged": true,
|
||||
"x-transports": ["uds"],
|
||||
"x-transports": [
|
||||
"uds"
|
||||
],
|
||||
"x-deadlineMs": 30000,
|
||||
"x-errors": [-32003, -32010, -32011],
|
||||
"x-errors": [
|
||||
-32003,
|
||||
-32010,
|
||||
-32011
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["taskId", "patch"],
|
||||
"required": [
|
||||
"taskId",
|
||||
"patch"
|
||||
],
|
||||
"properties": {
|
||||
"taskId": { "type": "string", "format": "uuid" },
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"patch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Only the present fields change. An explicit null clears a nullable field.",
|
||||
"properties": {
|
||||
"filename": { "type": ["string", "null"], "maxLength": 255 },
|
||||
"saveDir": { "type": ["string", "null"] },
|
||||
"categoryId": { "type": ["string", "null"] },
|
||||
"queueId": { "type": ["string", "null"] },
|
||||
"description": { "type": ["string", "null"], "maxLength": 1024 },
|
||||
"segments": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "Takes effect on the next start; a running task is not re-segmented underneath the user." },
|
||||
"bufferBytes": { "type": ["integer", "null"], "minimum": 4096, "maximum": 8388608 },
|
||||
"checksum": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Checksum.schema.json" }, { "type": "null" }] }
|
||||
"filename": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255
|
||||
},
|
||||
"saveDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"categoryId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"queueId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1024
|
||||
},
|
||||
"segments": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented underneath the user."
|
||||
},
|
||||
"bufferBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
},
|
||||
"checksum": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/Checksum.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"result": { "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" }
|
||||
"result": {
|
||||
"$ref": "https://velox.dev/schema/types/TaskSummary.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,110 @@
|
||||
"description": "Everything needed to create one task. Shared by download.add and each item of download.addBatch, so the two can never drift apart.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url"],
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] },
|
||||
"cookies": { "type": ["array", "null"], "items": { "$ref": "https://velox.dev/schema/types/Cookie.schema.json" } },
|
||||
"referrer": { "type": ["string", "null"] },
|
||||
"userAgent": { "type": ["string", "null"] },
|
||||
"filename": { "type": ["string", "null"], "maxLength": 255, "description": "Overrides the name derived from Content-Disposition or the URL." },
|
||||
"saveDir": { "type": ["string", "null"], "description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails." },
|
||||
"categoryId": { "type": ["string", "null"], "description": "null means the rules engine picks one." },
|
||||
"queueId": { "type": ["string", "null"], "description": "Required when startMode is 'queue'." },
|
||||
"segments": { "type": ["integer", "null"], "minimum": 1, "maximum": 32 },
|
||||
"bufferBytes": { "type": ["integer", "null"], "minimum": 4096, "maximum": 8388608 },
|
||||
"startMode": { "$ref": "https://velox.dev/schema/types/StartMode.schema.json" },
|
||||
"description": { "type": ["string", "null"], "maxLength": 1024 },
|
||||
"checksum": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Checksum.schema.json" }, { "type": "null" }] }
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"headers": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/Headers.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "https://velox.dev/schema/types/Cookie.schema.json"
|
||||
}
|
||||
},
|
||||
"referrer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"userAgent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"filename": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255,
|
||||
"description": "Overrides the name derived from Content-Disposition or the URL."
|
||||
},
|
||||
"saveDir": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Canonicalized and checked against the allowed roots before any write. -32011 if it fails."
|
||||
},
|
||||
"categoryId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "null means the rules engine picks one."
|
||||
},
|
||||
"queueId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Required when startMode is 'queue'."
|
||||
},
|
||||
"segments": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the per-host cap, and to 1 when the source turns out not to be resumable. What is actually in use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload."
|
||||
},
|
||||
"bufferBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
},
|
||||
"startMode": {
|
||||
"$ref": "https://velox.dev/schema/types/StartMode.schema.json"
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1024
|
||||
},
|
||||
"checksum": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/Checksum.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://velox.dev/schema/types/Segment.schema.json",
|
||||
"title": "Segment",
|
||||
"description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and never learns what a segment steal is.",
|
||||
"description": "One byte range being fetched by one connection. This is the deepest the contract ever exposes the engine: the GUI draws a bar per segment and is never told what a segment steal is.\n\nRANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and endByte is the index of the LAST byte in the range, not one past it. This deliberately matches the HTTP Range header the engine actually sends ('Range: bytes=<startByte>-<endByte>' is a byte-for-byte copy of these two fields, and RFC 9110 ranges are inclusive), so no arithmetic happens between the wire and the socket and there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose inclusive for that reason and this note exists so nobody discovers the difference at integration. A segment always covers at least one byte: endByte >= startByte always holds. An empty range is not representable and is not needed — a zero-length download carries an empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the bytes it already wrote.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["index", "startByte", "endByte", "downloadedBytes", "state"],
|
||||
"properties": {
|
||||
"index": { "type": "integer", "minimum": 0, "maximum": 31 },
|
||||
"startByte": { "type": "integer", "minimum": 0 },
|
||||
"endByte": { "type": "integer", "minimum": 0, "description": "Inclusive. Equal to startByte - 1 for an empty segment." },
|
||||
"downloadedBytes": { "type": "integer", "minimum": 0 },
|
||||
"index": { "type": "integer", "minimum": 0, "maximum": 31, "description": "Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there is no 'i' spelling anywhere in the contract." },
|
||||
"startByte": { "type": "integer", "minimum": 0, "description": "Absolute offset of the first byte of the range. Inclusive." },
|
||||
"endByte": { "type": "integer", "minimum": 0, "description": "Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end. Always >= startByte." },
|
||||
"downloadedBytes": { "type": "integer", "minimum": 0, "description": "Bytes written for this range so far, out of endByte - startByte + 1." },
|
||||
"speedBps": { "type": "integer", "minimum": 0 },
|
||||
"state": { "type": "string", "enum": ["pending", "connecting", "receiving", "stalled", "complete", "failed"] },
|
||||
"httpStatus": { "type": ["integer", "null"] }
|
||||
"state": { "type": "string", "enum": ["pending", "connecting", "downloading", "stalled", "complete", "failed"], "description": "'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has been planned but not yet dialled." },
|
||||
"httpStatus": { "type": ["integer", "null"], "minimum": 100, "maximum": 599, "description": "The status this segment's request got. 206 on a healthy ranged fetch." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,92 @@
|
||||
"description": "Everything TaskSummary carries, plus what only the progress dialog and the File Info dialog need. Returned by download.get; never sent in a list or an event, because it is expensive to build.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["summary", "segmentDetail"],
|
||||
"required": [
|
||||
"summary",
|
||||
"segmentDetail"
|
||||
],
|
||||
"properties": {
|
||||
"summary": { "$ref": "https://velox.dev/schema/types/TaskSummary.schema.json" },
|
||||
"summary": {
|
||||
"$ref": "https://velox.dev/schema/types/TaskSummary.schema.json"
|
||||
},
|
||||
"segmentDetail": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": { "$ref": "https://velox.dev/schema/types/Segment.schema.json" }
|
||||
"items": {
|
||||
"$ref": "https://velox.dev/schema/types/Segment.schema.json"
|
||||
},
|
||||
"description": "Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps and no overlaps. Empty for a zero-length download, and empty before the task has been segmented."
|
||||
},
|
||||
"headers": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Headers.schema.json" }, { "type": "null" }] },
|
||||
"referrer": { "type": ["string", "null"] },
|
||||
"userAgent": { "type": ["string", "null"] },
|
||||
"mime": { "type": ["string", "null"] },
|
||||
"bufferBytes": { "type": ["integer", "null"], "minimum": 4096, "maximum": 8388608 },
|
||||
"partPath": { "type": ["string", "null"], "description": "Absolute path of the .veloxpart file while the task is unfinished." },
|
||||
"checksum": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/Checksum.schema.json" }, { "type": "null" }] },
|
||||
"checksumVerified": { "type": ["boolean", "null"], "description": "null until the verifying state has run." },
|
||||
"averageSpeedBps": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"retryCount": { "type": "integer", "minimum": 0 }
|
||||
"headers": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/Headers.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"referrer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"userAgent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mime": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"bufferBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 4096,
|
||||
"maximum": 8388608
|
||||
},
|
||||
"partPath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Absolute path of the .veloxpart file while the task is unfinished."
|
||||
},
|
||||
"checksum": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/Checksum.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"checksumVerified": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "null until the verifying state has run."
|
||||
},
|
||||
"averageSpeedBps": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"retryCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://velox.dev/schema/types/TaskError.schema.json",
|
||||
"title": "TaskError",
|
||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download.",
|
||||
"description": "Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which describes a failed call rather than a failed download — the two live in different code spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message", "retryable"],
|
||||
"properties": {
|
||||
"code": { "type": "integer" },
|
||||
"message": { "type": "string" },
|
||||
"httpStatus": { "type": ["integer", "null"] },
|
||||
"retryable": { "type": "boolean" },
|
||||
"code": { "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" },
|
||||
"message": { "type": "string", "description": "Human-readable, safe to show a user. Never carries a credential, a token or a full local path outside the download roots." },
|
||||
"httpStatus": { "type": ["integer", "null"], "minimum": 100, "maximum": 599, "description": "Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise." },
|
||||
"retryable": { "type": "boolean", "description": "Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather than derived from the code, because 'probe_failed' is retryable or not depending on what the probe hit." },
|
||||
"cause": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskErrorCode.schema.json" }, { "type": "null" }], "description": "The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever the last attempt actually failed with, so a user learns the reason rather than just that Velox gave up." },
|
||||
"attempt": { "type": ["integer", "null"], "minimum": 0, "description": "How many attempts have been made so far." },
|
||||
"nextRetryAt":{ "type": ["string", "null"], "format": "date-time" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://velox.dev/schema/types/TaskErrorCode.schema.json",
|
||||
"title": "TaskErrorCode",
|
||||
"description": "Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a *download* failed. A task can fail while every RPC involved succeeded. The values mirror vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming or removing one is major, and would desynchronise the engine.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"canceled",
|
||||
|
||||
"resolve_failed",
|
||||
"connect_failed",
|
||||
"tls_failed",
|
||||
"connection_reset",
|
||||
"timeout",
|
||||
"too_many_redirects",
|
||||
|
||||
"http_client_error",
|
||||
"http_server_error",
|
||||
"auth_required",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"range_not_satisfiable",
|
||||
"gone",
|
||||
|
||||
"server_file_changed",
|
||||
"content_length_mismatch",
|
||||
"checksum_mismatch",
|
||||
|
||||
"disk_full",
|
||||
"io_error",
|
||||
"path_rejected",
|
||||
"permission_denied",
|
||||
|
||||
"meta_corrupt",
|
||||
"meta_version_unsupported",
|
||||
|
||||
"probe_failed",
|
||||
"unsupported_url_scheme",
|
||||
|
||||
"max_retries_exhausted",
|
||||
|
||||
"internal"
|
||||
],
|
||||
"x-groups": {
|
||||
"cancellation": ["canceled"],
|
||||
"network": ["resolve_failed", "connect_failed", "tls_failed", "connection_reset", "timeout", "too_many_redirects"],
|
||||
"http": ["http_client_error", "http_server_error", "auth_required", "forbidden", "not_found", "range_not_satisfiable", "gone"],
|
||||
"content": ["server_file_changed", "content_length_mismatch", "checksum_mismatch"],
|
||||
"localIo": ["disk_full", "io_error", "path_rejected", "permission_denied"],
|
||||
"resumeMetadata": ["meta_corrupt", "meta_version_unsupported"],
|
||||
"probe": ["probe_failed", "unsupported_url_scheme"],
|
||||
"retry": ["max_retries_exhausted"],
|
||||
"internal": ["internal"]
|
||||
},
|
||||
"x-carriesHttpStatus": [
|
||||
"http_client_error", "http_server_error", "auth_required", "forbidden", "not_found",
|
||||
"range_not_satisfiable", "gone", "server_file_changed", "probe_failed", "max_retries_exhausted"
|
||||
]
|
||||
}
|
||||
@@ -5,28 +5,134 @@
|
||||
"description": "One row of the main download list. Everything the GUI table needs, and nothing more. TaskDetail is the same shape plus the fields only the progress dialog and File Info dialog need.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["taskId", "filename", "saveDir", "url", "state", "downloadedBytes",
|
||||
"speedBps", "resumable", "segments", "createdAt"],
|
||||
"required": [
|
||||
"taskId",
|
||||
"filename",
|
||||
"saveDir",
|
||||
"url",
|
||||
"state",
|
||||
"downloadedBytes",
|
||||
"speedBps",
|
||||
"resumable",
|
||||
"segments",
|
||||
"createdAt"
|
||||
],
|
||||
"properties": {
|
||||
"taskId": { "type": "string", "format": "uuid" },
|
||||
"filename": { "type": "string", "maxLength": 255 },
|
||||
"saveDir": { "type": "string", "description": "Absolute, canonicalized, inside an allowed root." },
|
||||
"url": { "type": "string", "format": "uri", "description": "The URL as the user or the extension supplied it." },
|
||||
"effectiveUrl": { "type": ["string", "null"], "format": "uri", "description": "After redirects. null until the first probe succeeds." },
|
||||
"sizeBytes": { "type": ["integer", "null"], "minimum": 0, "description": "null when the server did not report a length." },
|
||||
"downloadedBytes": { "type": "integer", "minimum": 0 },
|
||||
"state": { "$ref": "https://velox.dev/schema/types/TaskState.schema.json" },
|
||||
"speedBps": { "type": "integer", "minimum": 0 },
|
||||
"etaSeconds": { "type": ["integer", "null"], "minimum": 0, "description": "null when the size or the speed is unknown." },
|
||||
"resumable": { "type": "boolean" },
|
||||
"segments": { "type": "integer", "minimum": 1, "maximum": 32, "description": "Connection count. Per-segment detail lives in TaskDetail." },
|
||||
"categoryId": { "type": ["string", "null"] },
|
||||
"queueId": { "type": ["string", "null"] },
|
||||
"queuePosition":{ "type": ["integer", "null"], "minimum": 0, "description": "The Q column." },
|
||||
"description": { "type": ["string", "null"], "maxLength": 1024 },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"lastTryAt": { "type": ["string", "null"], "format": "date-time" },
|
||||
"completedAt": { "type": ["string", "null"], "format": "date-time" },
|
||||
"error": { "oneOf": [{ "$ref": "https://velox.dev/schema/types/TaskError.schema.json" }, { "type": "null" }] }
|
||||
"taskId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
},
|
||||
"saveDir": {
|
||||
"type": "string",
|
||||
"description": "Absolute, canonicalized, inside an allowed root."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "The URL as the user or the extension supplied it."
|
||||
},
|
||||
"effectiveUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "uri",
|
||||
"description": "After redirects. null until the first probe succeeds."
|
||||
},
|
||||
"sizeBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"description": "null when the server did not report a length."
|
||||
},
|
||||
"downloadedBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"state": {
|
||||
"$ref": "https://velox.dev/schema/types/TaskState.schema.json"
|
||||
},
|
||||
"speedBps": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"etaSeconds": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"description": "null when the size or the speed is unknown."
|
||||
},
|
||||
"resumable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"segments": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "The EFFECTIVE connection count in use right now \u2014 not the number that was requested. It is what remains after the per-host connection cap has been applied and after the demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on legitimately reports 4, or 1. The GUI displays this value and must not assume it equals what download.add asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this type. TaskDetail.segmentDetail always has exactly this many entries."
|
||||
},
|
||||
"categoryId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"queueId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"queuePosition": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"description": "The Q column."
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1024
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"lastTryAt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"completedAt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"error": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "https://velox.dev/schema/types/TaskError.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ std::string_view to_string(SegmentState v) noexcept {
|
||||
switch (v) {
|
||||
case SegmentState::Pending: return "pending";
|
||||
case SegmentState::Connecting: return "connecting";
|
||||
case SegmentState::Receiving: return "receiving";
|
||||
case SegmentState::Downloading: return "downloading";
|
||||
case SegmentState::Stalled: return "stalled";
|
||||
case SegmentState::Complete: return "complete";
|
||||
case SegmentState::Failed: return "failed";
|
||||
@@ -362,7 +362,7 @@ std::string_view to_string(SegmentState v) noexcept {
|
||||
Result<SegmentState> parse_SegmentState(std::string_view s) {
|
||||
if (s == "pending") return SegmentState::Pending;
|
||||
if (s == "connecting") return SegmentState::Connecting;
|
||||
if (s == "receiving") return SegmentState::Receiving;
|
||||
if (s == "downloading") return SegmentState::Downloading;
|
||||
if (s == "stalled") return SegmentState::Stalled;
|
||||
if (s == "complete") return SegmentState::Complete;
|
||||
if (s == "failed") return SegmentState::Failed;
|
||||
@@ -591,6 +591,79 @@ template <> Result<SettingsSaveToFileExistsPolicy> parse<SettingsSaveToFileExist
|
||||
return *r;
|
||||
}
|
||||
|
||||
std::string_view to_string(TaskErrorCode v) noexcept {
|
||||
switch (v) {
|
||||
case TaskErrorCode::Canceled: return "canceled";
|
||||
case TaskErrorCode::ResolveFailed: return "resolve_failed";
|
||||
case TaskErrorCode::ConnectFailed: return "connect_failed";
|
||||
case TaskErrorCode::TlsFailed: return "tls_failed";
|
||||
case TaskErrorCode::ConnectionReset: return "connection_reset";
|
||||
case TaskErrorCode::Timeout: return "timeout";
|
||||
case TaskErrorCode::TooManyRedirects: return "too_many_redirects";
|
||||
case TaskErrorCode::HttpClientError: return "http_client_error";
|
||||
case TaskErrorCode::HttpServerError: return "http_server_error";
|
||||
case TaskErrorCode::AuthRequired: return "auth_required";
|
||||
case TaskErrorCode::Forbidden: return "forbidden";
|
||||
case TaskErrorCode::NotFound: return "not_found";
|
||||
case TaskErrorCode::RangeNotSatisfiable: return "range_not_satisfiable";
|
||||
case TaskErrorCode::Gone: return "gone";
|
||||
case TaskErrorCode::ServerFileChanged: return "server_file_changed";
|
||||
case TaskErrorCode::ContentLengthMismatch: return "content_length_mismatch";
|
||||
case TaskErrorCode::ChecksumMismatch: return "checksum_mismatch";
|
||||
case TaskErrorCode::DiskFull: return "disk_full";
|
||||
case TaskErrorCode::IoError: return "io_error";
|
||||
case TaskErrorCode::PathRejected: return "path_rejected";
|
||||
case TaskErrorCode::PermissionDenied: return "permission_denied";
|
||||
case TaskErrorCode::MetaCorrupt: return "meta_corrupt";
|
||||
case TaskErrorCode::MetaVersionUnsupported: return "meta_version_unsupported";
|
||||
case TaskErrorCode::ProbeFailed: return "probe_failed";
|
||||
case TaskErrorCode::UnsupportedUrlScheme: return "unsupported_url_scheme";
|
||||
case TaskErrorCode::MaxRetriesExhausted: return "max_retries_exhausted";
|
||||
case TaskErrorCode::Internal: return "internal";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
Result<TaskErrorCode> parse_TaskErrorCode(std::string_view s) {
|
||||
if (s == "canceled") return TaskErrorCode::Canceled;
|
||||
if (s == "resolve_failed") return TaskErrorCode::ResolveFailed;
|
||||
if (s == "connect_failed") return TaskErrorCode::ConnectFailed;
|
||||
if (s == "tls_failed") return TaskErrorCode::TlsFailed;
|
||||
if (s == "connection_reset") return TaskErrorCode::ConnectionReset;
|
||||
if (s == "timeout") return TaskErrorCode::Timeout;
|
||||
if (s == "too_many_redirects") return TaskErrorCode::TooManyRedirects;
|
||||
if (s == "http_client_error") return TaskErrorCode::HttpClientError;
|
||||
if (s == "http_server_error") return TaskErrorCode::HttpServerError;
|
||||
if (s == "auth_required") return TaskErrorCode::AuthRequired;
|
||||
if (s == "forbidden") return TaskErrorCode::Forbidden;
|
||||
if (s == "not_found") return TaskErrorCode::NotFound;
|
||||
if (s == "range_not_satisfiable") return TaskErrorCode::RangeNotSatisfiable;
|
||||
if (s == "gone") return TaskErrorCode::Gone;
|
||||
if (s == "server_file_changed") return TaskErrorCode::ServerFileChanged;
|
||||
if (s == "content_length_mismatch") return TaskErrorCode::ContentLengthMismatch;
|
||||
if (s == "checksum_mismatch") return TaskErrorCode::ChecksumMismatch;
|
||||
if (s == "disk_full") return TaskErrorCode::DiskFull;
|
||||
if (s == "io_error") return TaskErrorCode::IoError;
|
||||
if (s == "path_rejected") return TaskErrorCode::PathRejected;
|
||||
if (s == "permission_denied") return TaskErrorCode::PermissionDenied;
|
||||
if (s == "meta_corrupt") return TaskErrorCode::MetaCorrupt;
|
||||
if (s == "meta_version_unsupported") return TaskErrorCode::MetaVersionUnsupported;
|
||||
if (s == "probe_failed") return TaskErrorCode::ProbeFailed;
|
||||
if (s == "unsupported_url_scheme") return TaskErrorCode::UnsupportedUrlScheme;
|
||||
if (s == "max_retries_exhausted") return TaskErrorCode::MaxRetriesExhausted;
|
||||
if (s == "internal") return TaskErrorCode::Internal;
|
||||
return std::unexpected(ParseError{"", "not a valid TaskErrorCode: '" + std::string(s) + "'"});
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const TaskErrorCode& v) { j = to_string(v); }
|
||||
|
||||
template <> Result<TaskErrorCode> parse<TaskErrorCode>(const nlohmann::json& j, std::string_view path) {
|
||||
if (!j.is_string()) return std::unexpected(ParseError{std::string(path), "expected a string"});
|
||||
auto r = parse_TaskErrorCode(j.get_ref<const std::string&>());
|
||||
if (!r) return std::unexpected(ParseError{std::string(path), r.error().message});
|
||||
return *r;
|
||||
}
|
||||
|
||||
std::string_view to_string(TaskSortDirection v) noexcept {
|
||||
switch (v) {
|
||||
case TaskSortDirection::Asc: return "asc";
|
||||
@@ -2304,6 +2377,8 @@ template <> Result<Segment> parse<Segment>(const nlohmann::json& j, std::string_
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 100) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 100"});
|
||||
if (val > 599) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 599"});
|
||||
out.httpStatus = std::move(val);
|
||||
}
|
||||
}
|
||||
@@ -2799,6 +2874,7 @@ void to_json(nlohmann::json& j, const TaskError& v) {
|
||||
j["message"] = v.message;
|
||||
if (v.httpStatus.has_value()) j["httpStatus"] = *v.httpStatus;
|
||||
j["retryable"] = v.retryable;
|
||||
if (v.cause.has_value()) j["cause"] = *v.cause;
|
||||
if (v.attempt.has_value()) j["attempt"] = *v.attempt;
|
||||
if (v.nextRetryAt.has_value()) j["nextRetryAt"] = *v.nextRetryAt;
|
||||
}
|
||||
@@ -2811,8 +2887,9 @@ template <> Result<TaskError> parse<TaskError>(const nlohmann::json& j, std::str
|
||||
const auto it = j.find("code");
|
||||
if (it == j.end() || it->is_null())
|
||||
return std::unexpected(ParseError{fp, "required field is missing"});
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
auto val_r = parse<TaskErrorCode>((*it), fp);
|
||||
if (!val_r) return std::unexpected(val_r.error());
|
||||
auto val = std::move(*val_r);
|
||||
out.code = std::move(val);
|
||||
}
|
||||
{
|
||||
@@ -2830,6 +2907,8 @@ template <> Result<TaskError> parse<TaskError>(const nlohmann::json& j, std::str
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
if (!(*it).is_number_integer()) return std::unexpected(ParseError{std::string(fp), "expected an integer"});
|
||||
auto val = (*it).get<std::int64_t>();
|
||||
if (val < 100) return std::unexpected(ParseError{std::string(fp), "value is below the minimum of 100"});
|
||||
if (val > 599) return std::unexpected(ParseError{std::string(fp), "value is above the maximum of 599"});
|
||||
out.httpStatus = std::move(val);
|
||||
}
|
||||
}
|
||||
@@ -2842,6 +2921,16 @@ template <> Result<TaskError> parse<TaskError>(const nlohmann::json& j, std::str
|
||||
auto val = (*it).get<bool>();
|
||||
out.retryable = std::move(val);
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "cause");
|
||||
const auto it = j.find("cause");
|
||||
if (it != j.end() && !it->is_null()) {
|
||||
auto val_r = parse<TaskErrorCode>((*it), fp);
|
||||
if (!val_r) return std::unexpected(val_r.error());
|
||||
auto val = std::move(*val_r);
|
||||
out.cause = std::move(val);
|
||||
}
|
||||
}
|
||||
{
|
||||
const std::string fp = join(path, "attempt");
|
||||
const auto it = j.find("attempt");
|
||||
|
||||
@@ -181,10 +181,12 @@ enum class RuleActionCapture {
|
||||
std::string_view to_string(RuleActionCapture v) noexcept;
|
||||
Result<RuleActionCapture> parse_RuleActionCapture(std::string_view s);
|
||||
|
||||
/// 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has
|
||||
/// been planned but not yet dialled.
|
||||
enum class SegmentState {
|
||||
Pending, // "pending"
|
||||
Connecting, // "connecting"
|
||||
Receiving, // "receiving"
|
||||
Downloading, // "downloading"
|
||||
Stalled, // "stalled"
|
||||
Complete, // "complete"
|
||||
Failed, // "failed"
|
||||
@@ -279,6 +281,46 @@ enum class SettingsSaveToFileExistsPolicy {
|
||||
std::string_view to_string(SettingsSaveToFileExistsPolicy v) noexcept;
|
||||
Result<SettingsSaveToFileExistsPolicy> parse_SettingsSaveToFileExistsPolicy(std::string_view s);
|
||||
|
||||
/// Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the
|
||||
/// JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a
|
||||
/// *download* failed. A task can fail while every RPC involved succeeded. The values mirror
|
||||
/// vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's projection
|
||||
/// from the engine taxonomy onto the wire is lossless and the GUI can tell 'the file on the
|
||||
/// server changed' from 'the checksum did not match'. CORE's 'ok' has no wire spelling: a
|
||||
/// TaskError only exists when there is a failure. Adding a value here is a minor bump; renaming
|
||||
/// or removing one is major, and would desynchronise the engine.
|
||||
enum class TaskErrorCode {
|
||||
Canceled, // "canceled"
|
||||
ResolveFailed, // "resolve_failed"
|
||||
ConnectFailed, // "connect_failed"
|
||||
TlsFailed, // "tls_failed"
|
||||
ConnectionReset, // "connection_reset"
|
||||
Timeout, // "timeout"
|
||||
TooManyRedirects, // "too_many_redirects"
|
||||
HttpClientError, // "http_client_error"
|
||||
HttpServerError, // "http_server_error"
|
||||
AuthRequired, // "auth_required"
|
||||
Forbidden, // "forbidden"
|
||||
NotFound, // "not_found"
|
||||
RangeNotSatisfiable, // "range_not_satisfiable"
|
||||
Gone, // "gone"
|
||||
ServerFileChanged, // "server_file_changed"
|
||||
ContentLengthMismatch, // "content_length_mismatch"
|
||||
ChecksumMismatch, // "checksum_mismatch"
|
||||
DiskFull, // "disk_full"
|
||||
IoError, // "io_error"
|
||||
PathRejected, // "path_rejected"
|
||||
PermissionDenied, // "permission_denied"
|
||||
MetaCorrupt, // "meta_corrupt"
|
||||
MetaVersionUnsupported, // "meta_version_unsupported"
|
||||
ProbeFailed, // "probe_failed"
|
||||
UnsupportedUrlScheme, // "unsupported_url_scheme"
|
||||
MaxRetriesExhausted, // "max_retries_exhausted"
|
||||
Internal, // "internal"
|
||||
};
|
||||
std::string_view to_string(TaskErrorCode v) noexcept;
|
||||
Result<TaskErrorCode> parse_TaskErrorCode(std::string_view s);
|
||||
|
||||
enum class TaskSortDirection {
|
||||
Asc, // "asc"
|
||||
Desc, // "desc"
|
||||
@@ -489,6 +531,9 @@ struct DownloadSpec {
|
||||
std::optional<std::string> categoryId{};
|
||||
/// Required when startMode is 'queue'.
|
||||
std::optional<std::string> queueId{};
|
||||
/// The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to the
|
||||
/// per-host cap, and to 1 when the source turns out not to be resumable. What is actually in
|
||||
/// use comes back as TaskSummary.segments. null means use connection.maxSegmentsPerDownload.
|
||||
std::optional<std::int64_t> segments{};
|
||||
std::optional<std::int64_t> bufferBytes{};
|
||||
std::optional<StartMode> startMode{};
|
||||
@@ -602,16 +647,35 @@ struct Rule {
|
||||
};
|
||||
|
||||
/// One byte range being fetched by one connection. This is the deepest the contract ever
|
||||
/// exposes the engine: the GUI draws a bar per segment and never learns what a segment steal
|
||||
/// is.
|
||||
/// exposes the engine: the GUI draws a bar per segment and is never told what a segment steal
|
||||
/// is. RANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and INCLUSIVE on
|
||||
/// both ends: [startByte, endByte]. The segment covers endByte - startByte + 1 bytes, and
|
||||
/// endByte is the index of the LAST byte in the range, not one past it. This deliberately
|
||||
/// matches the HTTP Range header the engine actually sends ('Range:
|
||||
/// bytes=<startByte>-<endByte>' is a byte-for-byte copy of these two fields, and RFC 9110
|
||||
/// ranges are inclusive), so no arithmetic happens between the wire and the socket and there is
|
||||
/// nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO chose
|
||||
/// inclusive for that reason and this note exists so nobody discovers the difference at
|
||||
/// integration. A segment always covers at least one byte: endByte >= startByte always holds.
|
||||
/// An empty range is not representable and is not needed — a zero-length download carries an
|
||||
/// empty segmentDetail array, and a segment that has donated its remainder to a steal keeps the
|
||||
/// bytes it already wrote.
|
||||
struct Segment {
|
||||
/// Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress; there
|
||||
/// is no 'i' spelling anywhere in the contract.
|
||||
std::int64_t index{};
|
||||
/// Absolute offset of the first byte of the range. Inclusive.
|
||||
std::int64_t startByte{};
|
||||
/// Inclusive. Equal to startByte - 1 for an empty segment.
|
||||
/// Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end.
|
||||
/// Always >= startByte.
|
||||
std::int64_t endByte{};
|
||||
/// Bytes written for this range so far, out of endByte - startByte + 1.
|
||||
std::int64_t downloadedBytes{};
|
||||
std::optional<std::int64_t> speedBps{};
|
||||
/// 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has
|
||||
/// been planned but not yet dialled.
|
||||
SegmentState state{};
|
||||
/// The status this segment's request got. 206 on a healthy ranged fetch.
|
||||
std::optional<std::int64_t> httpStatus{};
|
||||
};
|
||||
|
||||
@@ -668,12 +732,23 @@ struct Settings {
|
||||
};
|
||||
|
||||
/// Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which
|
||||
/// describes a failed call rather than a failed download.
|
||||
/// describes a failed call rather than a failed download — the two live in different code
|
||||
/// spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.
|
||||
struct TaskError {
|
||||
std::int64_t code{};
|
||||
TaskErrorCode code{};
|
||||
/// Human-readable, safe to show a user. Never carries a credential, a token or a full local
|
||||
/// path outside the download roots.
|
||||
std::string message{};
|
||||
/// Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise.
|
||||
std::optional<std::int64_t> httpStatus{};
|
||||
/// Whether the scheduler will pick this task up again on its own. Carried per-occurrence rather
|
||||
/// than derived from the code, because 'probe_failed' is retryable or not depending on what the
|
||||
/// probe hit.
|
||||
bool retryable{};
|
||||
/// The underlying failure, for codes that wrap one. max_retries_exhausted sets it to whatever
|
||||
/// the last attempt actually failed with, so a user learns the reason rather than just that
|
||||
/// Velox gave up.
|
||||
std::optional<TaskErrorCode> cause{};
|
||||
/// How many attempts have been made so far.
|
||||
std::optional<std::int64_t> attempt{};
|
||||
std::optional<std::string> nextRetryAt{};
|
||||
@@ -699,7 +774,12 @@ struct TaskSummary {
|
||||
/// null when the size or the speed is unknown.
|
||||
std::optional<std::int64_t> etaSeconds{};
|
||||
bool resumable{};
|
||||
/// Connection count. Per-segment detail lives in TaskDetail.
|
||||
/// The EFFECTIVE connection count in use right now — not the number that was requested. It is
|
||||
/// what remains after the per-host connection cap has been applied and after the demotion to 1
|
||||
/// for a non-resumable source, so a task the user asked for 16 connections on legitimately
|
||||
/// reports 4, or 1. The GUI displays this value and must not assume it equals what download.add
|
||||
/// asked for. The requested value lives in DownloadSpec.segments and is not echoed back on this
|
||||
/// type. TaskDetail.segmentDetail always has exactly this many entries.
|
||||
std::int64_t segments{};
|
||||
std::optional<std::string> categoryId{};
|
||||
std::optional<std::string> queueId{};
|
||||
@@ -717,6 +797,9 @@ struct TaskSummary {
|
||||
/// build.
|
||||
struct TaskDetail {
|
||||
TaskSummary summary{};
|
||||
/// Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no gaps
|
||||
/// and no overlaps. Empty for a zero-length download, and empty before the task has been
|
||||
/// segmented.
|
||||
std::vector<Segment> segmentDetail{};
|
||||
std::optional<Headers> headers{};
|
||||
std::optional<std::string> referrer{};
|
||||
@@ -943,7 +1026,9 @@ struct DownloadUpdateParamsPatch {
|
||||
std::optional<std::string> categoryId{};
|
||||
std::optional<std::string> queueId{};
|
||||
std::optional<std::string> description{};
|
||||
/// Takes effect on the next start; a running task is not re-segmented underneath the user.
|
||||
/// The REQUESTED connection count, subject to the same per-host cap and non-resumable demotion
|
||||
/// as DownloadSpec.segments. Takes effect on the next start; a running task is not re-segmented
|
||||
/// underneath the user.
|
||||
std::optional<std::int64_t> segments{};
|
||||
std::optional<std::int64_t> bufferBytes{};
|
||||
std::optional<Checksum> checksum{};
|
||||
@@ -1309,6 +1394,7 @@ void to_json(nlohmann::json& j, const SettingsDownloadsDuplicatePolicy& v);
|
||||
void to_json(nlohmann::json& j, const SettingsProxyMode& v);
|
||||
void to_json(nlohmann::json& j, const SettingsSaveToFileExistsPolicy& v);
|
||||
void to_json(nlohmann::json& j, const Settings& v);
|
||||
void to_json(nlohmann::json& j, const TaskErrorCode& v);
|
||||
void to_json(nlohmann::json& j, const TaskError& v);
|
||||
void to_json(nlohmann::json& j, const TaskSummary& v);
|
||||
void to_json(nlohmann::json& j, const TaskDetail& v);
|
||||
@@ -1453,6 +1539,7 @@ template <> Result<SettingsDownloadsDuplicatePolicy> parse<SettingsDownloadsDupl
|
||||
template <> Result<SettingsProxyMode> parse<SettingsProxyMode>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<SettingsSaveToFileExistsPolicy> parse<SettingsSaveToFileExistsPolicy>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<Settings> parse<Settings>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<TaskErrorCode> parse<TaskErrorCode>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<TaskError> parse<TaskError>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<TaskSummary> parse<TaskSummary>(const nlohmann::json& j, std::string_view path);
|
||||
template <> Result<TaskDetail> parse<TaskDetail>(const nlohmann::json& j, std::string_view path);
|
||||
|
||||
@@ -15,9 +15,15 @@ implementations disagree at once.
|
||||
|
||||
## Decision
|
||||
|
||||
`contracts/VERSION` is frozen at **1.0.0**. The surface is 38 methods, 9 events, 25 named
|
||||
`contracts/VERSION` is frozen at **1.0.0**. The surface is 38 methods, 9 events, 26 named
|
||||
types and the JSON-RPC envelope, exactly as `contracts/README.md` documents it.
|
||||
|
||||
Three corrections landed into 1.0.0 before it reached `main`, answering CORE's
|
||||
freeze-blockers: a `TaskErrorCode` wire taxonomy, the effective-vs-requested meaning of
|
||||
`TaskSummary.segments`, and the segment range convention. See ADR 0010 — including why
|
||||
correcting an unpublished 1.0.0 in place is not the major bump this ADR's own rule would
|
||||
otherwise demand.
|
||||
|
||||
**Semantics of a change:**
|
||||
|
||||
| Change | Bump | Also needs |
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR 0010 — The wire failure taxonomy, and the segment range convention
|
||||
|
||||
**Status:** accepted · **Date:** 2026-09-09 · **Lane:** PROTO
|
||||
**Answers:** CORE's B1, B2 and B3 in `core/docs/proto-requests-m1.md`
|
||||
|
||||
## Context
|
||||
|
||||
The 1.0.0 freeze as first drafted had `TaskError.code` as a bare `integer` with no enum,
|
||||
`TaskSummary.segments` with no stated meaning, and a `Segment` range whose description
|
||||
contradicted its own bounds. CORE raised all three as freeze-blockers before building
|
||||
stage 6 on top of them. All three are retypes or meaning-pins, which rule 4 makes **major**
|
||||
bumps once the contract has landed.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `TaskErrorCode` — a string enum, separate from `ErrorCode`
|
||||
|
||||
`TaskError.code` is now a `TaskErrorCode`: a string enum whose 27 values mirror
|
||||
`vdm::Error` in `core/include/vdm/util/error.hpp` one-for-one, by name and in order.
|
||||
`ok` has no wire spelling, because a `TaskError` only exists when something failed.
|
||||
|
||||
**These are two different code spaces and conflating them was the bug.** `ErrorCode` is
|
||||
the JSON-RPC space: it says why a *call* failed. `TaskErrorCode` says why a *download*
|
||||
failed. A task fails while every RPC involved succeeds — that is the normal case, not an
|
||||
edge case, and the type system now says so. `TaskError`'s own description said "distinct
|
||||
from the JSON-RPC Error" while typing its code as the integer that JSON-RPC uses.
|
||||
|
||||
Strings rather than grouped integer ranges, which was CORE's offered fallback:
|
||||
|
||||
* the mapping stays lossless without anyone maintaining a numbering scheme in two repos;
|
||||
* a log line or a `nc` session reads `"server_file_changed"` instead of `407`;
|
||||
* CORE cannot include a protocol header (the layering rule), so the two enums are related
|
||||
only by name — which makes the name the thing worth keeping identical, and a number the
|
||||
thing most likely to drift.
|
||||
|
||||
DAEMON owns the projection. Because the names are identical, that projection is a
|
||||
generated-looking switch with no judgement in it, and a new `vdm::Error` value that is not
|
||||
on the wire is a compile-time hole rather than a silent collapse to "internal".
|
||||
|
||||
`retryable` stays a per-occurrence boolean rather than a property of the code, because
|
||||
CORE's own table has `probe_failed` as "maybe". `cause` carries the underlying code for
|
||||
`max_retries_exhausted`, so a user is told what actually kept failing.
|
||||
|
||||
### 2. `TaskSummary.segments` is the **effective** count
|
||||
|
||||
It is the number of connections in use **right now**, after the per-host cap and after the
|
||||
demotion to 1 for a non-resumable source. A task the user asked 16 connections for may
|
||||
honestly report 1. The requested value stays in `DownloadSpec.segments` and is not echoed
|
||||
back. `TaskDetail.segmentDetail` always holds exactly this many entries.
|
||||
|
||||
Pinning this was the genuinely blocking half of CORE's B2: an unstated meaning is not a
|
||||
free field, it is a coin flip that becomes a major bump the moment either side guesses.
|
||||
|
||||
### 3. Segment ranges are **closed and inclusive**: `[startByte, endByte]`
|
||||
|
||||
`endByte` is the index of the last byte, not one past it. CORE asked for half-open
|
||||
`[start, end)`; PROTO chose inclusive and this ADR is the notice.
|
||||
|
||||
The reason is that these two fields are copied verbatim into `Range: bytes=<start>-<end>`,
|
||||
and RFC 9110 byte ranges are inclusive. Inclusive means zero arithmetic between the wire
|
||||
and the socket. Half-open would mean a `-1` at every boundary between the contract and
|
||||
every HTTP request the engine makes, which is precisely where off-by-ones live.
|
||||
|
||||
The contradiction CORE would have hit is also fixed: the old description encoded an empty
|
||||
segment as `endByte == startByte - 1`, which is `-1` for a segment at offset 0 — and every
|
||||
download's first segment starts at 0, so the schema's `minimum: 0` rejected it. **Empty
|
||||
ranges are no longer representable and are not needed.** `endByte >= startByte` always
|
||||
holds; a zero-length download carries an empty `segmentDetail`; a segment that donates its
|
||||
remainder to a steal keeps the bytes it already wrote.
|
||||
|
||||
`tests/conformance/check_contract.py` now asserts contiguity, coverage of exactly
|
||||
`[0, sizeBytes - 1]`, `downloadedBytes <= endByte - startByte + 1`, and that the entry
|
||||
count matches `TaskSummary.segments`. Flipping a fixture to half-open makes it fail.
|
||||
|
||||
Also settled, from B3: the field is spelled **`index`** everywhere including
|
||||
`event.task.progress` (there is no `i`), and the segment state enum is
|
||||
`pending | connecting | downloading | stalled | complete | failed` — `downloading`, as CORE
|
||||
asked and as `TaskState` already spells it, not `receiving`.
|
||||
|
||||
## Why this is not a major bump
|
||||
|
||||
1.0.0 has **not landed on `main`**. `main` still carries `1.0.0-draft`; the freeze lives on
|
||||
`lane/proto` and no lane has consumed it. These are corrections *to* 1.0.0 before it is
|
||||
published, not changes to a released contract. Bumping to 2.0.0 for a version nobody ever
|
||||
received would be ceremony, not safety.
|
||||
|
||||
The rule is unchanged and starts biting the moment this lands: after that, retyping
|
||||
`error.code` or re-pinning `segments` is major, with an ADR and a migration note.
|
||||
|
||||
## Consequences
|
||||
|
||||
* `TaskError` gains `cause`; `TaskSummary`, `DownloadSpec` and `download.update`'s patch
|
||||
now state which side of the requested/effective line they sit on.
|
||||
* The contract has 26 named types rather than 25.
|
||||
* CORE builds stage 6 against inclusive ranges. **This is the item most likely to be got
|
||||
wrong silently**, which is why it is in an ADR, in the schema description, in
|
||||
`contracts/README.md`, in a fixture assertion, and in a conformance check.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
**Grouped integer ranges** (CORE's fallback). Works, but every value needs a number nobody
|
||||
can read, maintained in two places that cannot include each other's headers. The names are
|
||||
already identical; numbering them adds a translation step whose only purpose is to go
|
||||
wrong.
|
||||
|
||||
**Reusing `ErrorCode` for both.** This is what the draft accidentally did. It makes
|
||||
"the call failed" and "the download failed" indistinguishable at the type level, and there
|
||||
is no sensible JSON-RPC code for `checksum_mismatch`.
|
||||
|
||||
**Half-open ranges, as CORE asked.** Rejected for the HTTP reason above, but it was close,
|
||||
and it is the convention CORE would have implemented by default — hence the loud notice
|
||||
rather than a quiet schema edit.
|
||||
@@ -207,6 +207,12 @@ export interface DownloadSpec {
|
||||
categoryId?: string | null;
|
||||
/** Required when startMode is 'queue'. */
|
||||
queueId?: string | null;
|
||||
/**
|
||||
* The REQUESTED connection count. An upper bound, not a promise: the daemon lowers it to
|
||||
* the per-host cap, and to 1 when the source turns out not to be resumable. What is
|
||||
* actually in use comes back as TaskSummary.segments. null means use
|
||||
* connection.maxSegmentsPerDownload.
|
||||
*/
|
||||
segments?: number | null;
|
||||
bufferBytes?: number | null;
|
||||
startMode?: StartMode;
|
||||
@@ -374,11 +380,15 @@ export interface Rule {
|
||||
action: RuleAction;
|
||||
}
|
||||
|
||||
export type SegmentState = "pending" | "connecting" | "receiving" | "stalled" | "complete" | "failed";
|
||||
/**
|
||||
* 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has
|
||||
* been planned but not yet dialled.
|
||||
*/
|
||||
export type SegmentState = "pending" | "connecting" | "downloading" | "stalled" | "complete" | "failed";
|
||||
export const SEGMENT_STATE_VALUES = [
|
||||
"pending",
|
||||
"connecting",
|
||||
"receiving",
|
||||
"downloading",
|
||||
"stalled",
|
||||
"complete",
|
||||
"failed",
|
||||
@@ -386,17 +396,42 @@ export const SEGMENT_STATE_VALUES = [
|
||||
|
||||
/**
|
||||
* One byte range being fetched by one connection. This is the deepest the contract ever
|
||||
* exposes the engine: the GUI draws a bar per segment and never learns what a segment
|
||||
* steal is.
|
||||
* exposes the engine: the GUI draws a bar per segment and is never told what a segment
|
||||
* steal is. RANGE CONVENTION — READ THIS BEFORE IMPLEMENTING. The range is CLOSED and
|
||||
* INCLUSIVE on both ends: [startByte, endByte]. The segment covers endByte - startByte + 1
|
||||
* bytes, and endByte is the index of the LAST byte in the range, not one past it. This
|
||||
* deliberately matches the HTTP Range header the engine actually sends ('Range:
|
||||
* bytes=<startByte>-<endByte>' is a byte-for-byte copy of these two fields, and RFC 9110
|
||||
* ranges are inclusive), so no arithmetic happens between the wire and the socket and
|
||||
* there is nowhere for an off-by-one to hide. CORE asked for half-open [start, end); PROTO
|
||||
* chose inclusive for that reason and this note exists so nobody discovers the difference
|
||||
* at integration. A segment always covers at least one byte: endByte >= startByte always
|
||||
* holds. An empty range is not representable and is not needed — a zero-length download
|
||||
* carries an empty segmentDetail array, and a segment that has donated its remainder to a
|
||||
* steal keeps the bytes it already wrote.
|
||||
*/
|
||||
export interface Segment {
|
||||
/**
|
||||
* Position in TaskDetail.segmentDetail. Spelled 'index' here and in event.task.progress;
|
||||
* there is no 'i' spelling anywhere in the contract.
|
||||
*/
|
||||
index: number;
|
||||
/** Absolute offset of the first byte of the range. Inclusive. */
|
||||
startByte: number;
|
||||
/** Inclusive. Equal to startByte - 1 for an empty segment. */
|
||||
/**
|
||||
* Absolute offset of the LAST byte of the range. Inclusive — this is not one-past-the-end.
|
||||
* Always >= startByte.
|
||||
*/
|
||||
endByte: number;
|
||||
/** Bytes written for this range so far, out of endByte - startByte + 1. */
|
||||
downloadedBytes: number;
|
||||
speedBps?: number;
|
||||
/**
|
||||
* 'downloading' is spelled as in TaskState, not 'receiving'. 'pending' is a range that has
|
||||
* been planned but not yet dialled.
|
||||
*/
|
||||
state: SegmentState;
|
||||
/** The status this segment's request got. 206 on a healthy ranged fetch. */
|
||||
httpStatus?: number | null;
|
||||
}
|
||||
|
||||
@@ -541,15 +576,73 @@ export interface Settings {
|
||||
"sounds.onError"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a download failed. This is the WIRE failure taxonomy and it is deliberately NOT the
|
||||
* JSON-RPC ErrorCode space: ErrorCode says why a *call* failed, TaskErrorCode says why a
|
||||
* *download* failed. A task can fail while every RPC involved succeeded. The values mirror
|
||||
* vdm::Error in core/include/vdm/util/error.hpp one-for-one, by name, so DAEMON's
|
||||
* projection from the engine taxonomy onto the wire is lossless and the GUI can tell 'the
|
||||
* file on the server changed' from 'the checksum did not match'. CORE's 'ok' has no wire
|
||||
* spelling: a TaskError only exists when there is a failure. Adding a value here is a
|
||||
* minor bump; renaming or removing one is major, and would desynchronise the engine.
|
||||
*/
|
||||
export type TaskErrorCode = "canceled" | "resolve_failed" | "connect_failed" | "tls_failed" | "connection_reset" | "timeout" | "too_many_redirects" | "http_client_error" | "http_server_error" | "auth_required" | "forbidden" | "not_found" | "range_not_satisfiable" | "gone" | "server_file_changed" | "content_length_mismatch" | "checksum_mismatch" | "disk_full" | "io_error" | "path_rejected" | "permission_denied" | "meta_corrupt" | "meta_version_unsupported" | "probe_failed" | "unsupported_url_scheme" | "max_retries_exhausted" | "internal";
|
||||
export const TASK_ERROR_CODE_VALUES = [
|
||||
"canceled",
|
||||
"resolve_failed",
|
||||
"connect_failed",
|
||||
"tls_failed",
|
||||
"connection_reset",
|
||||
"timeout",
|
||||
"too_many_redirects",
|
||||
"http_client_error",
|
||||
"http_server_error",
|
||||
"auth_required",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"range_not_satisfiable",
|
||||
"gone",
|
||||
"server_file_changed",
|
||||
"content_length_mismatch",
|
||||
"checksum_mismatch",
|
||||
"disk_full",
|
||||
"io_error",
|
||||
"path_rejected",
|
||||
"permission_denied",
|
||||
"meta_corrupt",
|
||||
"meta_version_unsupported",
|
||||
"probe_failed",
|
||||
"unsupported_url_scheme",
|
||||
"max_retries_exhausted",
|
||||
"internal",
|
||||
] as const satisfies readonly TaskErrorCode[];
|
||||
|
||||
/**
|
||||
* Why a task is in the failed or retry_wait state. Distinct from the JSON-RPC Error, which
|
||||
* describes a failed call rather than a failed download.
|
||||
* describes a failed call rather than a failed download — the two live in different code
|
||||
* spaces on purpose, and `code` here is a TaskErrorCode string, never a JSON-RPC integer.
|
||||
*/
|
||||
export interface TaskError {
|
||||
code: number;
|
||||
code: TaskErrorCode;
|
||||
/**
|
||||
* Human-readable, safe to show a user. Never carries a credential, a token or a full local
|
||||
* path outside the download roots.
|
||||
*/
|
||||
message: string;
|
||||
/** Set for the codes listed in TaskErrorCode's x-carriesHttpStatus, and null otherwise. */
|
||||
httpStatus?: number | null;
|
||||
/**
|
||||
* Whether the scheduler will pick this task up again on its own. Carried per-occurrence
|
||||
* rather than derived from the code, because 'probe_failed' is retryable or not depending
|
||||
* on what the probe hit.
|
||||
*/
|
||||
retryable: boolean;
|
||||
/**
|
||||
* The underlying failure, for codes that wrap one. max_retries_exhausted sets it to
|
||||
* whatever the last attempt actually failed with, so a user learns the reason rather than
|
||||
* just that Velox gave up.
|
||||
*/
|
||||
cause?: TaskErrorCode | null;
|
||||
/** How many attempts have been made so far. */
|
||||
attempt?: number | null;
|
||||
nextRetryAt?: string | null;
|
||||
@@ -577,7 +670,15 @@ export interface TaskSummary {
|
||||
/** null when the size or the speed is unknown. */
|
||||
etaSeconds?: number | null;
|
||||
resumable: boolean;
|
||||
/** Connection count. Per-segment detail lives in TaskDetail. */
|
||||
/**
|
||||
* The EFFECTIVE connection count in use right now — not the number that was requested. It
|
||||
* is what remains after the per-host connection cap has been applied and after the
|
||||
* demotion to 1 for a non-resumable source, so a task the user asked for 16 connections on
|
||||
* legitimately reports 4, or 1. The GUI displays this value and must not assume it equals
|
||||
* what download.add asked for. The requested value lives in DownloadSpec.segments and is
|
||||
* not echoed back on this type. TaskDetail.segmentDetail always has exactly this many
|
||||
* entries.
|
||||
*/
|
||||
segments: number;
|
||||
categoryId?: string | null;
|
||||
queueId?: string | null;
|
||||
@@ -597,6 +698,11 @@ export interface TaskSummary {
|
||||
*/
|
||||
export interface TaskDetail {
|
||||
summary: TaskSummary;
|
||||
/**
|
||||
* Exactly TaskSummary.segments entries, in index order, covering [0, sizeBytes) with no
|
||||
* gaps and no overlaps. Empty for a zero-length download, and empty before the task has
|
||||
* been segmented.
|
||||
*/
|
||||
segmentDetail: Segment[];
|
||||
headers?: Headers | null;
|
||||
referrer?: string | null;
|
||||
@@ -883,7 +989,11 @@ export interface DownloadUpdateParamsPatch {
|
||||
categoryId?: string | null;
|
||||
queueId?: string | null;
|
||||
description?: string | null;
|
||||
/** Takes effect on the next start; a running task is not re-segmented underneath the user. */
|
||||
/**
|
||||
* The REQUESTED connection count, subject to the same per-host cap and non-resumable
|
||||
* demotion as DownloadSpec.segments. Takes effect on the next start; a running task is not
|
||||
* re-segmented underneath the user.
|
||||
*/
|
||||
segments?: number | null;
|
||||
bufferBytes?: number | null;
|
||||
checksum?: Checksum | null;
|
||||
|
||||
@@ -135,6 +135,7 @@ import type {
|
||||
TaskAddedEvent,
|
||||
TaskDetail,
|
||||
TaskError,
|
||||
TaskErrorCode,
|
||||
TaskFilter,
|
||||
TaskProgressEvent,
|
||||
TaskProgressEventTasksItem,
|
||||
@@ -175,6 +176,7 @@ import {
|
||||
SETTINGS_PROXY_MODE_VALUES,
|
||||
SETTINGS_SAVE_TO_FILE_EXISTS_POLICY_VALUES,
|
||||
START_MODE_VALUES,
|
||||
TASK_ERROR_CODE_VALUES,
|
||||
TASK_SORT_DIRECTION_VALUES,
|
||||
TASK_SORT_FIELD_VALUES,
|
||||
TASK_STATE_VALUES,
|
||||
@@ -698,7 +700,7 @@ export function validateSegment(v: unknown, path = ''): Validated<Segment> {
|
||||
if (!r.ok) return r;
|
||||
r = req(v, "state", path, validateSegmentState, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "httpStatus", path, vInteger, out);
|
||||
r = opt(v, "httpStatus", path, vLimited(vInteger, { minimum: 100, maximum: 599 }), out);
|
||||
if (!r.ok) return r;
|
||||
return { ok: true, value: out as unknown as Segment };
|
||||
}
|
||||
@@ -890,14 +892,16 @@ export function validateTaskError(v: unknown, path = ''): Validated<TaskError> {
|
||||
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
||||
const out: Record<string, unknown> = {};
|
||||
let r: Validated<null>;
|
||||
r = req(v, "code", path, vInteger, out);
|
||||
r = req(v, "code", path, validateTaskErrorCode, out);
|
||||
if (!r.ok) return r;
|
||||
r = req(v, "message", path, vString, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "httpStatus", path, vInteger, out);
|
||||
r = opt(v, "httpStatus", path, vLimited(vInteger, { minimum: 100, maximum: 599 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = req(v, "retryable", path, vBoolean, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "cause", path, validateTaskErrorCode, out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "attempt", path, vLimited(vInteger, { minimum: 0 }), out);
|
||||
if (!r.ok) return r;
|
||||
r = opt(v, "nextRetryAt", path, vString, out);
|
||||
@@ -905,6 +909,8 @@ export function validateTaskError(v: unknown, path = ''): Validated<TaskError> {
|
||||
return { ok: true, value: out as unknown as TaskError };
|
||||
}
|
||||
|
||||
export const validateTaskErrorCode: Validator<TaskErrorCode> = vEnum(TASK_ERROR_CODE_VALUES, 'TaskErrorCode');
|
||||
|
||||
/** Validate an untrusted value as TaskFilter. */
|
||||
export function validateTaskFilter(v: unknown, path = ''): Validated<TaskFilter> {
|
||||
if (!isPlainObject(v)) return fail(path, 'expected an object');
|
||||
|
||||
@@ -193,6 +193,40 @@ def main() -> int:
|
||||
if response.get("id") != request.get("id"):
|
||||
fail(f"{rel}: response id does not match request id")
|
||||
|
||||
# Segment ranges are the one place an off-by-one is both easy and expensive, and the
|
||||
# schema cannot express a cross-field invariant. So it is checked here instead.
|
||||
for path, doc in iter_fixtures():
|
||||
rel = path.relative_to(REPO)
|
||||
result = (doc.get("response") or {}).get("result") if isinstance(doc.get("response"), dict) else None
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
segments = result.get("segmentDetail")
|
||||
summary = result.get("summary")
|
||||
if not isinstance(segments, list) or not isinstance(summary, dict):
|
||||
continue
|
||||
|
||||
if len(segments) != summary.get("segments"):
|
||||
fail(f"{rel}: segmentDetail has {len(segments)} entries but summary.segments is "
|
||||
f"{summary.get('segments')}")
|
||||
for seg in segments:
|
||||
if seg["endByte"] < seg["startByte"]:
|
||||
fail(f"{rel}: segment {seg['index']} has endByte < startByte; the range is "
|
||||
"inclusive and a segment always covers at least one byte")
|
||||
span = seg["endByte"] - seg["startByte"] + 1
|
||||
if seg["downloadedBytes"] > span:
|
||||
fail(f"{rel}: segment {seg['index']} has downloadedBytes above its range size "
|
||||
f"({seg['downloadedBytes']} > {span}) — check for an off-by-one from "
|
||||
"treating endByte as exclusive")
|
||||
for a, b in zip(segments, segments[1:]):
|
||||
if a["endByte"] + 1 != b["startByte"]:
|
||||
fail(f"{rel}: segments {a['index']} and {b['index']} are not contiguous: "
|
||||
f"{a['endByte']} + 1 != {b['startByte']}")
|
||||
size = summary.get("sizeBytes")
|
||||
if segments and isinstance(size, int):
|
||||
if segments[0]["startByte"] != 0 or segments[-1]["endByte"] != size - 1:
|
||||
fail(f"{rel}: segments must cover exactly [0, {size - 1}] inclusive, got "
|
||||
f"[{segments[0]['startByte']}, {segments[-1]['endByte']}]")
|
||||
|
||||
for name in sorted(method_ids):
|
||||
if name not in covered_methods:
|
||||
fail(f"method {name} has no success fixture — a method with no fixture is not done")
|
||||
|
||||
Reference in New Issue
Block a user