The download entry point the AGENT-CORE brief asked for on day one and that slipped. DAEMON has an RPC surface and a store and, until this is agreed, nothing in velox::core to call. vdm/task/download.hpp — DownloadSpec (the resolved subset DAEMON hands in: absolute save_path, verbatim browser headers, requested segments/buffer, optional probe_hint / checksum / auth, allow_resume), EngineState (the CORE-owned subset of the wire TaskState), Progress / SegmentProgress, DownloadCallbacks (on_progress <=4 Hz, on_state for every transition incl. auto-pauses, on_auth_required, on_decision_needed, on_finished last), DownloadHandle (pause/resume/cancel — idempotent per the ADR 0013 signature — plus provide_auth / decide / refresh_url, and synchronous state()/progress() snapshots). vdm/engine.hpp — Engine: start(spec, callbacks) -> handle, segment_budget() (DAEMON's sched/ admission surface, ADR 0011), live connection.* setters, a standalone probe() on the pool outside the segment budget. core/docs/engine-api-m1.md — the review doc: field semantics, the state machine, threading/lifetime rules (which thread callbacks arrive on, what is legal from inside one, handle/engine lifetime), the shared-`paused` idempotency contract as a signature, and five open questions for DAEMON. Value types compile and are covered by api_compiles_test; Engine / DownloadHandle bodies land in stage 8, built against whatever DAEMON signs off here. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HPPSGhiArbvQgwC2DNiURS
10 KiB
CORE → DAEMON — the engine API for review (M1)
Status: for review. This is the libveloxcore public surface veloxd links and calls
to actually run a download. It is the thing the AGENT-CORE brief asked for on day one and
that slipped: DAEMON has an RPC surface and a store and, until this is agreed, nothing to
call. Sketch headers: core/include/vdm/engine.hpp, core/include/vdm/task/download.hpp
(both compile now; Engine / DownloadHandle bodies land in CORE stage 8). Nothing here
touches contracts/ — DAEMON projects these callbacks onto TaskSummary / TaskDetail /
event.*.
Please review the field semantics, the threading/lifetime rules, and the
pause/resume/cancel contract, and raise anything that would force a daemon/src/sched/ or
RPC-dispatch rewrite later. Open questions are at the bottom.
1. DownloadSpec — what DAEMON hands the engine
DAEMON has already run the rules engine, picked the category folder, canonicalised the
path and checked it against the allowed roots (-32011 is DAEMON's error, raised before
start()), and resolved the filename. The engine's spec is the concrete result.
| field | who fills it | notes |
|---|---|---|
url, mirrors |
DAEMON | mirrors are alternative URLs for the same bytes; the segmenter's requeue prefers a different host after 3 connection failures (docs/04 §3). |
headers, cookies, referrer, user_agent |
DAEMON, verbatim from the capture | replayed on every segment request and on the probe. |
save_path |
DAEMON | absolute and final. The engine never canonicalises or root-checks. <save_path>.veloxpart and .veloxpart.meta sit beside it; on success the part file is renamed in place. |
segments |
user / connection.maxSegmentsPerDownload |
requested upper bound 1–32; the engine lowers it to the per-host cap and to 1 for a non-resumable source. Effective value comes back in Progress.effective_segments. |
buffer_bytes |
user / connection.bufferBytes |
requested per-segment; silently reduced to fit maxTotalBufferBytes across all live segments. Effective value in Progress.effective_buffer_bytes → your TaskDetail.effectiveBufferBytes. |
proxy, auth |
DAEMON | auth carries credentials only if known up front (Secret Service). Leave scheme == none to get an on_auth_required on a 401/407 instead. |
checksum |
user (download.add.checksum) |
{algo, hex}. Verified during verifying; a mismatch is a terminal failed with Error::checksum_mismatch. |
probe_hint |
DAEMON | the ProbeResult you already got for the File Info dialog. Supplying it skips the engine's own probe — the task starts in connecting, not probing. The engine still revalidates with If-Range on resume. |
allow_resume |
DAEMON | true: if a CRC-valid .veloxpart.meta sits beside save_path, resume from it. false: start fresh, overwrite. Your restart flow (ADR 0013 §5) sets this per task. |
max_retries |
user / default 10 | per segment, before failed with Error::max_retries_exhausted. |
start() returns immediately. It never throws and never blocks on the network; a bad URL,
DNS failure, or unwritable save_path is delivered through on_finished.
2. The state machine the engine drives
EngineState is the CORE-owned subset of the wire TaskState (ADR 0013 §1):
probing ─▶ connecting ⇄ downloading ─▶ assembling ─▶ verifying ─▶ complete
│ │ ▲ │ │ (M4 mux; a no-op rename in M1)
│ │ └─ retry_wait ┘
▼ ▼
(any) ──────▶ paused ──(resume)──▶ connecting
(any CORE state) ──────────────────▶ failed (terminal, engine-initiated)
(any state, on cancel()) ──────────▶ cancelled (terminal, DAEMON-initiated)
new and queued are yours; the engine never emits them. start() corresponds to your
queued → probing. Every transition is reported through on_state(from, to, error?),
including the auto-pauses (§4) and the terminals. previousState on your
event.task.state maps straight from the from argument.
3. Threading and lifetime
- Callbacks run on an engine thread — a transfer worker, the progress timer, or a
dispatch thread — never the thread that called
start()/pause()/ etc. - Per task, callbacks are serialised. You will never get two callbacks for the same handle at once. Across tasks they run concurrently.
- A callback must not block. It runs on a thread doing real transfer work; a slow callback stalls that work. Hand off to your own queue/loop.
- A callback must not re-enter the same handle synchronously — no
pause()from insideon_state, etc. Post it. (Calling into a different handle, or intosegment_budget(), is fine.) on_finishedis always the last callback for a task. After it returns the engine makes no further callbacks for that handle and the handle's control methods are no-ops.- The handle is copyable and thread-safe. Dropping the last copy does not cancel —
the task runs on. Call
cancel()to stop it. (DAEMON holds the handle for the task's life anyway.) Enginemust outlive every handle.~Engine()cancels all running tasks and joins their workers before returning — expect it to block briefly.- Logging: the engine writes through
vdm::set_log_sink()(acore/utilglobal). Install your sink once at startup; the engine never opens a file itself.
4. paused is shared, and idempotency is the contract (ADR 0013 §2)
Both sides put a task in paused, for disjoint reasons:
- DAEMON-initiated:
handle.pause()— user pause, a schedule window closing, a queue stop,Queue.onComplete, the admission governor reconciling a loweredmaxActiveSegments. - Engine auto-pause:
on_auth_required(401/407),on_decision_needed(server_file_changed/ stale range), disk full. The engine transitions topausedon its own and fireson_state(_, paused, ErrorInfo{...})— the same path as any other transition.ErrorInfo.codepresent ⇒ engine-initiated; absent ⇒ you did it. That is the only discriminator, and it is what yourerror-on-pausedwidening (open item 3 of ADR 0013) carries on the wire.
Idempotency, now a signature:
| call | already in that state / terminal | otherwise |
|---|---|---|
pause() |
no-op, no error | stop new segment requests, flush + fdatasync in-flight buffers, write .veloxpart.meta, release the budget slots, on_state(_, paused, nullopt). Bounded by the slowest in-flight flush. |
resume() |
no-op if not paused; no-op if terminal |
revalidate with If-Range, re-acquire budget slots, paused → connecting, resume from the sidecar offsets. |
cancel(discard_partial) |
no-op if already terminal | stop everything, on_state(_, cancelled, nullopt), on_finished(Err{cancelled}). discard_partial also unlinks .veloxpart[.meta] — wire this to download.remove {deleteFile}. |
resume() after an auto-pause for auth_required without a preceding
provide_auth() is a no-op — the task stays paused. This is ADR 0013 §3's "resume must
not cross reasons", enforced on CORE's side: the scheduler cannot accidentally un-pause a
task waiting on credentials.
provide_auth(user, pass, remember)— acts only while the task is auto-paused for auth; supplies the credential for the retry and resumes.rememberasks you to persist to the Secret Service; the engine never stores it. No-op otherwise.decide(Decision)— acts only while auto-paused for a decision.restartdiscards the partial and re-downloads;keep_partialcontinues against what is on disk (the user's stated risk);abort→failed. No-op otherwise.refresh_url(url, headers)— IDM's "Refresh Download Address": swap the URL on a live or paused task without losing progress (a fresh signed URL). Maps todownload.refreshUrl.
5. Progress
on_progress is coalesced to ≤ 4 Hz per task inside the engine — the same cadence as
event.task.progress, so your batcher can forward without re-throttling. It carries
aggregate downloaded / speed_bps / eta_seconds, the effective segment count and
buffer size, and a SegmentProgress[] (index, inclusive [start,end], completed,
per-segment speed, state) for the GUI's segment bars. total is absent for a chunked
source until the stream ends.
handle.state() and handle.progress() are synchronous lock-guarded snapshots for
download.get / download.list — call them any time, including from your RPC thread.
6. What the engine does NOT do
- No filename resolution, no category matching, no path canonicalisation, no allowed-root
check — all DAEMON, before
start(). - No queueing, scheduling, priority, or "when queue completes" — DAEMON, via
segment_budget().set_task_order()and by choosing when to callstart()/pause(). - No persistence beyond
.veloxpart.meta. On a daemon restart the engine knows nothing; you reload from SQLite, rewrite CORE-owned states toqueued, and re-start()withallow_resume = true(ADR 0013 §5). - No credential storage. Ever (
CLAUDE.md§4).
Open questions for DAEMON
DownloadSpec.probe_hint— do you want to pass the File-InfoProbeResultin, or would you rather the engine always probe (one code path, ~1 extra round trip)? The sketch supports both; picking one simplifies stage 8.cancel(discard_partial)vs a separatehandle.remove()— the wire hasdownload.cancelanddownload.remove {deleteFile}as two methods. One call with a flag, or two?on_decision_neededgranularity — is{restart, keep_partial, abort}the right choice set forserver_file_changed, or do you also need "retry the same range once more" as a distinct option for the stale-416 case?- Progress cadence — 4 Hz per task matches the wire. With 20 active tasks that is 80
callbacks/s on the engine's timer thread. Acceptable, or do you want a single
on_progress_batch(span<Progress>)so the engine coalesces across tasks too? refresh_urlwhiledownloading— should an in-flight segment finish on the old URL and only new/retried segments use the new one (less disruption), or should the engine restart all segments on the new URL immediately (guaranteed consistency)? The signed-URL-expiry case wants the latter; a mirror swap wants the former.