@hackerrank/astra-cli 0.1.6 → 0.1.8

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.
@@ -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
+ }