@hackerrank/astra-cli 0.1.5 → 0.1.7

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/src/report.js CHANGED
@@ -83,6 +83,9 @@ export function scanRuns(rootDir) {
83
83
  }
84
84
 
85
85
  export function buildSummary(runs) {
86
+ runs = runs.map((run) => ({ ...run, criteria: flattenCriteria(run.criteria) }));
87
+ const verifierCriteria = summarizeCriteria(runs);
88
+ const verifierMatrix = buildVerifierMatrix(runs);
86
89
  const leaderboard = groupBy(runs, (r) => r.slug).map(([slug, rs]) => {
87
90
  const costRuns = rs.filter((r) => r.cost_source);
88
91
  const sources = new Set(costRuns.map((r) => r.cost_source));
@@ -92,6 +95,7 @@ export function buildSummary(runs) {
92
95
  outcomes[k] = (outcomes[k] || 0) + 1;
93
96
  }
94
97
  const completed = rs.filter((r) => r.completed).length;
98
+ const verified = rs.filter((r) => r.verification?.score);
95
99
  return {
96
100
  model: rs[0].model,
97
101
  reasoning: rs[0].reasoning,
@@ -99,6 +103,11 @@ export function buildSummary(runs) {
99
103
  runs: rs.length,
100
104
  completed,
101
105
  completion_rate: rs.length ? completed / rs.length : 0,
106
+ verification_runs: verified.length,
107
+ verification_rate: rs.length ? verified.length / rs.length : 0,
108
+ average_score: verified.length ? avg(verified, (r) => r.verification.score.percentage) : null,
109
+ hard_passes: verified.filter((r) => r.verification.score.hardFailPassed === true).length,
110
+ criteria_pass_rate: criterionRate(rs),
102
111
  outcomes,
103
112
  avg_steps: avg(rs, (r) => r.steps),
104
113
  sum_steps: sum(rs, (r) => r.steps),
@@ -125,6 +134,7 @@ export function buildSummary(runs) {
125
134
  const matrix = groupBy(runs, (r) => `${r.slug}\u0000${r.task_id}`).map(([, rs]) => {
126
135
  const completed = rs.filter((r) => r.completed).length;
127
136
  const costRuns = rs.filter((r) => r.cost_source);
137
+ const verified = rs.filter((r) => r.verification?.score);
128
138
  return {
129
139
  model: rs[0].model,
130
140
  reasoning: rs[0].reasoning,
@@ -134,6 +144,11 @@ export function buildSummary(runs) {
134
144
  k: rs.length,
135
145
  completed,
136
146
  completion_rate: rs.length ? completed / rs.length : 0,
147
+ verification_runs: verified.length,
148
+ verification_rate: rs.length ? verified.length / rs.length : 0,
149
+ average_score: verified.length ? avg(verified, (r) => r.verification.score.percentage) : null,
150
+ hard_passes: verified.filter((r) => r.verification.score.hardFailPassed === true).length,
151
+ criteria_pass_rate: criterionRate(rs),
137
152
  avg_steps: avg(rs, (r) => r.steps),
138
153
  avg_tokens: avg(rs, (r) => r.tokens.total),
139
154
  avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
@@ -193,6 +208,7 @@ export function buildSummary(runs) {
193
208
 
194
209
  const costRuns = runs.filter((r) => r.cost_source);
195
210
  const completed = runs.filter((r) => r.completed).length;
211
+ const verified = runs.filter((r) => r.verification?.score);
196
212
  const sources = new Set(costRuns.map((r) => r.cost_source));
197
213
 
198
214
  return {
@@ -204,6 +220,10 @@ export function buildSummary(runs) {
204
220
  runs: runs.length,
205
221
  completed,
206
222
  completion_rate: runs.length ? completed / runs.length : 0,
223
+ verification_runs: verified.length,
224
+ verification_rate: runs.length ? verified.length / runs.length : 0,
225
+ average_score: verified.length ? avg(verified, (r) => r.verification.score.percentage) : null,
226
+ hard_passes: verified.filter((r) => r.verification.score.hardFailPassed === true).length,
207
227
  cost_usd: costRuns.length ? round(sum(costRuns, (r) => r.cost_usd), 6) : null,
208
228
  cost_source: sources.size === 0 ? "unknown" : sources.size === 1 ? [...sources][0] : "mixed",
209
229
  tokens: sum(runs, (r) => r.tokens.total),
@@ -211,6 +231,8 @@ export function buildSummary(runs) {
211
231
  },
212
232
  leaderboard,
213
233
  matrix,
234
+ verifier_criteria: verifierCriteria,
235
+ verifier_matrix: verifierMatrix,
214
236
  runs,
215
237
  step_series,
216
238
  };
@@ -235,6 +257,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
235
257
  const metricsPath = path.join(dir, "metrics.csv");
236
258
  const taskPath = path.join(dir, "task.md");
237
259
  const projectPath = path.join(dir, "project.json");
260
+ const resultPath = path.join(dir, "result.json");
238
261
  if (!fs.existsSync(trajPath) && !fs.existsSync(metricsPath)) return null;
239
262
 
240
263
  let traj = null;
@@ -247,6 +270,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
247
270
  }
248
271
  const metricsRow = fs.existsSync(metricsPath) ? parseCsv(fs.readFileSync(metricsPath, "utf8"))[0] : null;
249
272
  const project = readJson(projectPath);
273
+ const result = readJson(resultPath);
250
274
  const taskMd = fs.existsSync(taskPath) ? fs.readFileSync(taskPath, "utf8") : traj?.info?.task || "";
251
275
  const parsed = parseSlug(slug);
252
276
  const model = metricsRow?.model || traj?.info?.model || parsed.model;
@@ -286,6 +310,12 @@ function readRunDir({ rootDir, slug, runId, dir }) {
286
310
  status,
287
311
  completed: status === "completed",
288
312
  error: null,
313
+ verification: result?.verification ?? null,
314
+ solved_score: result?.verification?.solvedScore ?? (result?.verification?.score?.percentage ?? "NA"),
315
+ verifier_status: result?.verification?.status ?? "not_configured",
316
+ verifier_error: result?.verification?.error ?? null,
317
+ failure_owner: result?.verification?.failureOwner ?? null,
318
+ criteria: flattenCriteria(result?.verification?.criteria),
289
319
  steps: num(metricsRow?.steps ?? traj?.info?.n_steps),
290
320
  n_calls: num(metricsRow?.n_calls ?? traj?.info?.n_calls),
291
321
  n_commands: num(metricsRow?.n_commands),
@@ -296,6 +326,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
296
326
  tokens,
297
327
  cost_usd: costSource ? num(metricsRow?.cost_usd ?? traj?.info?.cost?.usd) : null,
298
328
  cost_source: costSource || "",
329
+ resume_segments: Array.isArray(traj?.bench?.segments) ? traj.bench.segments : [],
299
330
  timeline,
300
331
  paths: {
301
332
  dir: path.relative(rootDir, dir),
@@ -303,6 +334,7 @@ function readRunDir({ rootDir, slug, runId, dir }) {
303
334
  workspace: path.relative(rootDir, path.join(dir, "workspace")),
304
335
  task: path.relative(rootDir, taskPath),
305
336
  run: path.relative(rootDir, path.join(dir, "run.json")),
337
+ result: path.relative(rootDir, resultPath),
306
338
  },
307
339
  };
308
340
  }
@@ -434,6 +466,80 @@ function groupBy(arr, keyFn) {
434
466
  return [...m.entries()];
435
467
  }
436
468
 
469
+ function criterionRate(runs) {
470
+ const values = [];
471
+ for (const run of runs) {
472
+ for (const criterion of run.criteria || []) {
473
+ values.push(criterion.status === "passed" ? 1 : 0);
474
+ }
475
+ }
476
+ return values.length ? sum(values, (value) => value) / values.length : null;
477
+ }
478
+
479
+ // A task may group related assertions under one scored criterion. Benchmark
480
+ // reports should still expose each assertion as a named test case, while score
481
+ // ownership remains entirely with the task verifier.
482
+ function flattenCriteria(criteria) {
483
+ if (!Array.isArray(criteria)) return [];
484
+ return criteria.flatMap((criterion) => {
485
+ if (!Array.isArray(criterion?.checks) || criterion.checks.length === 0) {
486
+ return [criterion];
487
+ }
488
+ return criterion.checks.map((check, index) => ({
489
+ id: `${criterion.id}.${check.name || `check-${index + 1}`}`,
490
+ title: check.description || check.name || criterion.title || criterion.id,
491
+ status: check.passed === true ? "passed" : check.status === "blocked" ? "blocked" : check.status === "error" ? "error" : "failed",
492
+ parentId: criterion.id,
493
+ }));
494
+ });
495
+ }
496
+
497
+ function summarizeCriteria(runs) {
498
+ const grouped = new Map();
499
+ for (const run of runs) {
500
+ for (const criterion of run.criteria || []) {
501
+ if (!grouped.has(criterion.id)) grouped.set(criterion.id, { id: criterion.id, title: criterion.title || criterion.id, runs: 0, passed: 0, failed: 0, blocked: 0, error: 0 });
502
+ const item = grouped.get(criterion.id);
503
+ item.runs += 1;
504
+ if (criterion.status === "passed") item.passed += 1;
505
+ else if (criterion.status === "blocked") item.blocked += 1;
506
+ else if (criterion.status === "error") item.error += 1;
507
+ else item.failed += 1;
508
+ }
509
+ }
510
+ return [...grouped.values()].map((item) => ({
511
+ ...item,
512
+ pass_rate: item.runs ? item.passed / item.runs : 0,
513
+ })).sort((a, b) => a.id.localeCompare(b.id));
514
+ }
515
+
516
+ function buildVerifierMatrix(runs) {
517
+ const models = [...new Set(runs.map((run) => run.slug))].sort();
518
+ const byCriterion = new Map();
519
+ for (const run of runs) {
520
+ for (const criterion of run.criteria || []) {
521
+ if (!byCriterion.has(criterion.id)) byCriterion.set(criterion.id, { title: criterion.title || criterion.id, byModel: new Map() });
522
+ const entry = byCriterion.get(criterion.id);
523
+ const byModel = entry.byModel;
524
+ if (!byModel.has(run.slug)) byModel.set(run.slug, { passed: 0, failed: 0, blocked: 0, error: 0, total: 0 });
525
+ const cell = byModel.get(run.slug);
526
+ cell.total += 1;
527
+ if (criterion.status === "passed") cell.passed += 1;
528
+ else if (criterion.status === "blocked") cell.blocked += 1;
529
+ else if (criterion.status === "error") cell.error += 1;
530
+ else cell.failed += 1;
531
+ }
532
+ }
533
+ return {
534
+ models,
535
+ criteria: [...byCriterion.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, entry]) => ({
536
+ id,
537
+ title: entry.title,
538
+ cells: Object.fromEntries(models.map((model) => [model, entry.byModel.get(model) || null])),
539
+ })),
540
+ };
541
+ }
542
+
437
543
  function sum(arr, fn) {
438
544
  return arr.reduce((a, x) => a + (Number(fn(x)) || 0), 0);
439
545
  }
@@ -0,0 +1,58 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const TRANSIENT = new Set([".git", "node_modules", "dist", "build", "coverage", "__pycache__", ".pytest_cache"]);
6
+
7
+ function walk(root, current = root, entries = []) {
8
+ for (const item of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
9
+ if (TRANSIENT.has(item.name)) continue;
10
+ const absolute = path.join(current, item.name);
11
+ const relative = path.relative(root, absolute).split(path.sep).join("/");
12
+ if (item.isDirectory()) walk(root, absolute, entries);
13
+ else if (item.isFile() && !item.isSymbolicLink()) entries.push({ relative, bytes: fs.readFileSync(absolute) });
14
+ }
15
+ return entries;
16
+ }
17
+
18
+ export function candidateTreeHash(root) {
19
+ const hash = crypto.createHash("sha256");
20
+ for (const entry of walk(path.resolve(root))) {
21
+ hash.update(entry.relative).update("\0").update(entry.bytes).update("\0");
22
+ }
23
+ return hash.digest("hex");
24
+ }
25
+
26
+ function finiteNumber(value) {
27
+ return typeof value === "number" && Number.isFinite(value);
28
+ }
29
+
30
+ export function validateVerifierResult(value, expected = {}) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, error: "verifier result must be an object" };
32
+ if (value.version !== 1) return { ok: false, error: "unsupported verifier result version" };
33
+ if (!["passed", "failed", "blocked", "error"].includes(value.status)) return { ok: false, error: "invalid verifier status" };
34
+ if (!Array.isArray(value.criteria)) return { ok: false, error: "verifier criteria must be an array" };
35
+ const score = value.score;
36
+ if (!score || !finiteNumber(score.points) || !finiteNumber(score.maxPoints) || !finiteNumber(score.percentage)) {
37
+ return { ok: false, error: "verifier score must contain finite numbers" };
38
+ }
39
+ if (score.maxPoints <= 0 || score.points < 0 || score.points > score.maxPoints || score.percentage < 0 || score.percentage > 100) {
40
+ return { ok: false, error: "verifier score is outside its valid range" };
41
+ }
42
+ if (Math.abs(score.percentage - (score.points / score.maxPoints) * 100) > 0.01) return { ok: false, error: "verifier score arithmetic is inconsistent" };
43
+ if (typeof score.hardFailPassed !== "boolean" || !Array.isArray(score.hardFailCriteria)) return { ok: false, error: "verifier hard-fail fields are invalid" };
44
+ if (value.task && (value.task.id !== expected.taskId || value.task.version !== expected.taskVersion)) return { ok: false, error: "verifier task identity mismatch" };
45
+ if (value.candidateSha256 && value.candidateSha256 !== expected.candidateSha256) return { ok: false, error: "verifier candidate identity mismatch" };
46
+ return { ok: true, result: value };
47
+ }
48
+
49
+ export function mergeRunResult({ task = null, cell, generation, verification, artifacts }) {
50
+ return {
51
+ schemaVersion: 1,
52
+ task,
53
+ cell,
54
+ generation,
55
+ verification,
56
+ artifacts,
57
+ };
58
+ }
@@ -0,0 +1,159 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ import { validateVerifierResult } from "./result-contract.js";
6
+
7
+ function redactOutput(value) {
8
+ return String(value || "")
9
+ .replace(/(authorization\s*:\s*bearer\s+)[^\s,;]+/gi, "$1[REDACTED]")
10
+ .replace(/(--token\s+)[^\s,;]+/gi, "$1[REDACTED]")
11
+ .slice(-2000);
12
+ }
13
+
14
+ function runCommand(command, { cwd, env, timeoutSeconds }) {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(command, { cwd, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
20
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
21
+ let timedOut = false;
22
+ const timer = setTimeout(() => {
23
+ timedOut = true;
24
+ child.kill("SIGTERM");
25
+ setTimeout(() => child.kill("SIGKILL"), 250);
26
+ }, Math.max(1, Number(timeoutSeconds) || 3600) * 1000);
27
+ child.on("close", (code, signal) => {
28
+ clearTimeout(timer);
29
+ resolve({ code, signal, timedOut, stdout, stderr });
30
+ });
31
+ child.on("error", (error) => {
32
+ clearTimeout(timer);
33
+ resolve({ code: null, signal: null, timedOut: false, stdout, stderr: `${stderr}${error.message}` });
34
+ });
35
+ });
36
+ }
37
+
38
+ function startProcess(command, { cwd, env }) {
39
+ return spawn(command, { cwd, env, shell: true, stdio: ["ignore", "ignore", "ignore"] });
40
+ }
41
+
42
+ async function acquireVerifierLock(projectRoot, timeoutSeconds) {
43
+ const lockPath = path.join(projectRoot, ".astra", "verifier.lock");
44
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
45
+ const deadline = Date.now() + Math.max(1, Number(timeoutSeconds) || 3600) * 1000;
46
+ while (Date.now() < deadline) {
47
+ try {
48
+ const descriptor = fs.openSync(lockPath, "wx");
49
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
50
+ return () => {
51
+ try { fs.closeSync(descriptor); } catch {}
52
+ try { fs.rmSync(lockPath, { force: true }); } catch {}
53
+ };
54
+ } catch (error) {
55
+ if (error?.code !== "EEXIST") throw error;
56
+ await new Promise((resolve) => setTimeout(resolve, 100));
57
+ }
58
+ }
59
+ throw new Error("timed out waiting for the task verifier lock");
60
+ }
61
+
62
+ async function waitReady(url, timeoutSeconds) {
63
+ const deadline = Date.now() + Math.max(1, Number(timeoutSeconds) || 30) * 1000;
64
+ while (Date.now() < deadline) {
65
+ try {
66
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
67
+ if (response.ok) return true;
68
+ } catch {
69
+ // Candidate is still starting.
70
+ }
71
+ await new Promise((resolve) => setTimeout(resolve, 250));
72
+ }
73
+ return false;
74
+ }
75
+
76
+ export async function runVerifier({ project, cell, runDir, candidateDir, config, expected = {} }) {
77
+ const started = Date.now();
78
+ fs.mkdirSync(runDir, { recursive: true });
79
+ const reportPath = path.resolve(runDir, config.report || "verifier.json");
80
+ if (reportPath !== path.resolve(runDir) && !reportPath.startsWith(`${path.resolve(runDir)}${path.sep}`)) {
81
+ return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds: 0, error: "verifier report must stay inside the run directory" };
82
+ }
83
+ // Never accept a report left by an earlier interrupted attempt.
84
+ try { fs.rmSync(reportPath, { force: true }); } catch (error) {
85
+ return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds: 0, error: `cannot clear previous verifier report: ${error.message}` };
86
+ }
87
+ const env = {
88
+ PATH: process.env.PATH,
89
+ HOME: process.env.HOME,
90
+ ...(process.env.DOCKER_HOST ? { DOCKER_HOST: process.env.DOCKER_HOST } : {}),
91
+ ASTRA_VERIFIER_REPORT: reportPath,
92
+ ASTRA_CANDIDATE_WORKSPACE: candidateDir,
93
+ ASTRA_TASK_ID: project.id,
94
+ ASTRA_TASK_VERSION: String(project.version),
95
+ ASTRA_TASK_TYPE: project.type || "brownfield",
96
+ ASTRA_CELL_KEY: cell.cellKey,
97
+ ASTRA_PROJECT_ROOT: project.root,
98
+ ...(config.baseUrl ? { SCIM_BASE_URL: config.baseUrl } : {}),
99
+ ...(config.tokenEnv && process.env[config.tokenEnv] ? { [config.tokenEnv]: process.env[config.tokenEnv] } : {}),
100
+ };
101
+ let candidateProcess = null;
102
+ let releaseLock = null;
103
+ try {
104
+ // Candidate Docker Compose files commonly claim fixed host ports. Serialize
105
+ // verification per task while allowing costly model generation to run in
106
+ // parallel safely.
107
+ releaseLock = await acquireVerifierLock(project.root, config.timeoutSeconds);
108
+ if (config.startCommand) {
109
+ candidateProcess = startProcess(config.startCommand, { cwd: candidateDir, env });
110
+ // A cold Docker build legitimately exceeds a minute. Give the candidate
111
+ // a bounded five-minute startup window, still capped by task timeout.
112
+ if (config.readinessUrl && !(await waitReady(config.readinessUrl, Math.min(config.timeoutSeconds, 300)))) {
113
+ return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds: (Date.now() - started) / 1000, error: "candidate readiness timed out" };
114
+ }
115
+ }
116
+ const commandResult = await runCommand(config.command, {
117
+ cwd: project.root,
118
+ env,
119
+ timeoutSeconds: config.timeoutSeconds,
120
+ });
121
+ const durationSeconds = (Date.now() - started) / 1000;
122
+ if (commandResult.timedOut) return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds, error: "verifier timed out" };
123
+ if (!fs.existsSync(reportPath)) {
124
+ const detail = redactOutput(commandResult.stderr);
125
+ return {
126
+ status: "error",
127
+ failureOwner: "infrastructure",
128
+ solvedScore: "NA",
129
+ durationSeconds,
130
+ error: detail ? `verifier report was not produced: ${detail}` : "verifier report was not produced",
131
+ exitCode: commandResult.code,
132
+ };
133
+ }
134
+ let raw;
135
+ try {
136
+ raw = JSON.parse(fs.readFileSync(reportPath, "utf8"));
137
+ } catch (error) {
138
+ return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds, error: `invalid verifier JSON: ${error.message}`, exitCode: commandResult.code };
139
+ }
140
+ const validation = validateVerifierResult(raw, expected);
141
+ if (!validation.ok) return { status: "error", failureOwner: "infrastructure", solvedScore: "NA", durationSeconds, error: validation.error, exitCode: commandResult.code };
142
+ return {
143
+ status: raw.status,
144
+ failureOwner: raw.status === "failed" ? "candidate" : raw.status === "passed" ? null : "infrastructure",
145
+ score: raw.score,
146
+ solvedScore: raw.score?.percentage ?? "NA",
147
+ criteria: raw.criteria,
148
+ reportPath,
149
+ durationSeconds,
150
+ exitCode: commandResult.code,
151
+ };
152
+ } finally {
153
+ if (config.stopCommand) {
154
+ await runCommand(config.stopCommand, { cwd: candidateDir, env, timeoutSeconds: Math.min(Number(config.timeoutSeconds) || 30, 30) });
155
+ }
156
+ if (candidateProcess && !candidateProcess.killed) candidateProcess.kill("SIGTERM");
157
+ if (releaseLock) releaseLock();
158
+ }
159
+ }