@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/README.md +41 -9
- package/package.json +1 -1
- package/src/agent.js +37 -6
- package/src/bench.js +65 -8
- package/src/cli.js +132 -6
- package/src/ledger.js +68 -0
- package/src/model.js +8 -1
- package/src/models.js +1 -0
- package/src/project-bench.js +154 -0
- package/src/project.js +89 -0
- package/src/prompts.js +2 -0
- package/src/report.html +77 -30
- package/src/report.js +106 -0
- package/src/result-contract.js +58 -0
- package/src/verifier-runner.js +159 -0
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
|
+
}
|