proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+145
View File
@@ -0,0 +1,145 @@
/**
* The little bit of real state mockd keeps.
*
* A pure fixture replayer would be useless to the GUI lane: adding a download and seeing
* nothing appear teaches you nothing about your table model. So mockd keeps a task list,
* moves tasks between states, and advances byte counters on a timer. Everything else is
* answered straight from a golden file.
*
* This is deliberately not a download engine. Bytes advance on a clock, not from a socket.
*/
import { randomUUID } from 'node:crypto';
import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
export interface MockOptions {
readonly progressHz: number;
readonly speedBps: number;
}
const ACTIVE: readonly TaskState[] = ['connecting', 'downloading'];
export class MockState {
readonly tasks = new Map<string, TaskSummary>();
limiter = { enabled: false, globalBps: 2097152, applyToRunning: false };
settings: Record<string, unknown> = {};
private readonly opts: MockOptions;
constructor(seed: readonly TaskSummary[], opts: MockOptions) {
this.opts = opts;
for (const t of seed) this.tasks.set(t.taskId, { ...t });
}
list(): TaskSummary[] {
return [...this.tasks.values()];
}
add(partial: Partial<TaskSummary> & { url: string }): TaskSummary {
const now = new Date().toISOString();
const task: TaskSummary = {
taskId: randomUUID(),
filename: partial.filename ?? filenameFromUrl(partial.url),
saveDir: partial.saveDir ?? '/home/sami/Downloads',
url: partial.url,
effectiveUrl: partial.url,
sizeBytes: partial.sizeBytes ?? 734003200,
downloadedBytes: 0,
state: partial.state ?? 'connecting',
speedBps: 0,
etaSeconds: null,
resumable: true,
segments: partial.segments ?? 8,
categoryId: partial.categoryId ?? null,
queueId: partial.queueId ?? null,
queuePosition: partial.queuePosition ?? null,
description: partial.description ?? null,
createdAt: now,
lastTryAt: now,
completedAt: null,
error: null,
};
this.tasks.set(task.taskId, task);
return task;
}
transition(taskId: string, state: TaskState): { changed: boolean; task: TaskSummary } | null {
const task = this.tasks.get(taskId);
if (!task) return null;
const changed = task.state !== state;
task.state = state;
task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0;
if (!ACTIVE.includes(state)) task.etaSeconds = null;
if (state === 'complete') {
task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes;
task.completedAt = new Date().toISOString();
}
return { changed, task };
}
remove(taskId: string): boolean {
return this.tasks.delete(taskId);
}
/**
* Advance one progress tick. Returns the tasks that moved and any that just finished,
* so the caller can emit event.task.progress and event.task.state from one place.
*/
tick(): { moved: TaskSummary[]; completed: TaskSummary[] } {
const moved: TaskSummary[] = [];
const completed: TaskSummary[] = [];
const perTick = Math.floor(this.opts.speedBps / this.opts.progressHz);
for (const task of this.tasks.values()) {
if (task.state === 'connecting') {
task.state = 'downloading';
moved.push(task);
}
if (task.state !== 'downloading') continue;
task.speedBps = jitter(perTick * this.opts.progressHz);
task.downloadedBytes += perTick;
const size = task.sizeBytes;
if (size !== null && size !== undefined && task.downloadedBytes >= size) {
task.downloadedBytes = size;
task.state = 'complete';
task.speedBps = 0;
task.etaSeconds = null;
task.completedAt = new Date().toISOString();
completed.push(task);
} else if (size !== null && size !== undefined && task.speedBps > 0) {
task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps);
}
moved.push(task);
}
return { moved, completed };
}
globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } {
let downBps = 0;
let activeCount = 0;
let queuedCount = 0;
for (const t of this.tasks.values()) {
if (ACTIVE.includes(t.state)) {
downBps += t.speedBps;
activeCount += 1;
} else if (t.state === 'queued') {
queuedCount += 1;
}
}
return { downBps, activeCount, queuedCount };
}
}
function jitter(bps: number): number {
return Math.max(0, Math.round(bps * (0.85 + Math.random() * 0.3)));
}
function filenameFromUrl(url: string): string {
try {
const path = new URL(url).pathname;
const last = path.split('/').filter(Boolean).pop();
return last && last.length > 0 ? decodeURIComponent(last) : 'download.bin';
} catch {
return 'download.bin';
}
}