@hackerrank/astra-cli 0.1.0 → 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
@@ -100,6 +100,55 @@ astra \
100
100
  -o ./runs/run-01.traj.json
101
101
  ```
102
102
 
103
+ ### Multi-model bench matrix
104
+
105
+ Pass a **comma-separated** model list (and/or `--repeat`, and/or a comma-separated
106
+ `-r` reasoning list) to run a matrix. Each cell runs sequentially in its own
107
+ isolated workspace under `bench/<model-reasoning>/run-NN/`, and per-run metrics
108
+ are appended to a rolled-up `bench/metrics.csv` index. A leaderboard prints at
109
+ the end.
110
+
111
+ ```bash
112
+ astra \
113
+ -m claude-sonnet-5,gpt-5.6-sol,gemini-3.7-flash \
114
+ -p tasks/dummy-slugify \
115
+ --repeat 3 \
116
+ --tar # write a bench/ tarball locally
117
+
118
+ # push results to S3 (tars bench/ then `aws s3 cp` to the URI)
119
+ astra -m m1,m2 -p tasks/dummy-slugify --repeat 3 \
120
+ --push s3://hackerrank-astra-bench-results-dev/runs/2025-09-02/
121
+ ```
122
+
123
+ Leaderboard columns: `solved` (pass rate), average `steps` / `tokens`, and
124
+ `cost`. Cost is per-run summed; a model whose route reports no cost and has no
125
+ price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) are
126
+ marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
127
+ upload fails the local tarball is kept.
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
+
103
152
  ### Sessions
104
153
 
105
154
  Every run (either mode) is saved under `~/.astra/sessions/<id>.json`.
@@ -118,9 +167,17 @@ credits — that's a quota issue, not a bug.
118
167
  ### Options
119
168
 
120
169
  ```
121
- -m, --model <id> Model id on the gateway (required unless --sessions)
170
+ -m, --model <id> Model id (comma-separated -> multi-model matrix)
171
+ -r, --reasoning <lvl> Reasoning effort (comma-separated -> sweep levels)
122
172
  -t, --task <text> Task text -> autonomous mode
123
173
  -f, --task-file <path> Read task text from a file -> autonomous mode
174
+ -p, --path <dir> Task directory -> isolated bench workspace
175
+ --repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
176
+ --bench-root <dir> Root folder for bench runs (default: ./bench)
177
+ --tar After a matrix, write a bench/ tarball locally
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
124
181
  -C, --cwd <path> Working directory for commands (default: cwd)
125
182
  -o, --output <path> Also write trajectory JSON here (autonomous mode)
126
183
  -s, --steps <n> Step limit (default: 40)
@@ -202,4 +259,6 @@ Tokens are exact (from each response's `usage`). Cost is **hybrid**, tracking-on
202
259
  A run that mixes both is tagged `source: "mixed"` with the split preserved in
203
260
  `reported_usd` / `estimated_usd`.
204
261
 
205
- Remote / Jenkins benchmarking lives in the sibling repo `astra-bench`, not here.
262
+ Multi-model benchmarking runs in this CLI (`-m a,b,c --repeat N`, with `--tar` /
263
+ `--push s3://...` to archive results). The sibling repo `astra-bench` only hosts
264
+ the Docker image + Jenkins job that invoke this CLI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.0",
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/agent.js CHANGED
@@ -50,6 +50,11 @@ export class Agent {
50
50
  this.messages = [];
51
51
  this.nSteps = 0;
52
52
  this.formatErrorStreak = 0;
53
+ // Counters surfaced as benchmark metrics.
54
+ this.nCommands = 0; // commands actually executed
55
+ this.nFailedCommands = 0; // executed commands with non-zero return code
56
+ this.nFormatErrors = 0; // unparseable model replies
57
+ this.nDeclined = 0; // commands rejected by the approval gate
53
58
  this.startTime = Date.now();
54
59
  // Exact context size (prompt tokens) reported by the most recent call.
55
60
  this.lastContextTokens = 0;
@@ -153,6 +158,7 @@ export class Agent {
153
158
  return { kind: "chat", content };
154
159
  }
155
160
  this.formatErrorStreak++;
161
+ this.nFormatErrors++;
156
162
  if (
157
163
  this.maxConsecutiveFormatErrors > 0 &&
158
164
  this.formatErrorStreak >= this.maxConsecutiveFormatErrors
@@ -166,6 +172,7 @@ export class Agent {
166
172
  // --- 3. Approval gate ---
167
173
  const allowed = await this.confirm(command);
168
174
  if (!allowed) {
175
+ this.nDeclined++;
169
176
  this.add("user", "The user declined to run that command. Suggest an alternative or ask what to do instead.", {
170
177
  command,
171
178
  declined: true,
@@ -175,6 +182,8 @@ export class Agent {
175
182
 
176
183
  // --- 4. Execute ---
177
184
  const output = await this.env.execute(command);
185
+ this.nCommands++;
186
+ if (output.returncode !== 0) this.nFailedCommands++;
178
187
 
179
188
  // --- 5. Submission sentinel ---
180
189
  const submitted = checkSubmitted(output);
package/src/bench.js ADDED
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Benchmark run management.
3
+ *
4
+ * A "bench" run executes a task autonomously in an isolated workspace and
5
+ * records metrics for later comparison across models / reasoning levels.
6
+ *
7
+ * Layout (created under the invocation cwd unless --bench-root overrides it):
8
+ *
9
+ * bench/<model-name-reasoning>/run-NN/
10
+ * ├── workspace/ copy of the task files; the agent works here
11
+ * ├── trajectory.json full astra-1 trajectory for the run
12
+ * ├── metrics.csv one-row metrics summary (also appended to the
13
+ * │ rolled-up bench/metrics.csv index)
14
+ * └── task.md the task text the agent was given
15
+ *
16
+ * The per-run metrics are also appended to a top-level `bench/metrics.csv`
17
+ * index so every run across every model lands in one comparable table.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import { GatewayModel } from "./model.js";
24
+ import { LocalEnvironment } from "./environment.js";
25
+ import { Agent } from "./agent.js";
26
+
27
+ /** CSV columns, in order, for the metrics table. */
28
+ export const METRIC_COLUMNS = [
29
+ "timestamp",
30
+ "model",
31
+ "reasoning",
32
+ "exit_status",
33
+ "resolved",
34
+ "steps",
35
+ "n_commands",
36
+ "n_failed_commands",
37
+ "n_format_errors",
38
+ "n_retries",
39
+ "n_calls",
40
+ "elapsed_seconds",
41
+ "prompt_tokens",
42
+ "cached_tokens",
43
+ "cache_write_tokens",
44
+ "completion_tokens",
45
+ "reasoning_tokens",
46
+ "total_tokens",
47
+ "last_context_tokens",
48
+ "cost_usd",
49
+ "cost_source",
50
+ ];
51
+
52
+ /**
53
+ * Turn a model id + reasoning level into a filesystem-safe folder name like
54
+ * `claude-sonnet-5-high` or `grok-4.6-none`.
55
+ */
56
+ export function benchSlug(model, reasoning) {
57
+ const clean = (s) =>
58
+ String(s ?? "")
59
+ .trim()
60
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
61
+ .replace(/^-+|-+$/g, "");
62
+ const r = reasoning ? clean(reasoning) : "none";
63
+ return `${clean(model)}-${r}`;
64
+ }
65
+
66
+ /** Absolute path to the bench root (default: <cwd>/bench). */
67
+ export function benchRoot(root) {
68
+ return path.resolve(root || path.join(process.cwd(), "bench"));
69
+ }
70
+
71
+ /**
72
+ * Allocate the next `run-NN` directory for a (model, reasoning) slug, creating
73
+ * parent folders. Returns { dir, run, slug, workspace, root }.
74
+ */
75
+ export function allocateRun({ model, reasoning, root } = {}) {
76
+ const rootDir = benchRoot(root);
77
+ const slug = benchSlug(model, reasoning);
78
+ const slugDir = path.join(rootDir, slug);
79
+ fs.mkdirSync(slugDir, { recursive: true });
80
+
81
+ // Find the next run index by scanning existing run-NN folders.
82
+ let max = 0;
83
+ for (const name of safeReaddir(slugDir)) {
84
+ const m = /^run-(\d+)$/.exec(name);
85
+ if (m) max = Math.max(max, Number(m[1]));
86
+ }
87
+ const run = max + 1;
88
+ const runId = `run-${String(run).padStart(2, "0")}`;
89
+ const dir = path.join(slugDir, runId);
90
+ const workspace = path.join(dir, "workspace");
91
+ fs.mkdirSync(workspace, { recursive: true });
92
+
93
+ return { dir, run, runId, slug, workspace, root: rootDir };
94
+ }
95
+
96
+ /**
97
+ * Seed a run workspace from a task source.
98
+ * @param {string} workspace destination directory (already created)
99
+ * @param {object} src { taskPath?, taskFile?, taskText? }
100
+ * - taskPath: a directory whose contents are copied into the workspace
101
+ * - taskFile: a single instruction file; its dir contents are copied too
102
+ * - taskText: raw task text (no files to copy)
103
+ * Returns the resolved task text.
104
+ */
105
+ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {}) {
106
+ let text = taskText ?? "";
107
+ let copyFrom = null;
108
+ let instructionName = null;
109
+
110
+ if (taskPath) {
111
+ const resolved = path.resolve(taskPath);
112
+ const stat = fs.statSync(resolved);
113
+ if (stat.isDirectory()) {
114
+ copyFrom = resolved;
115
+ // Prefer a conventional instruction file for the task text.
116
+ for (const name of ["instruction.md", "INSTRUCTION.md", "task.md", "README.md"]) {
117
+ if (fs.existsSync(path.join(resolved, name))) {
118
+ text = fs.readFileSync(path.join(resolved, name), "utf8");
119
+ break;
120
+ }
121
+ }
122
+ } else {
123
+ copyFrom = path.dirname(resolved);
124
+ instructionName = path.basename(resolved);
125
+ text = fs.readFileSync(resolved, "utf8");
126
+ }
127
+ } else if (taskFile) {
128
+ const resolved = path.resolve(taskFile);
129
+ copyFrom = path.dirname(resolved);
130
+ instructionName = path.basename(resolved);
131
+ text = fs.readFileSync(resolved, "utf8");
132
+ }
133
+
134
+ if (copyFrom) copyDir(copyFrom, workspace);
135
+ return { taskText: text, instructionName };
136
+ }
137
+
138
+ /** Directory / file names never copied into a fresh workspace (kept clean so
139
+ * result tarballs don't carry build cruft). */
140
+ const COPY_SKIP = new Set([
141
+ "bench",
142
+ ".git",
143
+ "node_modules",
144
+ "__pycache__",
145
+ ".pytest_cache",
146
+ ".mypy_cache",
147
+ ".ruff_cache",
148
+ ]);
149
+
150
+ /** Recursively copy a directory (skips build cruft — see COPY_SKIP). */
151
+ function copyDir(from, to) {
152
+ fs.mkdirSync(to, { recursive: true });
153
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
154
+ if (COPY_SKIP.has(entry.name)) continue;
155
+ const s = path.join(from, entry.name);
156
+ const d = path.join(to, entry.name);
157
+ if (entry.isDirectory()) copyDir(s, d);
158
+ else if (entry.isFile()) fs.copyFileSync(s, d);
159
+ }
160
+ }
161
+
162
+ /** Build a metrics row object from a finished agent + run info. */
163
+ export function collectMetrics({ agent, model, reasoning }) {
164
+ const info = agent.serialize().info;
165
+ const resolved = info.exit_status === "Submitted" ? 1 : 0;
166
+ return {
167
+ timestamp: new Date().toISOString(),
168
+ model: model.model,
169
+ reasoning: reasoning || "none",
170
+ exit_status: info.exit_status || "",
171
+ resolved,
172
+ steps: info.n_steps ?? 0,
173
+ n_commands: agent.nCommands ?? 0,
174
+ n_failed_commands: agent.nFailedCommands ?? 0,
175
+ n_format_errors: agent.nFormatErrors ?? 0,
176
+ n_retries: model.nRetries ?? 0,
177
+ n_calls: info.n_calls ?? 0,
178
+ elapsed_seconds: info.elapsed_seconds ?? 0,
179
+ prompt_tokens: info.tokens?.prompt ?? 0,
180
+ cached_tokens: model.totalCachedTokens ?? 0,
181
+ cache_write_tokens: model.totalCacheWriteTokens ?? 0,
182
+ completion_tokens: info.tokens?.completion ?? 0,
183
+ reasoning_tokens: model.totalReasoningTokens ?? 0,
184
+ total_tokens: info.tokens?.total ?? 0,
185
+ last_context_tokens: info.tokens?.last_context ?? 0,
186
+ cost_usd: round(info.cost?.usd ?? 0, 6),
187
+ cost_source: info.cost?.source ?? "",
188
+ };
189
+ }
190
+
191
+ /** Serialize a metrics row to a single CSV line (columns in METRIC_COLUMNS order). */
192
+ export function metricsRow(m) {
193
+ return METRIC_COLUMNS.map((c) => csvCell(m[c])).join(",");
194
+ }
195
+
196
+ /** The CSV header line. */
197
+ export function metricsHeader() {
198
+ return METRIC_COLUMNS.join(",");
199
+ }
200
+
201
+ /**
202
+ * Write a per-run metrics.csv (header + single row) and append the same row to
203
+ * the top-level rolled-up index at <root>/metrics.csv.
204
+ */
205
+ export function writeMetrics({ runDir, root, metrics }) {
206
+ const header = metricsHeader();
207
+ const row = metricsRow(metrics);
208
+
209
+ // Per-run file.
210
+ fs.writeFileSync(path.join(runDir, "metrics.csv"), header + "\n" + row + "\n");
211
+
212
+ // Rolled-up index (create header once).
213
+ const indexPath = path.join(benchRoot(root), "metrics.csv");
214
+ if (!fs.existsSync(indexPath)) {
215
+ fs.writeFileSync(indexPath, header + "\n" + row + "\n");
216
+ } else {
217
+ fs.appendFileSync(indexPath, row + "\n");
218
+ }
219
+ return { runMetrics: path.join(runDir, "metrics.csv"), index: indexPath };
220
+ }
221
+
222
+ function csvCell(v) {
223
+ const s = v == null ? "" : String(v);
224
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
225
+ }
226
+
227
+ function round(n, dp) {
228
+ const f = 10 ** dp;
229
+ return Math.round((Number(n) || 0) * f) / f;
230
+ }
231
+
232
+ function safeReaddir(dir) {
233
+ try {
234
+ return fs.readdirSync(dir);
235
+ } catch {
236
+ return [];
237
+ }
238
+ }
239
+
240
+ // ============================================================================
241
+ // Multi-model matrix runner
242
+ // ============================================================================
243
+
244
+ /** Turn a reasoning level into gateway modelKwargs. Empty for off/none/unset. */
245
+ function reasoningKwargs(level) {
246
+ if (!level) return {};
247
+ const l = String(level).toLowerCase();
248
+ if (l === "off" || l === "none" || l === "disabled") return {};
249
+ return { reasoning_effort: l };
250
+ }
251
+
252
+ /**
253
+ * Run one bench cell: allocate an isolated workspace, seed it, run the agent
254
+ * autonomously to completion, and record metrics. Never throws — a crashed run
255
+ * is captured as a failed cell so one bad run can't abort the whole matrix.
256
+ *
257
+ * @returns {object} the metrics row (plus `error` when the run threw).
258
+ */
259
+ export async function runCell({
260
+ model,
261
+ reasoning,
262
+ apiKey,
263
+ baseUrl,
264
+ task,
265
+ taskPath,
266
+ taskFile,
267
+ root,
268
+ steps = 40,
269
+ wall = 0,
270
+ timeout = 60,
271
+ maxOutputChars = 16000,
272
+ onStart = () => {},
273
+ onDone = () => {},
274
+ } = {}) {
275
+ const alloc = allocateRun({ model, reasoning, root });
276
+ onStart({ ...alloc, model, reasoning });
277
+
278
+ const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
279
+ const taskText = seeded.taskText || task || "";
280
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
281
+
282
+ const gw = new GatewayModel({
283
+ model,
284
+ baseUrl,
285
+ apiKey,
286
+ modelKwargs: reasoningKwargs(reasoning),
287
+ });
288
+ const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
289
+ const agent = new Agent(gw, env, {
290
+ mode: "autonomous",
291
+ stepLimit: steps,
292
+ wallTimeLimitSeconds: wall,
293
+ outputPath: path.join(alloc.dir, "trajectory.json"),
294
+ });
295
+
296
+ let error = null;
297
+ try {
298
+ await agent.run(taskText);
299
+ } catch (err) {
300
+ error = String(err?.message || err);
301
+ if (!agent.exitStatus) agent.exit("Error", "");
302
+ }
303
+
304
+ const metrics = collectMetrics({ agent, model: gw, reasoning });
305
+ if (error) metrics.exit_status = metrics.exit_status || "Error";
306
+ const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
307
+
308
+ const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, paths };
309
+ onDone(result);
310
+ return result;
311
+ }
312
+
313
+ /**
314
+ * Run a full matrix of models × reasoning levels × repeats against one task.
315
+ * Sequential (clean streamed output; avoids gateway rate-limit storms).
316
+ *
317
+ * @returns {object[]} one metrics row per cell.
318
+ */
319
+ export async function runMatrix({
320
+ models = [],
321
+ reasonings = [""],
322
+ repeat = 1,
323
+ ...cellOpts
324
+ } = {}) {
325
+ const rows = [];
326
+ for (const model of models) {
327
+ for (const reasoning of reasonings) {
328
+ for (let attempt = 1; attempt <= repeat; attempt++) {
329
+ const row = await runCell({ ...cellOpts, model, reasoning });
330
+ rows.push(row);
331
+ }
332
+ }
333
+ }
334
+ return rows;
335
+ }
336
+
337
+ // ============================================================================
338
+ // Aggregation / leaderboard
339
+ // ============================================================================
340
+
341
+ /**
342
+ * Aggregate metrics rows into a per-(model,reasoning) leaderboard.
343
+ * `resolved` counts as a solve. Cost is summed only over runs that reported a
344
+ * cost; a group with no cost data is reported as null (rendered "n/a"), never $0.
345
+ */
346
+ export function aggregate(rows) {
347
+ const groups = new Map();
348
+ for (const r of rows) {
349
+ const key = `${r.model}\u0000${r.reasoning || "none"}`;
350
+ if (!groups.has(key)) {
351
+ groups.set(key, {
352
+ model: r.model,
353
+ reasoning: r.reasoning || "none",
354
+ runs: 0,
355
+ solved: 0,
356
+ steps: 0,
357
+ totalTokens: 0,
358
+ costUsd: 0,
359
+ costRuns: 0,
360
+ costSources: new Set(),
361
+ });
362
+ }
363
+ const g = groups.get(key);
364
+ g.runs++;
365
+ g.solved += Number(r.resolved) ? 1 : 0;
366
+ g.steps += Number(r.steps) || 0;
367
+ g.totalTokens += Number(r.total_tokens) || 0;
368
+ if (r.cost_source && r.cost_source !== "") {
369
+ g.costUsd += Number(r.cost_usd) || 0;
370
+ g.costRuns++;
371
+ g.costSources.add(r.cost_source);
372
+ }
373
+ }
374
+ return [...groups.values()].map((g) => ({
375
+ model: g.model,
376
+ reasoning: g.reasoning,
377
+ runs: g.runs,
378
+ solved: g.solved,
379
+ solved_rate: g.runs ? g.solved / g.runs : 0,
380
+ avg_steps: g.runs ? g.steps / g.runs : 0,
381
+ avg_tokens: g.runs ? g.totalTokens / g.runs : 0,
382
+ cost_usd: g.costRuns ? g.costUsd : null,
383
+ cost_source:
384
+ g.costSources.size === 0
385
+ ? "unknown"
386
+ : g.costSources.size === 1
387
+ ? [...g.costSources][0]
388
+ : "mixed",
389
+ }));
390
+ }
391
+
392
+ // ============================================================================
393
+ // Packaging: tarball the bench folder and (optionally) push to S3
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Create a gzipped tarball of the bench root. Returns the tarball path.
398
+ * Excludes build cruft. Uses the system `tar` (zero npm deps).
399
+ */
400
+ export function packBench({ root, out } = {}) {
401
+ const rootDir = benchRoot(root);
402
+ if (!fs.existsSync(rootDir)) throw new Error(`bench root not found: ${rootDir}`);
403
+ const parent = path.dirname(rootDir);
404
+ const base = path.basename(rootDir);
405
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
406
+ const tarball = path.resolve(out || path.join(parent, `${base}-${stamp}.tgz`));
407
+
408
+ const res = spawnSync(
409
+ "tar",
410
+ [
411
+ "--exclude=__pycache__",
412
+ "--exclude=.pytest_cache",
413
+ "--exclude=.git",
414
+ "-czf",
415
+ tarball,
416
+ "-C",
417
+ parent,
418
+ base,
419
+ ],
420
+ { stdio: ["ignore", "ignore", "pipe"] }
421
+ );
422
+ if (res.status !== 0) {
423
+ throw new Error(`tar failed: ${res.stderr?.toString() || res.error?.message || "unknown"}`);
424
+ }
425
+ return tarball;
426
+ }
427
+
428
+ /** The default S3 bucket for bench result tarballs. */
429
+ export const DEFAULT_BENCH_BUCKET = "astra-bench-results";
430
+
431
+ /** A filesystem/S3-safe timestamp prefix like 20250902-142530. */
432
+ export function benchTimestamp(d = new Date()) {
433
+ const p = (n, w = 2) => String(n).padStart(w, "0");
434
+ return (
435
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
436
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
437
+ );
438
+ }
439
+
440
+ /** Default push destination: s3://astra-bench-results/<datetimestamp>/. */
441
+ export function defaultPushUri(stamp = benchTimestamp()) {
442
+ return `s3://${DEFAULT_BENCH_BUCKET}/${stamp}/`;
443
+ }
444
+
445
+ /** Parse an s3://bucket/key URI into { bucket, key }. */
446
+ function parseS3Uri(uri) {
447
+ const m = /^s3:\/\/([^/]+)\/?(.*)$/.exec(uri);
448
+ if (!m) throw new Error(`invalid s3 uri: ${uri}`);
449
+ return { bucket: m[1], key: m[2] };
450
+ }
451
+
452
+ /** Build a browsable https console URL for an s3://bucket/key object. */
453
+ function s3ConsoleUrl(s3Uri) {
454
+ const { bucket, key } = parseS3Uri(s3Uri);
455
+ return `https://s3.console.aws.amazon.com/s3/object/${bucket}?prefix=${encodeURIComponent(key)}`;
456
+ }
457
+
458
+ /**
459
+ * Upload a file to S3 via the AWS CLI. Returns { uri, url } where `uri` is the
460
+ * final s3:// destination and `url` is a browsable https console link.
461
+ * Fails gracefully (throws a descriptive error) when the AWS CLI is missing or
462
+ * the copy fails; callers should keep the local tarball on failure.
463
+ */
464
+ export function pushToS3(file, s3Uri) {
465
+ if (!/^s3:\/\//.test(s3Uri)) throw new Error(`--push destination must be an s3:// URI, got: ${s3Uri}`);
466
+ // If the URI ends with "/", upload into it under the file's basename.
467
+ const dest = s3Uri.endsWith("/") ? s3Uri + path.basename(file) : s3Uri;
468
+ const res = spawnSync("aws", ["s3", "cp", file, dest], {
469
+ stdio: ["ignore", "pipe", "pipe"],
470
+ });
471
+ if (res.error && res.error.code === "ENOENT") {
472
+ throw new Error("aws CLI not found on PATH — install it or push the tarball manually.");
473
+ }
474
+ if (res.status !== 0) {
475
+ throw new Error(`aws s3 cp failed: ${res.stderr?.toString() || "unknown"}`);
476
+ }
477
+ return { uri: dest, url: s3ConsoleUrl(dest) };
478
+ }