@hackerrank/astra-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,6 +126,29 @@ price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) ar
126
126
  marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
127
127
  upload fails the local tarball is kept.
128
128
 
129
+ ### HTML report
130
+
131
+ Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
132
+ `bench/summary.json` and a self-contained `bench/report.html` dashboard —
133
+ no server, no build step, no external JS: open it straight from `file://` or
134
+ from inside the `bench-*.tgz` tarball. It has a leaderboard, a model × task
135
+ pass-rate heatmap, cost/token charts, per-step trend lines, and an
136
+ expandable command timeline for every run.
137
+
138
+ Regenerate it on demand (e.g. after manually editing/pruning `bench/`) with:
139
+
140
+ ```bash
141
+ astra --report # rebuild ./bench/{summary.json,report.html}
142
+ astra --report --bench-root ./other-bench
143
+ ```
144
+
145
+ `summary.json` follows the `astra-bench-1` schema: `kpis` (totals), a tidy
146
+ `leaderboard[]` (one row per model×reasoning), `matrix[]` (model×reasoning×task
147
+ pass@k), `runs[]` (every attempt, with a compact per-step `timeline`), and
148
+ `step_series[]` (token/cost distributions by step, for the trend charts). Each
149
+ run folder also gets a standalone `run.json` for drill-down without loading
150
+ the full `trajectory.json`.
151
+
129
152
  ### Sessions
130
153
 
131
154
  Every run (either mode) is saved under `~/.astra/sessions/<id>.json`.
@@ -153,6 +176,8 @@ credits — that's a quota issue, not a bug.
153
176
  --bench-root <dir> Root folder for bench runs (default: ./bench)
154
177
  --tar After a matrix, write a bench/ tarball locally
155
178
  --push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` to the URI
179
+ --report Rebuild bench/summary.json + bench/report.html from
180
+ whatever runs already exist on disk, then exit
156
181
  -C, --cwd <path> Working directory for commands (default: cwd)
157
182
  -o, --output <path> Also write trajectory JSON here (autonomous mode)
158
183
  -s, --steps <n> Step limit (default: 40)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -32,6 +32,9 @@
32
32
  * s3://astra-bench-results/<timestamp>/
33
33
  * --push-uri <s3-uri> Override the S3 push destination
34
34
  * --tar After a matrix, write a bench/ tarball locally
35
+ * --report Rebuild bench/summary.json + bench/report.html
36
+ * from whatever runs already exist on disk, then
37
+ * exit (no model/API key needed).
35
38
  * -t, --task <text> Task text -> bench mode (run to completion)
36
39
  * -f, --task-file <path> Read task text from a file -> bench mode
37
40
  * -p, --path <dir> Task directory (copied into an isolated bench
@@ -94,6 +97,7 @@ import {
94
97
  defaultPushUri,
95
98
  benchRoot,
96
99
  } from "./bench.js";
100
+ import { refreshReport } from "./report.js";
97
101
 
98
102
  /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
99
103
  function reasoningKwargs(level) {
@@ -115,6 +119,7 @@ function parseArgs(argv) {
115
119
  "--push": "push",
116
120
  "--push-uri": "push-uri",
117
121
  "--tar": "tar",
122
+ "--report": "report",
118
123
  "--bench-root": "bench-root",
119
124
  "-C": "cwd", "--cwd": "cwd",
120
125
  "-o": "output", "--output": "output",
@@ -130,7 +135,7 @@ function parseArgs(argv) {
130
135
  "-y": "yolo", "--yolo": "yolo",
131
136
  "-h": "help", "--help": "help",
132
137
  };
133
- const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push"]);
138
+ const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
134
139
  for (let i = 2; i < argv.length; i++) {
135
140
  const key = alias[argv[i]];
136
141
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
@@ -154,6 +159,18 @@ async function main() {
154
159
 
155
160
  if (args.sessions) { printSessions(); process.exit(0); }
156
161
  if (args.help) { console.log(HELP); process.exit(0); }
162
+ if (args.report) {
163
+ try {
164
+ const res = refreshReport(args["bench-root"]);
165
+ console.error(`\x1b[1m[astra] report · ${res.runs} run(s) · ${res.models} model(s) · ${res.tasks} task(s)\x1b[0m`);
166
+ console.error(`\x1b[1m summary\x1b[0m ${res.summary}`);
167
+ console.error(`\x1b[1m html \x1b[0m ${res.html}`);
168
+ } catch (err) {
169
+ console.error(`\x1b[31m[astra] report failed: ${err.message}\x1b[0m`);
170
+ process.exit(1);
171
+ }
172
+ process.exit(0);
173
+ }
157
174
 
158
175
  // Load a session to resume (if any) to infer defaults.
159
176
  let resumeDoc = null;
@@ -384,6 +401,12 @@ async function main() {
384
401
  console.error(`\x1b[2m[astra] metrics -> ${paths.runMetrics}\x1b[0m`);
385
402
  console.error(`\x1b[2m[astra] index -> ${paths.index}\x1b[0m`);
386
403
  }
404
+ try {
405
+ const report = refreshReport(benchRun.root);
406
+ if (!quiet) console.error(`\x1b[2m[astra] report -> ${report.html}\x1b[0m`);
407
+ } catch {
408
+ // Non-fatal: report regeneration is best-effort for single runs.
409
+ }
387
410
  }
388
411
 
389
412
  if (!quiet) {
@@ -408,9 +431,50 @@ async function main() {
408
431
  console.error(`\x1b[2m[astra] session -> ${sessionId}\x1b[0m`);
409
432
  }
410
433
  if (result.submission) console.log(result.submission);
434
+
435
+ // For a single-run bench, tar + push the bench/ folder if requested.
436
+ // (Matrix runs handle this inside runBenchMatrix.)
437
+ if (benchRun) {
438
+ const links = [];
439
+ archiveAndPush(args, benchRun.root, links);
440
+ if (links.length) {
441
+ const w = Math.max(...links.map(([k]) => k.length));
442
+ console.error("");
443
+ for (const [k, v] of links) console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
444
+ }
445
+ }
446
+
411
447
  process.exit(result.exit_status === "Submitted" ? 0 : 1);
412
448
  }
413
449
 
450
+ /**
451
+ * Tar the bench/ folder and (optionally) upload it to S3. Mutates `links`
452
+ * with the resulting artifact paths/URLs and prints any failures.
453
+ * Works for both single-run and matrix bench runs.
454
+ */
455
+ function archiveAndPush(args, root, links) {
456
+ if (!(args.tar || args.push)) return;
457
+ try {
458
+ const tarball = packBench({ root });
459
+ links.push(["tarball", tarball]);
460
+ if (args.push) {
461
+ // Bare --push uses the default bucket + timestamp prefix; --push-uri
462
+ // overrides the destination.
463
+ const dest = args["push-uri"] || defaultPushUri();
464
+ try {
465
+ const pushed = pushToS3(tarball, dest);
466
+ links.push(["s3 uri", pushed.uri]);
467
+ links.push(["s3 url", pushed.url]);
468
+ } catch (err) {
469
+ console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
470
+ console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
471
+ }
472
+ }
473
+ } catch (err) {
474
+ console.error(`\x1b[31m[astra] tar failed: ${err.message}\x1b[0m`);
475
+ }
476
+ }
477
+
414
478
  /**
415
479
  * Run a multi-model / multi-reasoning / repeated bench matrix, print a
416
480
  * leaderboard, optionally tar + upload the bench/ folder, then exit.
@@ -463,29 +527,18 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
463
527
  // Collect the important artifact locations to print together at the end.
464
528
  const links = [["metrics", indexPath]];
465
529
 
466
- // Tar + optional S3 push of the whole bench/ folder.
467
- if (args.tar || args.push) {
468
- try {
469
- const tarball = packBench({ root });
470
- links.push(["tarball", tarball]);
471
- if (args.push) {
472
- // Bare --push uses the default bucket + timestamp prefix; --push-uri
473
- // overrides the destination.
474
- const dest = args["push-uri"] || defaultPushUri();
475
- try {
476
- const pushed = pushToS3(tarball, dest);
477
- links.push(["s3 uri", pushed.uri]);
478
- links.push(["s3 url", pushed.url]);
479
- } catch (err) {
480
- console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
481
- console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
482
- }
483
- }
484
- } catch (err) {
485
- console.error(`\x1b[31m[astra] tar failed: ${err.message}\x1b[0m`);
486
- }
530
+ // Rebuild the dashboard (summary.json + report.html) from every run on
531
+ // disk under this root, including runs from earlier matrix invocations.
532
+ try {
533
+ const report = refreshReport(root);
534
+ links.push(["report", report.html]);
535
+ } catch (err) {
536
+ console.error(`\x1b[33m[astra] report generation skipped: ${err.message}\x1b[0m`);
487
537
  }
488
538
 
539
+ // Tar + optional S3 push of the whole bench/ folder.
540
+ archiveAndPush(args, root, links);
541
+
489
542
  const solved = rows.filter((r) => r.resolved).length;
490
543
  console.error(`\n\x1b[1m[astra] matrix done · ${solved}/${rows.length} resolved\x1b[0m`);
491
544
 
package/src/model.js CHANGED
@@ -67,6 +67,9 @@ export class GatewayModel {
67
67
  this.costSource = null; // "reported" | "estimated" | "mixed" | null
68
68
  // Preferred token-cap parameter; swapped automatically on 400 if needed.
69
69
  this._tokenParam = "max_tokens";
70
+ // Optional AbortSignal; when set and aborted, in-flight requests are
71
+ // cancelled (used to force-quit a long model call on a second Ctrl+C).
72
+ this.signal = null;
70
73
  if (!this.apiKey) {
71
74
  throw new Error(
72
75
  "GatewayModel: no API key. Set ASTRA_GATEWAY_API_KEY or configure ~/.astra/config.json."
@@ -100,6 +103,7 @@ export class GatewayModel {
100
103
  Authorization: `Bearer ${this.apiKey}`,
101
104
  },
102
105
  body: JSON.stringify(body),
106
+ signal: this.signal || undefined,
103
107
  });
104
108
 
105
109
  if (!res.ok) {
@@ -228,8 +232,10 @@ export class GatewayModel {
228
232
 
229
233
  function isRetryable(err) {
230
234
  // Never retry classified terminal errors (auth/quota/bad-request) or context
231
- // overflow — those won't succeed on retry.
235
+ // overflow — those won't succeed on retry. Also never retry an aborted
236
+ // request (user force-quit via Ctrl+C).
232
237
  if (err instanceof GatewayError || err instanceof ContextWindowError) return false;
238
+ if (err?.name === "AbortError") return false;
233
239
  const msg = String(err?.message || err);
234
240
  return /HTTP 5\d\d|HTTP 429|ECONNRESET|ETIMEDOUT|fetch failed|network/i.test(msg);
235
241
  }
package/src/repl.js CHANGED
@@ -134,7 +134,9 @@ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
134
134
  */
135
135
  function footerLines(agent, model, yolo, mode = "agent") {
136
136
  const cols = process.stdout.columns || 100;
137
- const rule = "─".repeat(cols);
137
+ // Leave one cell of slack so a full-width rule never wraps to a new row
138
+ // (which pushes the whole footer up and looks broken on some terminals).
139
+ const rule = "─".repeat(Math.max(1, cols - 1));
138
140
 
139
141
  const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort || "off";
140
142
  const modeTag = mode === "bench" ? C.yellow("◆ bench") : C.cyan("◆ agent");
@@ -154,12 +156,13 @@ function footerLines(agent, model, yolo, mode = "agent") {
154
156
 
155
157
  return [
156
158
  C.dim(rule),
157
- modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")) + " " + C.dim("opt+←→ model · opt+↑↓ reason · tab: mode"),
159
+ modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
158
160
  C.dim(usage),
161
+ C.dim("opt+left/right: model · opt+up/down: reasoning · shift+tab: mode"),
159
162
  ];
160
163
  }
161
164
 
162
- const FOOTER_LINES = 3; // divider + repo/model + usage
165
+ const FOOTER_LINES = 4; // divider + repo/model + usage + shortcuts
163
166
  const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
164
167
  const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
165
168
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -336,6 +339,11 @@ class Screen {
336
339
  this.drawPrompt();
337
340
  }
338
341
 
342
+ /** True while the agent is working (a turn is in progress). */
343
+ isBusy() {
344
+ return this._busy || this._busyResume;
345
+ }
346
+
339
347
  /**
340
348
  * Resolve with the next full line the user types. An optional prompt label
341
349
  * replaces the default "you › " (used by the approval gate so the question
@@ -486,8 +494,12 @@ class Screen {
486
494
  this.buf = this.buf.slice(0, -1);
487
495
  this.drawPrompt();
488
496
  } else if (ch === "\x03") { // Ctrl+C
497
+ // If a prompt is pending (idle), resolve it so the loop can react.
498
+ // Also always notify the interrupt hook so a *running* agent turn can
499
+ // be aborted even when no readLine is pending.
489
500
  const r = this.resolve; this.resolve = null;
490
501
  if (r) r("__SIGINT__");
502
+ if (this.onInterrupt) this.onInterrupt();
491
503
  } else if (ch === "\x04") { // Ctrl+D
492
504
  const r = this.resolve; this.resolve = null;
493
505
  if (r) r("__EOF__");
@@ -576,6 +588,31 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
576
588
  // extra needed here, but expose a hook for symmetry / future use).
577
589
  screen.onClearInput = () => {};
578
590
 
591
+ // Ctrl+C handling. In raw mode Ctrl+C does NOT raise SIGINT — it arrives as
592
+ // a byte, so we handle it explicitly. The screen's key handler already
593
+ // resolves any pending readLine with "__SIGINT__" (idle case); here we only
594
+ // deal with the *busy* case where no readLine is pending:
595
+ // - first Ctrl+C while working -> request an abort after the current step
596
+ // - second Ctrl+C while working -> force an immediate quit
597
+ const interrupt = { aborted: false, quit: false };
598
+ screen.onInterrupt = () => {
599
+ if (!screen.isBusy()) {
600
+ // Idle: the readLine resolver was already fired with "__SIGINT__", which
601
+ // breaks the loop and quits gracefully. Nothing more to do here.
602
+ return;
603
+ }
604
+ if (interrupt.aborted) {
605
+ // Second Ctrl+C while working -> force quit: abort the in-flight model
606
+ // request so a long call unblocks immediately.
607
+ interrupt.quit = true;
608
+ log(C.red("[astra] force quitting…"));
609
+ if (model._abort) { try { model._abort.abort(); } catch {} }
610
+ return;
611
+ }
612
+ interrupt.aborted = true;
613
+ log(C.yellow("[astra] interrupting… (press Ctrl+C again to force quit)"));
614
+ };
615
+
579
616
  // Approval gate: show the command, then ask for y/n/a on the prompt row.
580
617
  agent.confirm = async (command) => {
581
618
  log(C.yellow("$ " + command));
@@ -621,6 +658,7 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
621
658
  mode = "agent"; refresh(); continue;
622
659
  }
623
660
  screen.startBusy("benching");
661
+ interrupt.aborted = false;
624
662
  try {
625
663
  await runBench(line, log);
626
664
  } catch (err) {
@@ -628,18 +666,33 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
628
666
  } finally {
629
667
  screen.stopBusy();
630
668
  }
669
+ if (interrupt.quit) break;
631
670
  mode = "agent";
632
671
  refresh();
633
672
  continue;
634
673
  }
635
674
 
636
675
  agent.addUserMessage(line);
676
+ interrupt.aborted = false; // reset per turn
677
+ // Fresh AbortController for this turn so a force-quit can cancel an
678
+ // in-flight model request. Cleared in `finally`.
679
+ model._abort = new AbortController();
680
+ model.signal = model._abort.signal;
637
681
  screen.startBusy("working");
638
682
  try {
639
- await driveUntilChat(agent, log);
683
+ await driveUntilChat(agent, log, interrupt);
684
+ } catch (err) {
685
+ if (err?.name === "AbortError" || interrupt.quit) {
686
+ // Force-quit path: swallow the abort and fall through to break.
687
+ } else {
688
+ throw err;
689
+ }
640
690
  } finally {
641
691
  screen.stopBusy();
692
+ model.signal = null;
693
+ model._abort = null;
642
694
  }
695
+ if (interrupt.quit) break; // force-quit requested via a second Ctrl+C
643
696
  const after = usageSnapshot(model);
644
697
  log(
645
698
  turnSummary({
@@ -733,10 +786,16 @@ function usageSnapshot(model) {
733
786
 
734
787
  /**
735
788
  * Take turns until the agent produces a chat reply (or terminates). Commands
736
- * run in between; their output goes back to the model automatically.
789
+ * run in between; their output goes back to the model automatically. If an
790
+ * `interrupt` signal is provided, the loop stops cleanly between turns when
791
+ * `interrupt.aborted` becomes true (Ctrl+C while working).
737
792
  */
738
- async function driveUntilChat(agent, log) {
793
+ async function driveUntilChat(agent, log, interrupt = null) {
739
794
  while (true) {
795
+ if (interrupt && interrupt.aborted) {
796
+ log(C.yellow("[astra] interrupted — returning to prompt."));
797
+ return;
798
+ }
740
799
  let turn;
741
800
  try {
742
801
  turn = await agent.runTurn();
@@ -748,6 +807,10 @@ async function driveUntilChat(agent, log) {
748
807
  throw err;
749
808
  }
750
809
 
810
+ if (interrupt && interrupt.aborted) {
811
+ log(C.yellow("[astra] interrupted — returning to prompt."));
812
+ return;
813
+ }
751
814
  if (turn.kind === "chat") {
752
815
  log("\n" + C.cyan("astra › ") + turn.content.trim());
753
816
  return;
@@ -0,0 +1,569 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>astra bench report</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1115;
10
+ --panel: #171a21;
11
+ --panel-2: #1d212b;
12
+ --border: #262b36;
13
+ --text: #e6e8ee;
14
+ --muted: #8b93a7;
15
+ --accent: #5b9dff;
16
+ --good: #3ecf8e;
17
+ --bad: #ef5a6f;
18
+ --warn: #e8b339;
19
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
23
+ a { color: var(--accent); }
24
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 28px 20px 64px; }
25
+ header.top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; flex-wrap: wrap; gap: 8px; }
26
+ header.top h1 { font-size: 20px; margin: 0; font-weight: 650; letter-spacing: .2px; }
27
+ header.top .meta { color: var(--muted); font-size: 12.5px; }
28
+ .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 20px 0 28px; }
29
+ .kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
30
+ .kpi .v { font-size: 22px; font-weight: 700; }
31
+ .kpi .l { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
32
+ section { margin: 34px 0; }
33
+ section > h2 { font-size: 15px; margin: 0 0 12px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 8px; }
34
+ section > h2 .sub { color: var(--muted); font-weight: 400; font-size: 12px; }
35
+ .grid2 { display: grid; grid-template-columns: 1.1fr 1fr; gap: 16px; }
36
+ .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
37
+ @media (max-width: 900px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
38
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
39
+ .panel h3 { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }
40
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
41
+ th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
42
+ th { color: var(--muted); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; cursor: pointer; user-select: none; }
43
+ th:hover { color: var(--text); }
44
+ th .arrow { opacity: .5; font-size: 10px; margin-left: 3px; }
45
+ tbody tr:hover { background: rgba(255,255,255,0.02); }
46
+ tbody tr.run-row { cursor: pointer; }
47
+ .pill { display: inline-block; padding: 1px 8px; border-radius: 100px; font-size: 11px; font-weight: 600; }
48
+ .pill.ok { background: rgba(62,207,142,.15); color: var(--good); }
49
+ .pill.bad { background: rgba(239,90,111,.15); color: var(--bad); }
50
+ .pill.warn { background: rgba(232,179,57,.15); color: var(--warn); }
51
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
52
+ .muted { color: var(--muted); }
53
+ .bar-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
54
+ .bar-row .lbl { width: 150px; flex: 0 0 150px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
55
+ .bar-track { flex: 1; height: 14px; background: var(--panel-2); border-radius: 6px; overflow: hidden; display: flex; }
56
+ .bar-seg { height: 100%; }
57
+ .bar-val { width: 64px; flex: 0 0 64px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
58
+ .legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: 11.5px; color: var(--muted); }
59
+ .legend .sw { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
60
+ .heat-table td { text-align: center; font-variant-numeric: tabular-nums; }
61
+ .heat-cell { display: inline-block; min-width: 44px; padding: 3px 6px; border-radius: 5px; font-size: 12px; }
62
+ details.run-detail { margin: 0; }
63
+ details.run-detail > summary { display: none; }
64
+ .timeline { margin: 0 0 18px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
65
+ .timeline .step { padding: 10px 14px; border-bottom: 1px solid var(--border); }
66
+ .timeline .step:last-child { border-bottom: none; }
67
+ .timeline .step .head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; margin-bottom: 4px; }
68
+ .timeline .step .n { color: var(--accent); font-weight: 700; font-size: 12px; }
69
+ .timeline .step .stats { color: var(--muted); font-size: 11.5px; }
70
+ .timeline .step .thought { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; }
71
+ .timeline .step pre { background: var(--panel-2); border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
72
+ .rc0 { color: var(--good); } .rcN { color: var(--bad); }
73
+ .toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
74
+ .toolbar select, .toolbar input { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 5px 9px; font-size: 12.5px; }
75
+ .toolbar label { color: var(--muted); font-size: 12px; }
76
+ footer { color: var(--muted); font-size: 11.5px; margin-top: 40px; text-align: center; }
77
+ svg text { fill: var(--muted); font-size: 10.5px; }
78
+ .axis line, .axis path { stroke: var(--border); }
79
+ .n-a { color: var(--muted); }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <div class="wrap">
84
+ <header class="top">
85
+ <h1>astra bench report</h1>
86
+ <div class="meta" id="generated"></div>
87
+ </header>
88
+
89
+ <div class="kpis" id="kpis"></div>
90
+
91
+ <section>
92
+ <h2>Leaderboard <span class="sub">one row per model · reasoning</span></h2>
93
+ <div class="grid2">
94
+ <div class="panel">
95
+ <h3>Solve rate</h3>
96
+ <div id="chart-solve"></div>
97
+ </div>
98
+ <div class="panel">
99
+ <h3>Outcomes</h3>
100
+ <div id="chart-outcomes"></div>
101
+ <div class="legend" id="legend-outcomes"></div>
102
+ </div>
103
+ </div>
104
+ <div class="panel" style="margin-top:16px;">
105
+ <table id="tbl-leaderboard"></table>
106
+ </div>
107
+ </section>
108
+
109
+ <section>
110
+ <h2>Cost &amp; tokens</h2>
111
+ <div class="grid2">
112
+ <div class="panel">
113
+ <h3>Cost vs. solve rate</h3>
114
+ <div id="chart-scatter"></div>
115
+ </div>
116
+ <div class="panel">
117
+ <h3>Token mix (prompt / completion / reasoning / cached)</h3>
118
+ <div id="chart-tokens"></div>
119
+ <div class="legend" id="legend-tokens"></div>
120
+ </div>
121
+ </div>
122
+ </section>
123
+
124
+ <section id="section-series">
125
+ <h2>Per-step trend <span class="sub">median tokens &amp; cumulative cost across the trajectory</span></h2>
126
+ <div class="grid2">
127
+ <div class="panel">
128
+ <h3>Prompt tokens (context) vs. step</h3>
129
+ <div id="chart-line-tokens"></div>
130
+ </div>
131
+ <div class="panel">
132
+ <h3>Cumulative cost vs. step</h3>
133
+ <div id="chart-line-cost"></div>
134
+ </div>
135
+ </div>
136
+ <div class="legend" id="legend-series"></div>
137
+ </section>
138
+
139
+ <section id="section-matrix">
140
+ <h2>Model &times; task <span class="sub">pass@k</span></h2>
141
+ <div class="panel">
142
+ <table id="tbl-matrix" class="heat-table"></table>
143
+ </div>
144
+ </section>
145
+
146
+ <section>
147
+ <h2>Runs <span class="sub">every attempt · click a row to expand the command timeline</span></h2>
148
+ <div class="toolbar">
149
+ <label>Model
150
+ <select id="filter-model"><option value="">all</option></select>
151
+ </label>
152
+ <label>Task
153
+ <select id="filter-task"><option value="">all</option></select>
154
+ </label>
155
+ <label>Outcome
156
+ <select id="filter-outcome"><option value="">all</option></select>
157
+ </label>
158
+ </div>
159
+ <div class="panel">
160
+ <table id="tbl-runs"></table>
161
+ </div>
162
+ </section>
163
+
164
+ <footer>generated by <span class="mono">astra bench --report</span> · schema <span id="schema"></span></footer>
165
+ </div>
166
+
167
+ <script>
168
+ /*__ASTRA_DATA__*/
169
+ </script>
170
+ <script>
171
+ (function () {
172
+ var DATA = window.DATA || { kpis: {}, leaderboard: [], matrix: [], runs: [], step_series: [] };
173
+ var PALETTE = ["#5b9dff", "#3ecf8e", "#e8b339", "#ef5a6f", "#b57bff", "#33c2c2", "#ff8a5b", "#a3d977"];
174
+ var colorCache = {};
175
+ function colorFor(key) {
176
+ if (!colorCache[key]) {
177
+ var n = Object.keys(colorCache).length;
178
+ colorCache[key] = PALETTE[n % PALETTE.length];
179
+ }
180
+ return colorCache[key];
181
+ }
182
+
183
+ function fmt(n) { return Number(n || 0).toLocaleString("en-US"); }
184
+ function fmt1(n) { return (Number(n) || 0).toFixed(1); }
185
+ function pct(n) { return Math.round((Number(n) || 0) * 100) + "%"; }
186
+ function fmtUsd(n) {
187
+ if (n == null) return '<span class="n-a">n/a</span>';
188
+ var v = Number(n) || 0;
189
+ if (v === 0) return "$0";
190
+ if (v < 0.01) return "$" + v.toFixed(5);
191
+ if (v < 1) return "$" + v.toFixed(4);
192
+ return "$" + v.toFixed(2);
193
+ }
194
+ function esc(s) {
195
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
196
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
197
+ });
198
+ }
199
+ function el(tag, attrs, html) {
200
+ var e = document.createElement(tag);
201
+ for (var k in attrs || {}) e.setAttribute(k, attrs[k]);
202
+ if (html != null) e.innerHTML = html;
203
+ return e;
204
+ }
205
+ function svgEl(tag, attrs) {
206
+ var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
207
+ for (var k in attrs || {}) e.setAttribute(k, attrs[k]);
208
+ return e;
209
+ }
210
+
211
+ // ---------------------------------------------------------------- header
212
+ document.getElementById("generated").textContent = "generated " + (DATA.generated_at || "");
213
+ document.getElementById("schema").textContent = DATA.schema || "";
214
+
215
+ // ---------------------------------------------------------------- KPIs
216
+ (function renderKpis() {
217
+ var k = DATA.kpis || {};
218
+ var cards = [
219
+ ["Runs", fmt(k.runs)],
220
+ ["Models", fmt(k.models)],
221
+ ["Tasks", fmt(k.tasks)],
222
+ ["Solved", fmt(k.solved) + " / " + fmt(k.runs)],
223
+ ["Solve rate", pct(k.solved_rate)],
224
+ ["Total tokens", fmt(k.tokens)],
225
+ ["Total cost", fmtUsd(k.cost_usd) + (k.cost_source === "estimated" ? "~" : "")],
226
+ ["Elapsed", fmt(k.elapsed_seconds) + "s"],
227
+ ];
228
+ var host = document.getElementById("kpis");
229
+ cards.forEach(function (c) {
230
+ host.appendChild(el("div", { class: "kpi" }, '<div class="v">' + c[1] + '</div><div class="l">' + esc(c[0]) + "</div>"));
231
+ });
232
+ })();
233
+
234
+ // ---------------------------------------------------------------- sortable table helper
235
+ function sortableTable(container, columns, rows, opts) {
236
+ opts = opts || {};
237
+ var state = { key: opts.defaultKey || columns[0].key, dir: opts.defaultDir || -1 };
238
+ function draw() {
239
+ var sorted = rows.slice().sort(function (a, b) {
240
+ var va = a[state.key], vb = b[state.key];
241
+ if (typeof va === "string" || typeof vb === "string") {
242
+ return state.dir * String(va).localeCompare(String(vb));
243
+ }
244
+ return state.dir * ((Number(va) || 0) - (Number(vb) || 0));
245
+ });
246
+ var thead = "<thead><tr>" + columns.map(function (c) {
247
+ var arrow = c.key === state.key ? (state.dir === 1 ? "\u2191" : "\u2193") : "";
248
+ return '<th data-key="' + c.key + '">' + esc(c.label) + (arrow ? ' <span class="arrow">' + arrow + "</span>" : "") + "</th>";
249
+ }).join("") + "</tr></thead>";
250
+ var tbody = "<tbody>" + sorted.map(function (row, i) {
251
+ var cells = columns.map(function (c) { return "<td>" + c.render(row) + "</td>"; }).join("");
252
+ var extra = opts.rowAttrs ? opts.rowAttrs(row, i) : "";
253
+ return "<tr " + extra + ">" + cells + "</tr>";
254
+ }).join("") + "</tbody>";
255
+ container.innerHTML = thead + tbody;
256
+ container.querySelectorAll("th").forEach(function (th) {
257
+ th.addEventListener("click", function () {
258
+ var key = th.getAttribute("data-key");
259
+ if (state.key === key) state.dir *= -1;
260
+ else { state.key = key; state.dir = -1; }
261
+ draw();
262
+ });
263
+ });
264
+ if (opts.afterDraw) opts.afterDraw(container, sorted);
265
+ }
266
+ draw();
267
+ }
268
+
269
+ // ---------------------------------------------------------------- leaderboard chart: solve rate bars
270
+ (function () {
271
+ var host = document.getElementById("chart-solve");
272
+ (DATA.leaderboard || []).forEach(function (g) {
273
+ var row = el("div", { class: "bar-row" });
274
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
275
+ var track = el("div", { class: "bar-track" });
276
+ var seg = el("div", {
277
+ class: "bar-seg",
278
+ style: "width:" + Math.round((g.solved_rate || 0) * 100) + "%;background:" + colorFor(g.slug),
279
+ });
280
+ track.appendChild(seg);
281
+ row.appendChild(track);
282
+ row.appendChild(el("div", { class: "bar-val" }, pct(g.solved_rate) + " (" + g.solved + "/" + g.runs + ")"));
283
+ host.appendChild(row);
284
+ });
285
+ })();
286
+
287
+ // ---------------------------------------------------------------- outcomes stacked bar
288
+ (function () {
289
+ var host = document.getElementById("chart-outcomes");
290
+ var legend = document.getElementById("legend-outcomes");
291
+ var outcomeColors = { Submitted: "#3ecf8e", LimitsExceeded: "#e8b339", TimeExceeded: "#e8b339", RepeatedFormatError: "#ef5a6f", ContextWindowExceeded: "#ef5a6f", Error: "#ef5a6f", Unknown: "#8b93a7" };
292
+ function colorForOutcome(k) { return outcomeColors[k] || "#8b93a7"; }
293
+ var allOutcomes = {};
294
+ (DATA.leaderboard || []).forEach(function (g) {
295
+ var row = el("div", { class: "bar-row" });
296
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
297
+ var track = el("div", { class: "bar-track" });
298
+ var total = g.runs || 1;
299
+ Object.keys(g.outcomes || {}).forEach(function (k) {
300
+ allOutcomes[k] = true;
301
+ var w = (g.outcomes[k] / total) * 100;
302
+ track.appendChild(el("div", { class: "bar-seg", style: "width:" + w + "%;background:" + colorForOutcome(k) }));
303
+ });
304
+ row.appendChild(track);
305
+ row.appendChild(el("div", { class: "bar-val" }, g.runs + " runs"));
306
+ host.appendChild(row);
307
+ });
308
+ Object.keys(allOutcomes).forEach(function (k) {
309
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colorForOutcome(k) + '"></span>' + esc(k)));
310
+ });
311
+ })();
312
+
313
+ // ---------------------------------------------------------------- leaderboard table
314
+ (function () {
315
+ var container = document.getElementById("tbl-leaderboard");
316
+ var columns = [
317
+ { key: "model", label: "Model", render: function (r) { return esc(r.model); } },
318
+ { key: "reasoning", label: "Reasoning", render: function (r) { return esc(r.reasoning); } },
319
+ { key: "runs", label: "Runs", render: function (r) { return fmt(r.runs); } },
320
+ { key: "solved_rate", label: "Solved", render: function (r) { return r.solved + "/" + r.runs + " (" + pct(r.solved_rate) + ")"; } },
321
+ { key: "avg_steps", label: "Avg steps", render: function (r) { return fmt1(r.avg_steps); } },
322
+ { key: "avg_elapsed_seconds", label: "Avg time", render: function (r) { return fmt1(r.avg_elapsed_seconds) + "s"; } },
323
+ { key: "avg_tokens", label: "Avg tokens", render: function (r) { return fmt(Math.round(r.avg_tokens)); } },
324
+ { key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
325
+ { key: "avg_n_failed_commands", label: "Fail/run", render: function (r) { return fmt1(r.avg_n_failed_commands); } },
326
+ ];
327
+ sortableTable(container, columns, DATA.leaderboard || [], { defaultKey: "solved_rate" });
328
+ })();
329
+
330
+ // ---------------------------------------------------------------- token mix stacked bar
331
+ (function () {
332
+ var host = document.getElementById("chart-tokens");
333
+ var legend = document.getElementById("legend-tokens");
334
+ var keys = ["prompt", "completion", "reasoning", "cached"];
335
+ var colors = { prompt: "#5b9dff", completion: "#3ecf8e", reasoning: "#b57bff", cached: "#8b93a7" };
336
+ var maxTotal = Math.max(1, Math.max.apply(null, (DATA.leaderboard || []).map(function (g) {
337
+ return keys.reduce(function (s, k) { return s + (g.tokens[k] || 0); }, 0);
338
+ })));
339
+ (DATA.leaderboard || []).forEach(function (g) {
340
+ var row = el("div", { class: "bar-row" });
341
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
342
+ var track = el("div", { class: "bar-track" });
343
+ var total = keys.reduce(function (s, k) { return s + (g.tokens[k] || 0); }, 0);
344
+ keys.forEach(function (k) {
345
+ var w = (g.tokens[k] || 0) / maxTotal * 100;
346
+ if (w > 0) track.appendChild(el("div", { class: "bar-seg", style: "width:" + w + "%;background:" + colors[k] }));
347
+ });
348
+ row.appendChild(track);
349
+ row.appendChild(el("div", { class: "bar-val" }, fmt(total)));
350
+ host.appendChild(row);
351
+ });
352
+ keys.forEach(function (k) {
353
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colors[k] + '"></span>' + k));
354
+ });
355
+ })();
356
+
357
+ // ---------------------------------------------------------------- scatter: cost vs solve rate
358
+ (function () {
359
+ var host = document.getElementById("chart-scatter");
360
+ var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
361
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
362
+ var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
363
+ var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
364
+ function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
365
+ function y(v) { return H - pad.b - v * (H - pad.t - pad.b); }
366
+ // axes
367
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
368
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
369
+ [0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
370
+ var ty = y(t);
371
+ var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
372
+ lab.textContent = Math.round(t * 100) + "%";
373
+ svg.appendChild(lab);
374
+ });
375
+ var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
376
+ lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
377
+ svg.appendChild(lab2);
378
+ data.forEach(function (g) {
379
+ var cx = x(g.cost_usd), cy = y(g.solved_rate);
380
+ var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
381
+ var c = svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 });
382
+ svg.appendChild(c);
383
+ var t = svgEl("text", { x: cx + r + 4, y: cy + 3 });
384
+ t.textContent = g.slug;
385
+ svg.appendChild(t);
386
+ });
387
+ host.appendChild(svg);
388
+ })();
389
+
390
+ // ---------------------------------------------------------------- per-step line charts
391
+ function lineChart(host, series, valueFn, opts) {
392
+ opts = opts || {};
393
+ var W = host.clientWidth || 460, H = 260, pad = { l: 46, r: 16, t: 14, b: 26 };
394
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
395
+ var maxStep = 1, maxVal = 0.0001;
396
+ series.forEach(function (s) {
397
+ s.points.forEach(function (p) {
398
+ if (p.step > maxStep) maxStep = p.step;
399
+ var v = valueFn(p);
400
+ if (v > maxVal) maxVal = v;
401
+ });
402
+ });
403
+ function x(v) { return pad.l + (v - 1) / Math.max(1, maxStep - 1) * (W - pad.l - pad.r); }
404
+ function y(v) { return H - pad.b - (v / maxVal) * (H - pad.t - pad.b); }
405
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
406
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
407
+ [0, 0.5, 1].forEach(function (t) {
408
+ var ty = y(maxVal * t);
409
+ var lab = svgEl("text", { x: pad.l - 6, y: ty + 3, "text-anchor": "end" });
410
+ lab.textContent = opts.fmt ? opts.fmt(maxVal * t) : Math.round(maxVal * t);
411
+ svg.appendChild(lab);
412
+ });
413
+ series.forEach(function (s) {
414
+ var pts = s.points.slice().sort(function (a, b) { return a.step - b.step; });
415
+ var d = pts.map(function (p, i) { return (i === 0 ? "M" : "L") + x(p.step) + "," + y(valueFn(p)); }).join(" ");
416
+ svg.appendChild(svgEl("path", { d: d, fill: "none", stroke: colorFor(s.slug), "stroke-width": 2 }));
417
+ pts.forEach(function (p) {
418
+ svg.appendChild(svgEl("circle", { cx: x(p.step), cy: y(valueFn(p)), r: 2.5, fill: colorFor(s.slug) }));
419
+ });
420
+ });
421
+ host.appendChild(svg);
422
+ }
423
+
424
+ (function () {
425
+ var bySlug = {};
426
+ (DATA.step_series || []).forEach(function (r) {
427
+ if (!bySlug[r.slug]) bySlug[r.slug] = [];
428
+ bySlug[r.slug].push(r);
429
+ });
430
+ var series = Object.keys(bySlug).map(function (slug) { return { slug: slug, points: bySlug[slug] }; });
431
+ if (series.length === 0) {
432
+ document.getElementById("section-series").style.display = "none";
433
+ return;
434
+ }
435
+ lineChart(document.getElementById("chart-line-tokens"), series, function (p) { return p.prompt_tokens.p50; }, { fmt: fmt });
436
+ lineChart(document.getElementById("chart-line-cost"), series, function (p) { return p.cum_cost_usd.mean; }, { fmt: fmtUsd });
437
+ var legend = document.getElementById("legend-series");
438
+ series.forEach(function (s) {
439
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colorFor(s.slug) + '"></span>' + esc(s.slug)));
440
+ });
441
+ })();
442
+
443
+ // ---------------------------------------------------------------- model x task matrix (heatmap table)
444
+ (function () {
445
+ var matrix = DATA.matrix || [];
446
+ var container = document.getElementById("tbl-matrix");
447
+ if (matrix.length === 0) {
448
+ document.getElementById("section-matrix").style.display = "none";
449
+ return;
450
+ }
451
+ var tasks = [];
452
+ var slugs = [];
453
+ var cell = {};
454
+ matrix.forEach(function (m) {
455
+ if (tasks.indexOf(m.task_id) === -1) tasks.push(m.task_id);
456
+ if (slugs.indexOf(m.slug) === -1) slugs.push(m.slug);
457
+ cell[m.slug + "\u0000" + m.task_id] = m;
458
+ });
459
+ function heatColor(rate) {
460
+ if (rate == null) return "transparent";
461
+ var g = Math.round(60 + rate * 140);
462
+ var r = Math.round(220 - rate * 160);
463
+ return "rgba(" + r + "," + g + ",120,0.35)";
464
+ }
465
+ var thead = "<thead><tr><th>Model</th>" + tasks.map(function (t) { return "<th>" + esc(t) + "</th>"; }).join("") + "</tr></thead>";
466
+ var tbody = "<tbody>" + slugs.map(function (slug) {
467
+ var cells = tasks.map(function (t) {
468
+ var m = cell[slug + "\u0000" + t];
469
+ if (!m) return "<td>&mdash;</td>";
470
+ return '<td><span class="heat-cell" style="background:' + heatColor(m.pass_at_k) + '">' + m.passed + "/" + m.k + "<br>" + pct(m.pass_at_k) + "</span></td>";
471
+ }).join("");
472
+ return "<tr><td class=\"mono\">" + esc(slug) + "</td>" + cells + "</tr>";
473
+ }).join("") + "</tbody>";
474
+ container.innerHTML = thead + tbody;
475
+ })();
476
+
477
+ // ---------------------------------------------------------------- runs table + filters + expandable timeline
478
+ (function () {
479
+ var runs = DATA.runs || [];
480
+ var container = document.getElementById("tbl-runs");
481
+ var fModel = document.getElementById("filter-model");
482
+ var fTask = document.getElementById("filter-task");
483
+ var fOutcome = document.getElementById("filter-outcome");
484
+
485
+ function uniq(fn) {
486
+ var seen = {}, out = [];
487
+ runs.forEach(function (r) { var v = fn(r); if (v && !seen[v]) { seen[v] = 1; out.push(v); } });
488
+ return out.sort();
489
+ }
490
+ uniq(function (r) { return r.slug; }).forEach(function (v) { fModel.appendChild(el("option", { value: v }, esc(v))); });
491
+ uniq(function (r) { return r.task_id; }).forEach(function (v) { fTask.appendChild(el("option", { value: v }, esc(v))); });
492
+ uniq(function (r) { return r.exit_status; }).forEach(function (v) { fOutcome.appendChild(el("option", { value: v }, esc(v))); });
493
+
494
+ var columns = [
495
+ { key: "slug", label: "Model", render: function (r) { return '<span class="mono">' + esc(r.slug) + "</span>"; } },
496
+ { key: "task_id", label: "Task", render: function (r) { return esc(r.task_id); } },
497
+ { key: "run_id", label: "Run", render: function (r) { return esc(r.run_id); } },
498
+ { key: "exit_status", label: "Outcome", render: function (r) {
499
+ var cls = r.resolved ? "ok" : (r.exit_status === "Error" ? "bad" : "warn");
500
+ return '<span class="pill ' + cls + '">' + esc(r.exit_status || "Unknown") + "</span>";
501
+ } },
502
+ { key: "steps", label: "Steps", render: function (r) { return fmt(r.steps); } },
503
+ { key: "elapsed_seconds", label: "Time", render: function (r) { return fmt(r.elapsed_seconds) + "s"; } },
504
+ { key: "tokens", label: "Tokens", render: function (r) { return fmt(r.tokens.total); } },
505
+ { key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
506
+ { key: "n_failed_commands", label: "Fails", render: function (r) { return fmt(r.n_failed_commands); } },
507
+ ];
508
+
509
+ function renderTimeline(run) {
510
+ var box = el("div", { class: "timeline" });
511
+ (run.timeline || []).forEach(function (s) {
512
+ var rcClass = s.returncode === 0 ? "rc0" : (s.returncode == null ? "muted" : "rcN");
513
+ var step = el("div", { class: "step" });
514
+ step.innerHTML =
515
+ '<div class="head"><span class="n">step ' + s.step + '</span>' +
516
+ '<span class="stats">' +
517
+ fmt(s.tokens.total) + " tok" +
518
+ (s.cost_usd != null ? " \u00b7 " + fmtUsd(s.cost_usd) : "") +
519
+ (s.returncode != null ? ' \u00b7 rc=<span class="' + rcClass + '">' + s.returncode + "</span>" : "") +
520
+ "</span></div>" +
521
+ (s.thought ? '<div class="thought">' + esc(s.thought) + "</div>" : "") +
522
+ (s.command ? "<pre>$ " + esc(s.command) + "</pre>" : "") +
523
+ (s.output_preview ? "<pre>" + esc(s.output_preview) + "</pre>" : "");
524
+ box.appendChild(step);
525
+ });
526
+ return box;
527
+ }
528
+
529
+ function draw() {
530
+ var filtered = runs.filter(function (r) {
531
+ if (fModel.value && r.slug !== fModel.value) return false;
532
+ if (fTask.value && r.task_id !== fTask.value) return false;
533
+ if (fOutcome.value && r.exit_status !== fOutcome.value) return false;
534
+ return true;
535
+ });
536
+ sortableTable(container, columns, filtered, {
537
+ defaultKey: "slug",
538
+ defaultDir: 1,
539
+ rowAttrs: function (r, i) { return 'class="run-row" data-idx="' + i + '"'; },
540
+ afterDraw: function (containerEl, sorted) {
541
+ containerEl.querySelectorAll("tbody tr").forEach(function (tr) {
542
+ var idx = Number(tr.getAttribute("data-idx"));
543
+ var run = sorted[idx];
544
+ tr.addEventListener("click", function () {
545
+ var next = tr.nextElementSibling;
546
+ if (next && next.classList.contains("detail-row")) {
547
+ next.remove();
548
+ return;
549
+ }
550
+ containerEl.querySelectorAll(".detail-row").forEach(function (d) { d.remove(); });
551
+ var detailRow = document.createElement("tr");
552
+ detailRow.className = "detail-row";
553
+ var td = document.createElement("td");
554
+ td.colSpan = columns.length;
555
+ td.appendChild(renderTimeline(run));
556
+ detailRow.appendChild(td);
557
+ tr.parentNode.insertBefore(detailRow, tr.nextSibling);
558
+ });
559
+ });
560
+ },
561
+ });
562
+ }
563
+ [fModel, fTask, fOutcome].forEach(function (elm) { elm.addEventListener("change", draw); });
564
+ draw();
565
+ })();
566
+ })();
567
+ </script>
568
+ </body>
569
+ </html>