scaffold: project structure, wire contract, roadmap and agent briefs
Lays out Velox Download Manager (IDM-class download manager for Ubuntu 26.04) as a monorepo ready for parallel lane development. No implementation code by design. - docs/: architecture, roadmap M0-M7, IDM-parity GUI spec, engine design, Firefox extension spec, risks/spikes, packaging - contracts/: wire-contract skeleton (JSON Schema + fixture templates) — the single synchronization point between lanes - docs/agents/: one brief per lane (PROTO, CORE, DAEMON, GUI, EXT, PKG/QA) with owned directories, build order and definition of done - CLAUDE.md: rules of engagement — lane ownership, layering, non-negotiables - CMake scaffolding with dev/tsan/release/ci presets Two environment findings shape the design: Firefox here is the Mozilla snap (native-messaging risk, so the extension carries a loopback-WebSocket fallback), and Wayland forbids passive clipboard monitoring (so clipboard capture is explicit-action-first). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# contracts/ — the wire contract
|
||||
|
||||
**This directory is the interface between every lane.** Owner: agent **PROTO**.
|
||||
Nobody else commits here. Everybody else *generates from* here.
|
||||
|
||||
```
|
||||
contracts/
|
||||
├── VERSION # protocol semver, e.g. 1.0.0
|
||||
├── openrpc.json # human-readable API doc (generated from schema/)
|
||||
├── schema/
|
||||
│ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes
|
||||
│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureOffer…
|
||||
│ ├── methods/ # one file per method: params + result
|
||||
│ └── events/ # one file per server→client notification
|
||||
├── fixtures/ # golden request/response pairs, replayed by conformance
|
||||
└── codegen/
|
||||
├── gen_cpp.py # → core/generated/ (structs + to_json/from_json)
|
||||
└── gen_ts.py # → extension/src/shared/protocol/ (types + client)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Generated code is committed.** No lane may be blocked because it can't run Python.
|
||||
2. **Hand-editing generated files is a merge blocker.** Fix the schema and regenerate.
|
||||
3. **Every method needs at least one fixture** — a success case and, where meaningful, an
|
||||
error case. A method with no fixture is not done.
|
||||
4. **Versioning:** adding an optional field or a new method → minor bump. Removing,
|
||||
renaming, retyping, or changing a default → **major** bump and a written migration note
|
||||
in `docs/adr/`. `session.hello` rejects a major mismatch with error `-32001` and a
|
||||
message the GUI renders as "Velox needs updating".
|
||||
5. **Changes arrive as a PR to `contracts/` alone**, containing: schema edit + fixtures +
|
||||
regenerated code + `VERSION` bump. Lanes rebase onto it. This is the only synchronization
|
||||
point in the whole project — keep it cheap and frequent rather than big and rare.
|
||||
|
||||
## Transport framing
|
||||
|
||||
| Client | Transport | Framing |
|
||||
|---|---|---|
|
||||
| GUI, CLI | `$XDG_RUNTIME_DIR/velox/velox.sock` | newline-delimited JSON (NDJSON) |
|
||||
| nmhost ← Firefox | stdio | 4-byte little-endian length prefix (Firefox's format) |
|
||||
| nmhost → daemon | same Unix socket | NDJSON |
|
||||
| Extension (fallback) | `ws://127.0.0.1:520xx` | one JSON message per WS text frame |
|
||||
|
||||
All four carry **the same JSON-RPC 2.0 payloads**. The framing differences stop at the
|
||||
transport layer; no method behaves differently depending on how it arrived — except that
|
||||
methods marked `"privileged": true` in the schema are refused over the WebSocket transport.
|
||||
|
||||
## Method surface (v1.0.0 target — expand only via PR)
|
||||
|
||||
### Session
|
||||
| Method | Params → Result |
|
||||
|---|---|
|
||||
| `session.hello` | `{clientType, clientName, protocolVersion, token?}` → `{daemonVersion, protocolVersion, capabilities[], sessionId}` |
|
||||
| `session.pair` | `{clientName, extensionId}` → `{token, expiresAt}` *(WS only; triggers user prompt)* |
|
||||
| `session.subscribe` | `{events[]}` → `{ok}` |
|
||||
|
||||
### Downloads
|
||||
| Method | Params → Result |
|
||||
|---|---|
|
||||
| `download.probe` | `{url, headers?, cookies?, referrer?, userAgent?}` → `{filename, sizeBytes?, mime, resumable, effectiveUrl, suggestedCategoryId}` |
|
||||
| `download.add` | `{url, headers?, cookies?, referrer?, userAgent?, filename?, saveDir?, categoryId?, segments?, bufferBytes?, startMode:"now"\|"later"\|"queue", queueId?, description?, checksum?}` → `{taskId, state}` |
|
||||
| `download.addBatch` | `{items[], defaults}` → `{taskIds[]}` |
|
||||
| `download.list` | `{filter?, sort?, offset?, limit?}` → `{total, items: TaskSummary[]}` |
|
||||
| `download.get` | `{taskId}` → `TaskDetail` (includes `segments[]`) |
|
||||
| `download.start` \| `.pause` \| `.resume` \| `.cancel` | `{taskIds[]}` → `{updated[]}` |
|
||||
| `download.remove` | `{taskIds[], deleteFile:bool}` → `{removed[]}` |
|
||||
| `download.update` | `{taskId, patch:{filename?, saveDir?, categoryId?, queueId?, description?, segments?, bufferBytes?}}` → `TaskSummary` |
|
||||
| `download.refreshUrl` | `{taskId, url, headers?}` → `{ok}` *(IDM's "Refresh Download Address")* |
|
||||
|
||||
### Organisation
|
||||
`category.list` · `category.upsert` · `category.remove` · `queue.list` · `queue.upsert` ·
|
||||
`queue.start` · `queue.stop` · `queue.reorder` · `rules.list` · `rules.upsert` ·
|
||||
`schedule.get` · `schedule.set`
|
||||
|
||||
### Settings & limits
|
||||
`settings.get {keys?}` · `settings.set {values}` · `limiter.get` · `limiter.set {globalBps?, enabled}`
|
||||
|
||||
### Browser integration
|
||||
| Method | Notes |
|
||||
|---|---|
|
||||
| `capture.offer` | `{url, method, headers, cookies, contentType?, contentLength?, contentDisposition?, tabUrl, filename?}` → `{action:"take"\|"ignore", taskId?, reason?}` — **must answer within 750 ms**; the extension gives up and lets Firefox handle it otherwise |
|
||||
| `capture.getRules` | Extension mirrors the daemon's monitored types so the two never disagree |
|
||||
| `media.listVariants` | `{manifestUrl, headers}` → `{variants:[{id,resolution,bitrate,codec,sizeEstimate}]}` |
|
||||
| `media.addVariant` | `{manifestUrl, variantId, ...addParams}` → `{taskId}` |
|
||||
|
||||
### Grabber
|
||||
`grabber.start {startUrl, depth, includePatterns[], excludePatterns[], fileTypes[]}` →
|
||||
`{jobId}`; `grabber.status {jobId}`; `grabber.harvest {jobId, select[]}` → `{taskIds[]}`
|
||||
|
||||
### Events (server → client notifications)
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `event.task.added` / `.removed` | `{taskId, summary?}` |
|
||||
| `event.task.state` | `{taskId, state, error?}` |
|
||||
| `event.task.progress` | **Batched array**, emitted at ≤4 Hz: `[{taskId, downloaded, speedBps, etaSec, segments:[{i,completed,speedBps}]}]` |
|
||||
| `event.speed.global` | `{downBps, activeCount}` |
|
||||
| `event.auth.required` | `{taskId, host, realm, scheme}` |
|
||||
| `event.notify` | `{level, title, body, taskId?}` |
|
||||
| `event.settings.changed` | `{keys[]}` |
|
||||
| `event.grabber.progress` | `{jobId, found, crawled, done}` |
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `-32600/-32601/-32602/-32603` | Standard JSON-RPC |
|
||||
| `-32001` | Protocol major version mismatch |
|
||||
| `-32002` | Not paired / invalid token |
|
||||
| `-32003` | Method not permitted on this transport |
|
||||
| `-32010` | Task not found |
|
||||
| `-32011` | Invalid destination path (outside allowed roots, or not writable) |
|
||||
| `-32012` | Disk full |
|
||||
| `-32013` | Probe failed (with `data.httpStatus`) |
|
||||
| `-32014` | Rate limited (pairing brute-force lockout) |
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0-draft
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "capture.offer — attachment on a monitored type is taken",
|
||||
"description": "Golden fixture. tests/conformance replays this against the real daemon AND the TS client. If either side drifts, this goes red before the lanes ever integrate.",
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 42,
|
||||
"method": "capture.offer",
|
||||
"params": {
|
||||
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
|
||||
"method": "GET",
|
||||
"tabUrl": "https://releases.ubuntu.com/26.04/",
|
||||
"headers": {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:154.0) Gecko/20100101 Firefox/154.0",
|
||||
"Referer": "https://releases.ubuntu.com/26.04/",
|
||||
"Accept": "*/*"
|
||||
},
|
||||
"cookies": [],
|
||||
"contentType": "application/octet-stream",
|
||||
"contentLength": 6228541440,
|
||||
"contentDisposition": "attachment; filename=\"ubuntu-26.04-desktop-amd64.iso\"",
|
||||
"filename": "ubuntu-26.04-desktop-amd64.iso",
|
||||
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 42,
|
||||
"result": {
|
||||
"action": "take",
|
||||
"taskId": "$uuid",
|
||||
"reason": null
|
||||
}
|
||||
},
|
||||
"assertions": [
|
||||
"response arrives within 750 ms",
|
||||
"a task exists afterwards with state in [queued, connecting, downloading]",
|
||||
"the task's saveDir resolves to the 'Programs' category folder for .iso",
|
||||
"the Referer and User-Agent from params.headers are replayed on the daemon's own request"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://velox.dev/schema/methods/capture.offer.schema.json",
|
||||
"title": "capture.offer",
|
||||
"description": "Firefox offers an intercepted response to the daemon. The daemon MUST reply within 750 ms; the extension abandons the offer and lets Firefox download normally on timeout. TEMPLATE — lane PROTO owns the final shape.",
|
||||
"x-privileged": false,
|
||||
"x-transports": ["uds", "ws"],
|
||||
"x-deadlineMs": 750,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"params": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "method", "tabUrl"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"method": { "type": "string", "enum": ["GET", "POST"] },
|
||||
"tabUrl": { "type": "string", "format": "uri" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "Request headers Firefox was about to send, verbatim. Needed for signed-URL and referrer-gated CDNs.",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"cookies": {
|
||||
"type": "array",
|
||||
"description": "Cookies for the URL, so authenticated downloads work outside the browser.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "value"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" },
|
||||
"domain": { "type": "string" },
|
||||
"path": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"contentType": { "type": ["string", "null"] },
|
||||
"contentLength": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"contentDisposition": { "type": ["string", "null"] },
|
||||
"filename": { "type": ["string", "null"], "description": "Extension's best guess; the daemon may override" },
|
||||
"userAgent": { "type": ["string", "null"] },
|
||||
"referrer": { "type": ["string", "null"] },
|
||||
"origin": { "type": "string", "description": "moz-extension://… — the daemon verifies this on the WS transport" }
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["take", "ignore"] },
|
||||
"taskId": { "type": ["string", "null"], "format": "uuid" },
|
||||
"reason": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["excluded_host", "type_not_monitored", "below_min_size", "duplicate",
|
||||
"capture_disabled", "user_declined", null]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://velox.dev/schema/types/TaskSummary.schema.json",
|
||||
"title": "TaskSummary",
|
||||
"description": "One row of the main download list. Everything the GUI table needs, and nothing more. TEMPLATE — lane PROTO owns the final shape.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["taskId", "filename", "state", "createdAt"],
|
||||
"properties": {
|
||||
"taskId": { "type": "string", "format": "uuid" },
|
||||
"filename": { "type": "string", "maxLength": 255 },
|
||||
"saveDir": { "type": "string" },
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"effectiveUrl": { "type": "string", "format": "uri" },
|
||||
"sizeBytes": { "type": ["integer", "null"], "minimum": 0, "description": "null when the server did not report a length" },
|
||||
"downloadedBytes": { "type": "integer", "minimum": 0 },
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["new", "probing", "queued", "connecting", "downloading", "paused",
|
||||
"retry_wait", "assembling", "verifying", "complete", "failed", "cancelled"]
|
||||
},
|
||||
"speedBps": { "type": "integer", "minimum": 0 },
|
||||
"etaSeconds": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"resumable": { "type": "boolean" },
|
||||
"segments": { "type": "integer", "minimum": 1, "maximum": 32 },
|
||||
"categoryId": { "type": ["string", "null"] },
|
||||
"queueId": { "type": ["string", "null"] },
|
||||
"queuePosition":{ "type": ["integer", "null"], "minimum": 0, "description": "the Q column" },
|
||||
"description": { "type": "string", "maxLength": 1024 },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"lastTryAt": { "type": ["string", "null"], "format": "date-time" },
|
||||
"completedAt": { "type": ["string", "null"], "format": "date-time" },
|
||||
"error": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": { "type": "integer" },
|
||||
"message": { "type": "string" },
|
||||
"httpStatus": { "type": ["integer", "null"] },
|
||||
"retryable": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user