| 1 | import {
|
| 2 | computeAggregates,
|
| 3 | computeComparisons,
|
| 4 | effectiveSubject,
|
| 5 | effectiveSubjectKind,
|
| 6 | rowsToMeasurements,
|
| 7 | scorecardFromCells,
|
| 8 | sha256Hex,
|
| 9 | stableId,
|
| 10 | trendSeries,
|
| 11 | upsertSubjects,
|
| 12 | verifySignature,
|
| 13 | } from "./reporting.mjs";
|
| 14 |
|
| 15 | // Re-export the pure ingest/reporting functions so tests (node --test) and
|
| 16 | // any future consumers can import them straight from the worker module.
|
| 17 | export {
|
| 18 | computeAggregates,
|
| 19 | computeComparisons,
|
| 20 | effectiveSubject,
|
| 21 | effectiveSubjectKind,
|
| 22 | rowsToMeasurements,
|
| 23 | scorecardFromCells,
|
| 24 | trendSeries,
|
| 25 | upsertSubjects,
|
| 26 | verifySignature,
|
| 27 | };
|
| 28 |
|
| 29 | export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
| 30 |
|
| 31 | export default {
|
| 32 | async fetch(request, env) {
|
| 33 | const url = new URL(request.url);
|
| 34 | const { pathname } = url;
|
| 35 |
|
| 36 | if (request.method === "GET") {
|
| 37 | if (pathname === "/health") {
|
| 38 | return json({ ok: true, service: "oak-benchmark-upload" });
|
| 39 | }
|
| 40 | if (pathname === "/api/scorecard") {
|
| 41 | return getScorecard(env);
|
| 42 | }
|
| 43 | if (pathname === "/api/trends") {
|
| 44 | return getTrends(env, url);
|
| 45 | }
|
| 46 | if (pathname.startsWith("/api/baseline-book/")) {
|
| 47 | return getBaselineBook(env, decodeURIComponent(pathname.slice("/api/baseline-book/".length)));
|
| 48 | }
|
| 49 | if (pathname === "/api/verifications") {
|
| 50 | return getVerifications(env);
|
| 51 | }
|
| 52 | return json({ ok: false, error: "not_found" }, 404);
|
| 53 | }
|
| 54 |
|
| 55 | if (request.method !== "POST") {
|
| 56 | return json({ ok: false, error: "not_found" }, 404);
|
| 57 | }
|
| 58 |
|
| 59 | if (pathname === "/webhook/github") {
|
| 60 | return handleGithubWebhook(request, env);
|
| 61 | }
|
| 62 |
|
| 63 | const authError = authorize(request, env);
|
| 64 | if (authError) {
|
| 65 | return authError;
|
| 66 | }
|
| 67 |
|
| 68 | if (pathname === "/api/scorecard") {
|
| 69 | return postScorecard(request, env);
|
| 70 | }
|
| 71 | if (pathname === "/api/baseline-book") {
|
| 72 | return postBaselineBook(request, env);
|
| 73 | }
|
| 74 | if (pathname === "/api/verifications") {
|
| 75 | return postVerification(request, env);
|
| 76 | }
|
| 77 | if (["/", "/upload", "/api/upload"].includes(pathname)) {
|
| 78 | return handleUpload(request, env);
|
| 79 | }
|
| 80 | return json({ ok: false, error: "not_found" }, 404);
|
| 81 | },
|
| 82 | };
|
| 83 |
|
| 84 | // --- Upload + ingest --------------------------------------------------------
|
| 85 |
|
| 86 | async function handleUpload(request, env) {
|
| 87 | const contentLength = Number(request.headers.get("content-length") || "0");
|
| 88 | if (contentLength > MAX_UPLOAD_BYTES) {
|
| 89 | return json({ ok: false, error: "upload_too_large", max_bytes: MAX_UPLOAD_BYTES }, 413);
|
| 90 | }
|
| 91 |
|
| 92 | const body = await request.text();
|
| 93 | const bodyBytes = new TextEncoder().encode(body);
|
| 94 | if (bodyBytes.byteLength > MAX_UPLOAD_BYTES) {
|
| 95 | return json({ ok: false, error: "upload_too_large", max_bytes: MAX_UPLOAD_BYTES }, 413);
|
| 96 | }
|
| 97 |
|
| 98 | let parsed;
|
| 99 | try {
|
| 100 | parsed = parseJsonl(body);
|
| 101 | } catch (error) {
|
| 102 | return json({ ok: false, error: "invalid_jsonl", detail: String(error.message || error) }, 400);
|
| 103 | }
|
| 104 |
|
| 105 | const firstRow = parsed.firstRow;
|
| 106 | const benchId = stringValue(firstRow.bench_id);
|
| 107 | const profile = stringValue(firstRow.profile);
|
| 108 | const timestampUtc = stringValue(firstRow.timestamp_utc || firstRow.bench_id);
|
| 109 |
|
| 110 | if (!benchId || !profile) {
|
| 111 | return json({ ok: false, error: "missing_required_metadata", required: ["bench_id", "profile"] }, 400);
|
| 112 | }
|
| 113 |
|
| 114 | const sha256 = await sha256Hex(body);
|
| 115 | const r2Key = objectKey(profile, timestampUtc, benchId, sha256);
|
| 116 | const now = new Date().toISOString();
|
| 117 |
|
| 118 | const persistence = {
|
| 119 | r2: "not_configured",
|
| 120 | d1: "not_configured",
|
| 121 | };
|
| 122 |
|
| 123 | if (env.OAK_BENCH_RESULTS && typeof env.OAK_BENCH_RESULTS.put === "function") {
|
| 124 | await env.OAK_BENCH_RESULTS.put(r2Key, body, {
|
| 125 | httpMetadata: { contentType: "application/x-ndjson; charset=utf-8" },
|
| 126 | customMetadata: {
|
| 127 | bench_id: benchId,
|
| 128 | profile,
|
| 129 | timestamp_utc: timestampUtc,
|
| 130 | sha256,
|
| 131 | row_count: String(parsed.rowCount),
|
| 132 | source_oak_hash: stringValue(firstRow.source && firstRow.source.oak_hash) || "",
|
| 133 | host: stringValue(firstRow.host) || "",
|
| 134 | machine: stringValue(firstRow.machine) || "",
|
| 135 | },
|
| 136 | });
|
| 137 | persistence.r2 = "stored";
|
| 138 | }
|
| 139 |
|
| 140 | if (env.OAK_BENCH_DB && typeof env.OAK_BENCH_DB.prepare === "function") {
|
| 141 | await persistUploadMetadata(env.OAK_BENCH_DB, {
|
| 142 | firstRow,
|
| 143 | r2Key,
|
| 144 | sha256,
|
| 145 | rowCount: parsed.rowCount,
|
| 146 | byteCount: bodyBytes.byteLength,
|
| 147 | contentType: request.headers.get("content-type") || "application/x-ndjson",
|
| 148 | now,
|
| 149 | });
|
| 150 | persistence.d1 = "upload_metadata_recorded";
|
| 151 |
|
| 152 | const runKey = `${benchId}:${profile}`;
|
| 153 | const ingested = await ingestRows(env.OAK_BENCH_DB, parsed.rows, runKey, profile, now);
|
| 154 | persistence.d1 = "ingested";
|
| 155 | persistence.measurements = ingested.measurements;
|
| 156 | persistence.subjects = ingested.subjects;
|
| 157 | persistence.aggregates = ingested.aggregates;
|
| 158 | persistence.comparisons = ingested.comparisons;
|
| 159 | persistence.skipped_no_subject = ingested.skipped_no_subject;
|
| 160 | }
|
| 161 |
|
| 162 | return json({
|
| 163 | ok: true,
|
| 164 | bench_id: benchId,
|
| 165 | profile,
|
| 166 | row_count: parsed.rowCount,
|
| 167 | sha256,
|
| 168 | r2_key: r2Key,
|
| 169 | persistence,
|
| 170 | });
|
| 171 | }
|
| 172 |
|
| 173 | /**
|
| 174 | * The four ingest steps, all computed by pure functions in reporting.mjs:
|
| 175 | * 1. Upsert run-scoped subjects keyed (subject, subject_kind, binary_sha256).
|
| 176 | * 2. Insert each JSONL row into measurements (null-preserving projection).
|
| 177 | * 3. Per-group p50/p90/p95/p99 nearest-rank latency aggregates + averages.
|
| 178 | * 4. Target-vs-git comparison rows (delta/slower_pct/ratio per reporting.py).
|
| 179 | *
|
| 180 | * Exported so tests can drive the full ingest flow against a stub D1
|
| 181 | * binding (see reporting_parity.test.mjs re-upload test).
|
| 182 | */
|
| 183 | export async function ingestRows(db, rows, runKey, profile, now) {
|
| 184 | const firstRow = rows[0] || {};
|
| 185 | const subjectDetails = firstRow.subject_details && typeof firstRow.subject_details === "object"
|
| 186 | ? firstRow.subject_details
|
| 187 | : {};
|
| 188 | const subjectVersions = firstRow.subject_versions && typeof firstRow.subject_versions === "object"
|
| 189 | ? firstRow.subject_versions
|
| 190 | : {};
|
| 191 |
|
| 192 | // Step 1: subjects. Identity is the deterministic subject_id, which
|
| 193 | // stableId-encodes (run_key, effective subject, kind, binary_sha256): two
|
| 194 | // binary shas for one subject name in a run stay two distinct subject
|
| 195 | // records (schema_v2.sql documents that schema.sql's inline
|
| 196 | // UNIQUE(run_key, name) is superseded by subject_id identity). Re-uploads
|
| 197 | // hash to the same id and take the conflict path, so no read-back remap is
|
| 198 | // needed. Rows with no subject identity (no subject AND no platform) are
|
| 199 | // counted in skipped_no_subject, never silently dropped.
|
| 200 | const { records: subjects, idsByKey, skipped } = await upsertSubjects(rows, runKey, subjectDetails, subjectVersions);
|
| 201 | await batched(db, subjects.map((subject) => db.prepare(`
|
| 202 | INSERT INTO subjects (subject_id, run_key, name, kind, label, version, binary_path, metadata_json)
|
| 203 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 204 | ON CONFLICT(subject_id) DO UPDATE SET
|
| 205 | name = excluded.name,
|
| 206 | kind = excluded.kind,
|
| 207 | label = excluded.label,
|
| 208 | version = excluded.version,
|
| 209 | binary_path = excluded.binary_path,
|
| 210 | metadata_json = excluded.metadata_json
|
| 211 | `).bind(
|
| 212 | subject.subject_id,
|
| 213 | subject.run_key,
|
| 214 | subject.name,
|
| 215 | subject.kind,
|
| 216 | subject.label,
|
| 217 | subject.version,
|
| 218 | subject.binary_path,
|
| 219 | subject.metadata_json,
|
| 220 | )));
|
| 221 |
|
| 222 | // Step 2: measurements (idempotent re-upload: replace this run's rows).
|
| 223 | // Derived tables (aggregates, comparisons) are deleted alongside the
|
| 224 | // measurements: they are recomputed from this upload's measurements, so a
|
| 225 | // re-upload with FEWER groups must not leave stale rows from a previous
|
| 226 | // upload behind (upserts alone only overwrite groups that still exist).
|
| 227 | const ingestReport = { skipped_no_subject: skipped };
|
| 228 | const measurements = rowsToMeasurements(rows, runKey, now, idsByKey, ingestReport);
|
| 229 | const aggregates = computeAggregates(measurements, profile, now);
|
| 230 | const comparisons = computeComparisons(aggregates);
|
| 231 |
|
| 232 | // Keep the destructive run rewrite in one D1 batch. D1 batches are
|
| 233 | // transactional, so a mid-ingest insert failure must not commit only the
|
| 234 | // deletes and leave the run empty or partially rebuilt. Subject upserts
|
| 235 | // above are non-destructive and can safely remain outside this rewrite.
|
| 236 | const runRewriteStatements = [
|
| 237 | db.prepare("DELETE FROM measurements WHERE run_key = ?").bind(runKey),
|
| 238 | db.prepare("DELETE FROM aggregates WHERE run_key = ?").bind(runKey),
|
| 239 | db.prepare("DELETE FROM comparisons WHERE run_key = ?").bind(runKey),
|
| 240 | ...measurements.map((m) => db.prepare(`
|
| 241 | INSERT INTO measurements (
|
| 242 | run_key, subject_id, scenario, operation, run_index, is_canonical,
|
| 243 | elapsed_ms, estimated_tokens_total, estimated_tokens_input, estimated_tokens_output,
|
| 244 | token_source, raw_output_bytes, stdout_bytes, stderr_bytes, output_truncated,
|
| 245 | tool_call_count, vcs_tool_call_count, terminal_tool_call_count, returncode,
|
| 246 | command_json, stderr, row_json, created_at,
|
| 247 | runner_id, runner_class, load_tier, netshape_profile
|
| 248 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 249 | `).bind(
|
| 250 | m.run_key, m.subject_id, m.scenario, m.operation, m.run_index, m.is_canonical,
|
| 251 | m.elapsed_ms, m.estimated_tokens_total, m.estimated_tokens_input, m.estimated_tokens_output,
|
| 252 | m.token_source, m.raw_output_bytes, m.stdout_bytes, m.stderr_bytes, m.output_truncated,
|
| 253 | m.tool_call_count, m.vcs_tool_call_count, m.terminal_tool_call_count, m.returncode,
|
| 254 | m.command_json, m.stderr, m.row_json, m.created_at,
|
| 255 | m.runner_id, m.runner_class, m.load_tier, m.netshape_profile,
|
| 256 | )),
|
| 257 | // Step 3: aggregates (plain INSERTs: the run's rows are deleted in the
|
| 258 | // same batch, so there is nothing to conflict with after commit).
|
| 259 | ...aggregates.map((a) => db.prepare(`
|
| 260 | INSERT INTO aggregates (
|
| 261 | run_key, subject_id, profile, scenario, operation, is_canonical,
|
| 262 | sample_count, failure_count, failure_rate,
|
| 263 | p50_ms, p90_ms, p95_ms, p99_ms, mean_ms, min_ms, max_ms,
|
| 264 | avg_tokens, avg_output_bytes, truncated_count, avg_tool_calls, updated_at
|
| 265 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 266 | `).bind(
|
| 267 | a.run_key, a.subject_id, a.profile, a.scenario, a.operation, a.is_canonical,
|
| 268 | a.sample_count, a.failure_count, a.failure_rate,
|
| 269 | a.p50_ms, a.p90_ms, a.p95_ms, a.p99_ms, a.mean_ms, a.min_ms, a.max_ms,
|
| 270 | a.avg_tokens, a.avg_output_bytes, a.truncated_count, a.avg_tool_calls, a.updated_at,
|
| 271 | )),
|
| 272 | // Step 4: comparisons (plain INSERTs, same reasoning as step 3).
|
| 273 | ...comparisons.map((c) => db.prepare(`
|
| 274 | INSERT INTO comparisons (
|
| 275 | run_key, target_subject_id, baseline_subject_id, baseline_kind, profile,
|
| 276 | scenario, operation,
|
| 277 | p50_delta_ms, p50_slower_pct, p90_delta_ms, p90_slower_pct,
|
| 278 | p99_delta_ms, p99_slower_pct,
|
| 279 | avg_token_delta, token_higher_pct, avg_output_byte_delta, output_byte_higher_pct,
|
| 280 | avg_tool_call_delta, tool_call_higher_pct, failure_rate_delta_pp,
|
| 281 | threshold_pct, min_delta_ms, is_regression, regression_dimensions, state, severity_score, updated_at
|
| 282 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 283 | `).bind(
|
| 284 | c.run_key, c.target_subject_id, c.baseline_subject_id, c.baseline_kind, c.profile,
|
| 285 | c.scenario, c.operation,
|
| 286 | c.p50_delta_ms, c.p50_slower_pct, c.p90_delta_ms, c.p90_slower_pct,
|
| 287 | c.p99_delta_ms, c.p99_slower_pct,
|
| 288 | c.avg_token_delta, c.token_higher_pct, c.avg_output_byte_delta, c.output_byte_higher_pct,
|
| 289 | c.avg_tool_call_delta, c.tool_call_higher_pct, c.failure_rate_delta_pp,
|
| 290 | c.threshold_pct, c.min_delta_ms, c.is_regression,
|
| 291 | c.regression_dimensions == null ? null : JSON.stringify(c.regression_dimensions),
|
| 292 | c.state, c.severity_score, c.updated_at ?? now,
|
| 293 | )),
|
| 294 | ];
|
| 295 |
|
| 296 | await db.batch(runRewriteStatements);
|
| 297 |
|
| 298 | return {
|
| 299 | subjects: subjects.length,
|
| 300 | measurements: measurements.length,
|
| 301 | aggregates: aggregates.length,
|
| 302 | comparisons: comparisons.length,
|
| 303 | skipped_no_subject: ingestReport.skipped_no_subject,
|
| 304 | };
|
| 305 | }
|
| 306 |
|
| 307 | async function batched(db, statements, chunkSize = 50) {
|
| 308 | for (let index = 0; index < statements.length; index += chunkSize) {
|
| 309 | const chunk = statements.slice(index, index + chunkSize);
|
| 310 | if (chunk.length) {
|
| 311 | await db.batch(chunk);
|
| 312 | }
|
| 313 | }
|
| 314 | }
|
| 315 |
|
| 316 | // --- API endpoints (JSON; public pages consume these) -----------------------
|
| 317 |
|
| 318 | async function getScorecard(env) {
|
| 319 | const db = requireDb(env);
|
| 320 | if (!db) {
|
| 321 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 322 | }
|
| 323 | const row = await db.prepare(
|
| 324 | "SELECT bench_id, profile, scorecard_json, created_utc FROM scorecards ORDER BY id DESC LIMIT 1",
|
| 325 | ).first();
|
| 326 | if (!row) {
|
| 327 | return json({ ok: false, error: "no_scorecard" }, 404);
|
| 328 | }
|
| 329 | const scorecard = JSON.parse(row.scorecard_json);
|
| 330 | const cells = Array.isArray(scorecard) ? scorecard : scorecard.cells || scorecard.targets || [];
|
| 331 | return json({
|
| 332 | ok: true,
|
| 333 | bench_id: row.bench_id,
|
| 334 | profile: row.profile,
|
| 335 | created_utc: row.created_utc,
|
| 336 | summary: scorecardFromCells(cells),
|
| 337 | scorecard,
|
| 338 | });
|
| 339 | }
|
| 340 |
|
| 341 | async function postScorecard(request, env) {
|
| 342 | const db = requireDb(env);
|
| 343 | if (!db) {
|
| 344 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 345 | }
|
| 346 | const payload = await readJson(request);
|
| 347 | if (payload instanceof Response) {
|
| 348 | return payload;
|
| 349 | }
|
| 350 | const now = new Date().toISOString();
|
| 351 | await db.prepare(
|
| 352 | "INSERT INTO scorecards (bench_id, profile, scorecard_json, created_utc) VALUES (?, ?, ?, ?)",
|
| 353 | ).bind(
|
| 354 | nullableString(payload.bench_id),
|
| 355 | nullableString(payload.profile),
|
| 356 | JSON.stringify(payload.scorecard ?? payload),
|
| 357 | now,
|
| 358 | ).run();
|
| 359 | return json({ ok: true, created_utc: now });
|
| 360 | }
|
| 361 |
|
| 362 | async function getTrends(env, url) {
|
| 363 | const db = requireDb(env);
|
| 364 | if (!db) {
|
| 365 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 366 | }
|
| 367 | const scenario = url.searchParams.get("scenario");
|
| 368 | const operation = url.searchParams.get("operation");
|
| 369 | const metric = url.searchParams.get("metric") || "p50";
|
| 370 | if (!scenario || !operation) {
|
| 371 | return json({ ok: false, error: "missing_params", required: ["scenario", "operation"] }, 400);
|
| 372 | }
|
| 373 | const rows = await db.prepare(`
|
| 374 | SELECT r.timestamp_utc, a.run_key, s.name AS subject, a.scenario, a.operation,
|
| 375 | a.p50_ms, a.p90_ms, a.p95_ms, a.p99_ms, a.mean_ms,
|
| 376 | a.avg_tokens, a.avg_output_bytes, a.avg_tool_calls, a.failure_rate
|
| 377 | FROM aggregates a
|
| 378 | JOIN benchmark_runs r ON r.run_key = a.run_key
|
| 379 | JOIN subjects s ON s.subject_id = a.subject_id
|
| 380 | WHERE a.scenario = ? AND a.operation = ?
|
| 381 | ORDER BY r.timestamp_utc ASC
|
| 382 | LIMIT 2000
|
| 383 | `).bind(scenario, operation).all();
|
| 384 | const series = trendSeries(rows.results || [], { scenario, operation, metric });
|
| 385 | if (!series) {
|
| 386 | return json({ ok: false, error: "unknown_metric", metric }, 400);
|
| 387 | }
|
| 388 | return json({ ok: true, ...series });
|
| 389 | }
|
| 390 |
|
| 391 | async function getBaselineBook(env, bookId) {
|
| 392 | const db = requireDb(env);
|
| 393 | if (!db) {
|
| 394 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 395 | }
|
| 396 | if (!bookId) {
|
| 397 | return json({ ok: false, error: "missing_book_id" }, 400);
|
| 398 | }
|
| 399 | const row = await db.prepare(
|
| 400 | "SELECT book_id, runner_class, manifest_json, book_json, created_utc FROM baseline_books WHERE book_id = ?",
|
| 401 | ).bind(bookId).first();
|
| 402 | if (!row) {
|
| 403 | return json({ ok: false, error: "not_found", book_id: bookId }, 404);
|
| 404 | }
|
| 405 | return json({
|
| 406 | ok: true,
|
| 407 | book_id: row.book_id,
|
| 408 | runner_class: row.runner_class,
|
| 409 | created_utc: row.created_utc,
|
| 410 | manifest: row.manifest_json ? JSON.parse(row.manifest_json) : null,
|
| 411 | book: JSON.parse(row.book_json),
|
| 412 | });
|
| 413 | }
|
| 414 |
|
| 415 | async function postBaselineBook(request, env) {
|
| 416 | const db = requireDb(env);
|
| 417 | if (!db) {
|
| 418 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 419 | }
|
| 420 | const payload = await readJson(request);
|
| 421 | if (payload instanceof Response) {
|
| 422 | return payload;
|
| 423 | }
|
| 424 | const bookId = stringValue(payload.book_id);
|
| 425 | if (!bookId || typeof payload.book !== "object" || payload.book === null) {
|
| 426 | return json({ ok: false, error: "missing_required_fields", required: ["book_id", "book"] }, 400);
|
| 427 | }
|
| 428 | const now = new Date().toISOString();
|
| 429 | await db.prepare(`
|
| 430 | INSERT INTO baseline_books (book_id, runner_class, manifest_json, book_json, created_utc)
|
| 431 | VALUES (?, ?, ?, ?, ?)
|
| 432 | ON CONFLICT(book_id) DO UPDATE SET
|
| 433 | runner_class = excluded.runner_class,
|
| 434 | manifest_json = excluded.manifest_json,
|
| 435 | book_json = excluded.book_json,
|
| 436 | created_utc = excluded.created_utc
|
| 437 | `).bind(
|
| 438 | bookId,
|
| 439 | nullableString(payload.runner_class),
|
| 440 | payload.manifest === undefined || payload.manifest === null ? null : JSON.stringify(payload.manifest),
|
| 441 | JSON.stringify(payload.book),
|
| 442 | now,
|
| 443 | ).run();
|
| 444 | return json({ ok: true, book_id: bookId, created_utc: now });
|
| 445 | }
|
| 446 |
|
| 447 | async function getVerifications(env) {
|
| 448 | const db = requireDb(env);
|
| 449 | if (!db) {
|
| 450 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 451 | }
|
| 452 | const rows = await db.prepare(
|
| 453 | "SELECT id, bundle_sha256, verdict, detail_json, created_utc FROM verifications ORDER BY id DESC LIMIT 100",
|
| 454 | ).all();
|
| 455 | return json({
|
| 456 | ok: true,
|
| 457 | verifications: (rows.results || []).map((row) => ({
|
| 458 | id: row.id,
|
| 459 | bundle_sha256: row.bundle_sha256,
|
| 460 | verdict: row.verdict,
|
| 461 | detail: row.detail_json ? JSON.parse(row.detail_json) : null,
|
| 462 | created_utc: row.created_utc,
|
| 463 | })),
|
| 464 | });
|
| 465 | }
|
| 466 |
|
| 467 | async function postVerification(request, env) {
|
| 468 | const db = requireDb(env);
|
| 469 | if (!db) {
|
| 470 | return json({ ok: false, error: "d1_not_configured" }, 503);
|
| 471 | }
|
| 472 | const payload = await readJson(request);
|
| 473 | if (payload instanceof Response) {
|
| 474 | return payload;
|
| 475 | }
|
| 476 | const bundleSha = stringValue(payload.bundle_sha256);
|
| 477 | const verdict = stringValue(payload.verdict);
|
| 478 | if (!bundleSha || !verdict) {
|
| 479 | return json({ ok: false, error: "missing_required_fields", required: ["bundle_sha256", "verdict"] }, 400);
|
| 480 | }
|
| 481 | const now = new Date().toISOString();
|
| 482 | await db.prepare(
|
| 483 | "INSERT INTO verifications (bundle_sha256, verdict, detail_json, created_utc) VALUES (?, ?, ?, ?)",
|
| 484 | ).bind(
|
| 485 | bundleSha,
|
| 486 | verdict,
|
| 487 | payload.detail === undefined || payload.detail === null ? null : JSON.stringify(payload.detail),
|
| 488 | now,
|
| 489 | ).run();
|
| 490 | return json({ ok: true, created_utc: now });
|
| 491 | }
|
| 492 |
|
| 493 | // --- GitHub webhook (HMAC-verified, recorded into webhook_events) -----------
|
| 494 |
|
| 495 | async function handleGithubWebhook(request, env) {
|
| 496 | if (!env.GITHUB_WEBHOOK_SECRET) {
|
| 497 | return json({ ok: false, error: "webhook_not_configured" }, 503);
|
| 498 | }
|
| 499 | const body = await request.text();
|
| 500 | const valid = await verifySignature(
|
| 501 | env.GITHUB_WEBHOOK_SECRET,
|
| 502 | body,
|
| 503 | request.headers.get("x-hub-signature-256") || "",
|
| 504 | );
|
| 505 | if (!valid) {
|
| 506 | return json({ ok: false, error: "invalid_signature" }, 401);
|
| 507 | }
|
| 508 | const db = requireDb(env);
|
| 509 | if (db) {
|
| 510 | await db.prepare(
|
| 511 | "INSERT INTO webhook_events (event, delivery_id, payload_json, signature_valid, created_utc) VALUES (?, ?, ?, 1, ?)",
|
| 512 | ).bind(
|
| 513 | nullableString(request.headers.get("x-github-event")),
|
| 514 | nullableString(request.headers.get("x-github-delivery")),
|
| 515 | body,
|
| 516 | new Date().toISOString(),
|
| 517 | ).run();
|
| 518 | }
|
| 519 | return json({ ok: true, recorded: Boolean(db) });
|
| 520 | }
|
| 521 |
|
| 522 | // --- helpers -----------------------------------------------------------------
|
| 523 |
|
| 524 | function requireDb(env) {
|
| 525 | return env.OAK_BENCH_DB && typeof env.OAK_BENCH_DB.prepare === "function" ? env.OAK_BENCH_DB : null;
|
| 526 | }
|
| 527 |
|
| 528 | async function readJson(request) {
|
| 529 | try {
|
| 530 | const payload = await request.json();
|
| 531 | if (!payload || typeof payload !== "object") {
|
| 532 | throw new Error("payload must be a JSON object");
|
| 533 | }
|
| 534 | return payload;
|
| 535 | } catch (error) {
|
| 536 | return json({ ok: false, error: "invalid_json", detail: String(error.message || error) }, 400);
|
| 537 | }
|
| 538 | }
|
| 539 |
|
| 540 | function authorize(request, env) {
|
| 541 | const uploadToken = stringValue(env.UPLOAD_TOKEN).trim();
|
| 542 | if (!uploadToken) {
|
| 543 | return json({ ok: false, error: "upload_token_not_configured" }, 503);
|
| 544 | }
|
| 545 | const expected = `Bearer ${uploadToken}`;
|
| 546 | if (request.headers.get("authorization") === expected) {
|
| 547 | return null;
|
| 548 | }
|
| 549 | return json({ ok: false, error: "unauthorized" }, 401);
|
| 550 | }
|
| 551 |
|
| 552 | function parseJsonl(text) {
|
| 553 | const lines = text.split(/\r?\n/);
|
| 554 | const rows = [];
|
| 555 |
|
| 556 | for (let index = 0; index < lines.length; index += 1) {
|
| 557 | const line = lines[index].trim();
|
| 558 | if (!line) {
|
| 559 | continue;
|
| 560 | }
|
| 561 | const row = JSON.parse(line);
|
| 562 | if (!row || typeof row !== "object" || Array.isArray(row)) {
|
| 563 | throw new Error(`line ${index + 1} is not a JSON object`);
|
| 564 | }
|
| 565 | rows.push(row);
|
| 566 | }
|
| 567 |
|
| 568 | if (rows.length === 0) {
|
| 569 | throw new Error("upload contained no JSON rows");
|
| 570 | }
|
| 571 |
|
| 572 | return { firstRow: rows[0], rows, rowCount: rows.length };
|
| 573 | }
|
| 574 |
|
| 575 | async function persistUploadMetadata(db, upload) {
|
| 576 | const row = upload.firstRow;
|
| 577 | const source = row.source && typeof row.source === "object" ? row.source : {};
|
| 578 | const subjectVersions = row.subject_versions && typeof row.subject_versions === "object"
|
| 579 | ? row.subject_versions
|
| 580 | : {};
|
| 581 | const subjectDetails = row.subject_details && typeof row.subject_details === "object"
|
| 582 | ? row.subject_details
|
| 583 | : {};
|
| 584 | const hostId = await stableId([
|
| 585 | stringValue(row.host),
|
| 586 | stringValue(row.platform),
|
| 587 | stringValue(row.machine),
|
| 588 | stringValue(row.python),
|
| 589 | ].join("\u001f"));
|
| 590 |
|
| 591 | await db.batch([
|
| 592 | db.prepare(`
|
| 593 | INSERT INTO hosts (
|
| 594 | host_id, host, platform, machine, python, metadata_json, first_seen_at, last_seen_at
|
| 595 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 596 | ON CONFLICT(host_id) DO UPDATE SET
|
| 597 | last_seen_at = excluded.last_seen_at,
|
| 598 | metadata_json = excluded.metadata_json
|
| 599 | `).bind(
|
| 600 | hostId,
|
| 601 | nullableString(row.host),
|
| 602 | nullableString(row.platform),
|
| 603 | nullableString(row.machine),
|
| 604 | nullableString(row.python),
|
| 605 | JSON.stringify({ source: "worker-upload" }),
|
| 606 | upload.now,
|
| 607 | upload.now,
|
| 608 | ),
|
| 609 | db.prepare(`
|
| 610 | INSERT INTO uploads (
|
| 611 | r2_key, sha256, bench_id, profile, row_count, byte_count, content_type, status, uploaded_at
|
| 612 | ) VALUES (?, ?, ?, ?, ?, ?, ?, 'accepted', ?)
|
| 613 | ON CONFLICT(r2_key) DO UPDATE SET
|
| 614 | row_count = excluded.row_count,
|
| 615 | byte_count = excluded.byte_count,
|
| 616 | status = excluded.status,
|
| 617 | uploaded_at = excluded.uploaded_at
|
| 618 | `).bind(
|
| 619 | upload.r2Key,
|
| 620 | upload.sha256,
|
| 621 | stringValue(row.bench_id),
|
| 622 | stringValue(row.profile),
|
| 623 | upload.rowCount,
|
| 624 | upload.byteCount,
|
| 625 | upload.contentType,
|
| 626 | upload.now,
|
| 627 | ),
|
| 628 | db.prepare(`
|
| 629 | INSERT INTO benchmark_runs (
|
| 630 | run_key, bench_id, timestamp_utc, profile, source_oak_hash, source_oak_repo,
|
| 631 | source_oak_status, host_id, raw_r2_key, benchmark_track, command_semantics_version,
|
| 632 | row_count, subject_versions_json, subject_details_json, uploaded_at
|
| 633 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 634 | ON CONFLICT(run_key) DO UPDATE SET
|
| 635 | raw_r2_key = excluded.raw_r2_key,
|
| 636 | benchmark_track = excluded.benchmark_track,
|
| 637 | command_semantics_version = excluded.command_semantics_version,
|
| 638 | row_count = excluded.row_count,
|
| 639 | subject_versions_json = excluded.subject_versions_json,
|
| 640 | subject_details_json = excluded.subject_details_json,
|
| 641 | uploaded_at = excluded.uploaded_at
|
| 642 | `).bind(
|
| 643 | `${stringValue(row.bench_id)}:${stringValue(row.profile)}`,
|
| 644 | stringValue(row.bench_id),
|
| 645 | stringValue(row.timestamp_utc || row.bench_id),
|
| 646 | stringValue(row.profile),
|
| 647 | nullableString(source.oak_hash),
|
| 648 | nullableString(source.oak_repo),
|
| 649 | nullableString(source.oak_status),
|
| 650 | hostId,
|
| 651 | upload.r2Key,
|
| 652 | nullableString(row.benchmark_track),
|
| 653 | nullableString(row.command_semantics_version),
|
| 654 | upload.rowCount,
|
| 655 | JSON.stringify(subjectVersions),
|
| 656 | JSON.stringify(subjectDetails),
|
| 657 | upload.now,
|
| 658 | ),
|
| 659 | ]);
|
| 660 | }
|
| 661 |
|
| 662 | function objectKey(profile, timestampUtc, benchId, sha256) {
|
| 663 | const date = isoDate(timestampUtc);
|
| 664 | return [
|
| 665 | "raw/v1",
|
| 666 | `profile=${sanitizePathPart(profile)}`,
|
| 667 | `date=${date}`,
|
| 668 | `bench_id=${sanitizePathPart(benchId)}`,
|
| 669 | `sha256=${sha256.slice(0, 12)}`,
|
| 670 | "results.jsonl",
|
| 671 | ].join("/");
|
| 672 | }
|
| 673 |
|
| 674 | function isoDate(timestampUtc) {
|
| 675 | const compact = String(timestampUtc || "");
|
| 676 | const match = compact.match(/^(\d{4})(\d{2})(\d{2})T/);
|
| 677 | if (match) {
|
| 678 | return `${match[1]}-${match[2]}-${match[3]}`;
|
| 679 | }
|
| 680 | const parsed = new Date(compact);
|
| 681 | if (!Number.isNaN(parsed.getTime())) {
|
| 682 | return parsed.toISOString().slice(0, 10);
|
| 683 | }
|
| 684 | return new Date().toISOString().slice(0, 10);
|
| 685 | }
|
| 686 |
|
| 687 | function sanitizePathPart(value) {
|
| 688 | return String(value || "unknown").replace(/[^A-Za-z0-9._=-]/g, "_");
|
| 689 | }
|
| 690 |
|
| 691 | function stringValue(value) {
|
| 692 | if (value === null || value === undefined) {
|
| 693 | return "";
|
| 694 | }
|
| 695 | return String(value);
|
| 696 | }
|
| 697 |
|
| 698 | function nullableString(value) {
|
| 699 | const text = stringValue(value);
|
| 700 | return text ? text : null;
|
| 701 | }
|
| 702 |
|
| 703 | function json(payload, status = 200) {
|
| 704 | return new Response(JSON.stringify(payload, null, 2), {
|
| 705 | status,
|
| 706 | headers: { "content-type": "application/json; charset=utf-8" },
|
| 707 | });
|
| 708 | }
|