badgr-cli 1.0.46 → 1.0.48
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/package.json +4 -2
- package/src/api.js +39 -0
- package/src/badgr.js +15 -0
- package/src/commands/batch.js +620 -0
- package/src/commands/rerun.js +75 -0
- package/src/commands/run.js +3 -19
- package/src/commands/train.js +49 -21
- package/src/store.js +27 -0
- package/src/workloadSpec.js +126 -0
- package/tests/api.test.js +29 -1
- package/tests/batch.test.js +329 -0
- package/tests/rerun.test.js +94 -0
- package/tests/train-lora-dataset.test.js +176 -0
- package/tests/workload-spec.test.js +180 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr batch — generic containerized batch-job runner.
|
|
3
|
+
*
|
|
4
|
+
* badgr batch run <workload.yml>
|
|
5
|
+
* badgr batch status <run_id>
|
|
6
|
+
* badgr batch logs <run_id>
|
|
7
|
+
* badgr batch artifacts <run_id>
|
|
8
|
+
* badgr batch receipt <run_id>
|
|
9
|
+
* badgr batch compare <run_a> <run_b>
|
|
10
|
+
*
|
|
11
|
+
* This is the generic bridge from `badgr run` into non-vLLM GPU workloads
|
|
12
|
+
* (CV batch, video batch, scientific batch, Monte Carlo, sim, physical-AI
|
|
13
|
+
* eval) — a thin wrapper over the same /v1/run + monitorBatchJob primitives
|
|
14
|
+
* `badgr train`/`transcribe`/`embed` already use, not a new execution path.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, readdirSync } from 'fs';
|
|
17
|
+
import { join, resolve } from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
|
|
20
|
+
import { requireApiKey, CONFIG_DIR } from '../config.js';
|
|
21
|
+
import { parseWorkloadYaml } from '../workloadSpec.js';
|
|
22
|
+
import { addReceipt, updateReceipt, generateReceiptId, loadStore } from '../store.js';
|
|
23
|
+
import { normalizeTier, callWithFallback } from '../fallback.js';
|
|
24
|
+
import { monitorBatchJob, fmtRuntime } from '../batch.js';
|
|
25
|
+
import { uploadBlob } from '../api.js';
|
|
26
|
+
import { logsCommand } from './logs.js';
|
|
27
|
+
|
|
28
|
+
// Server-side watchdog / batch.js reason strings, mapped to the vocabulary
|
|
29
|
+
// the batch spec asks for. The raw reason is always shown too.
|
|
30
|
+
const FAILURE_REASON_LABELS = {
|
|
31
|
+
max_runtime: 'runtime_limit_exceeded',
|
|
32
|
+
'max-runtime': 'runtime_limit_exceeded',
|
|
33
|
+
max_cost: 'cost_cap_exceeded',
|
|
34
|
+
'max-cost': 'cost_cap_exceeded',
|
|
35
|
+
provisioning_timeout: 'provisioning_timeout',
|
|
36
|
+
idle_timeout: 'idle_timeout',
|
|
37
|
+
infrastructure: 'infrastructure_failure',
|
|
38
|
+
interrupted: 'interrupted_by_user',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function displayFailureReason(raw) {
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
return FAILURE_REASON_LABELS[raw] ?? raw;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function artifactsDir(runId) {
|
|
47
|
+
return join(CONFIG_DIR, 'batch-artifacts', runId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findBatchRun(runId) {
|
|
51
|
+
const { receipts } = loadStore();
|
|
52
|
+
return receipts.find(r => r.receiptId === runId || r.deploymentId === runId) ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function apiRequest(config, path, opts = {}) {
|
|
56
|
+
const { callApi } = await import('../api.js');
|
|
57
|
+
return callApi(path, { apiKey: config.apiKey, baseUrl: config.baseUrl, ...opts });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// inputs archive (tar.gz, member paths = container path minus leading '/')
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
async function _buildInputsArchive(inputs, chalk) {
|
|
65
|
+
const { default: archiver } = await import('archiver');
|
|
66
|
+
const { createWriteStream } = await import('fs');
|
|
67
|
+
const tmpFile = join(os.tmpdir(), `badgr-batch-inputs-${Date.now()}.tar.gz`);
|
|
68
|
+
|
|
69
|
+
await new Promise((res, rej) => {
|
|
70
|
+
const output = createWriteStream(tmpFile);
|
|
71
|
+
const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } });
|
|
72
|
+
output.on('close', res);
|
|
73
|
+
archive.on('error', rej);
|
|
74
|
+
archive.pipe(output);
|
|
75
|
+
for (const { localPath, containerPath } of inputs) {
|
|
76
|
+
if (!existsSync(localPath)) {
|
|
77
|
+
rej(new Error(`Input not found: ${localPath} (declared as ${containerPath})`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const memberName = containerPath.replace(/^\/+/, '');
|
|
81
|
+
const stat = statSync(localPath);
|
|
82
|
+
if (stat.isDirectory()) {
|
|
83
|
+
archive.directory(localPath, memberName);
|
|
84
|
+
} else {
|
|
85
|
+
archive.file(localPath, { name: memberName });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
archive.finalize();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return tmpFile;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function _uploadInputsArchive(config, inputs, chalk) {
|
|
95
|
+
process.stdout.write(chalk.dim(' Packing inputs...'));
|
|
96
|
+
const tmpFile = await _buildInputsArchive(inputs, chalk);
|
|
97
|
+
const sizeMb = (statSync(tmpFile).size / 1024 / 1024).toFixed(1);
|
|
98
|
+
process.stdout.write(chalk.dim(` ${sizeMb} MB\n`));
|
|
99
|
+
|
|
100
|
+
process.stdout.write(chalk.dim(' Uploading inputs...'));
|
|
101
|
+
const fileData = readFileSync(tmpFile);
|
|
102
|
+
let uploadResp;
|
|
103
|
+
try {
|
|
104
|
+
uploadResp = await uploadBlob(config, { data: fileData, filename: 'inputs.tar.gz', contentType: 'application/gzip' });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Inputs upload failed: ${err.message}\n` +
|
|
108
|
+
` No GPU has been provisioned yet — nothing was billed. Fix the upload (check network/API key) and re-run.`,
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
unlinkSync(tmpFile);
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(chalk.dim(' done\n'));
|
|
114
|
+
return uploadResp.code_uri;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// output artifacts: fetch metadata → download tar.gz → extract
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async function _fetchArtifactMetadata(config, runId) {
|
|
122
|
+
try {
|
|
123
|
+
return await apiRequest(config, `/deployments/${runId}/artifacts`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.httpStatus === 404) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`No artifact was ever uploaded for run ${runId}. This means either: the workload had no ` +
|
|
128
|
+
`\`outputs:\` declared, the command failed before producing any of them, or the runner ` +
|
|
129
|
+
`could not reach Badgr to upload (check \`badgr batch logs ${runId}\` for an upload error).`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (err.httpStatus === 410) {
|
|
133
|
+
throw new Error(`Artifact for run ${runId} has expired (48h retention) and can no longer be downloaded.`);
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function downloadAndExtractArtifacts(config, runId) {
|
|
140
|
+
const meta = await _fetchArtifactMetadata(config, runId);
|
|
141
|
+
const destDir = artifactsDir(runId);
|
|
142
|
+
mkdirSync(destDir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
// Prefer the CLI's configured HTTPS base URL over meta.download_url.
|
|
145
|
+
// Behind the gateway, request.base_url is often http://…, and fetch() following
|
|
146
|
+
// the http→https redirect drops the Authorization header → 401.
|
|
147
|
+
const base = String(config.baseUrl || '').replace(/\/$/, '');
|
|
148
|
+
const downloadUrl = base
|
|
149
|
+
? `${base}/deployments/${runId}/artifacts/download`
|
|
150
|
+
: meta.download_url;
|
|
151
|
+
|
|
152
|
+
const res = await fetch(downloadUrl, {
|
|
153
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Artifact download failed for run ${runId}: ${res.status} ${res.statusText}. ` +
|
|
158
|
+
`The upload succeeded (metadata exists) but the file itself couldn't be fetched — retry, ` +
|
|
159
|
+
`or check backend storage (BADGR_STORAGE_DIR) if this persists.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
163
|
+
const tmpFile = join(os.tmpdir(), `badgr-batch-artifacts-${runId}.tar.gz`);
|
|
164
|
+
writeFileSync(tmpFile, buf);
|
|
165
|
+
try {
|
|
166
|
+
const tar = await import('tar');
|
|
167
|
+
await tar.x({ file: tmpFile, cwd: destDir });
|
|
168
|
+
} finally {
|
|
169
|
+
unlinkSync(tmpFile);
|
|
170
|
+
}
|
|
171
|
+
return destDir;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function missingOutputPaths(destDir, declaredOutputs) {
|
|
175
|
+
return declaredOutputs.filter(p => !existsSync(join(destDir, p)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function readMetricFile(destDir, metricFilePath) {
|
|
179
|
+
const full = join(destDir, metricFilePath);
|
|
180
|
+
if (!existsSync(full)) return null;
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(readFileSync(full, 'utf8'));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// badgr batch run <workload.yml>
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async function runSubcommand(config, args, chalk) {
|
|
193
|
+
const flags = {};
|
|
194
|
+
const positional = [];
|
|
195
|
+
for (let i = 0; i < args.length; i++) {
|
|
196
|
+
if (args[i] === '--tier') { flags.tier = args[++i]; continue; }
|
|
197
|
+
if (args[i] === '--region') { flags.region = args[++i]; continue; }
|
|
198
|
+
if (args[i] === '--gpu') { flags.gpu = args[++i]; continue; }
|
|
199
|
+
positional.push(args[i]);
|
|
200
|
+
}
|
|
201
|
+
const yamlPath = positional[0];
|
|
202
|
+
if (!yamlPath) {
|
|
203
|
+
console.error(chalk.red('\n Usage: badgr batch run <workload.yml>\n'));
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
requireApiKey(config);
|
|
209
|
+
|
|
210
|
+
let spec;
|
|
211
|
+
try {
|
|
212
|
+
spec = parseWorkloadYaml(resolve(yamlPath));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(chalk.bold(`\n📦 Batch: ${spec.name}\n`));
|
|
220
|
+
console.log(` ${chalk.bold('Image:')} ${spec.image}`);
|
|
221
|
+
console.log(` ${chalk.bold('Command:')} ${spec.command.join(' ')}`);
|
|
222
|
+
console.log(` ${chalk.bold('Max cost:')} $${spec.maxCost.toFixed(2)}`);
|
|
223
|
+
console.log(` ${chalk.bold('Max runtime:')} ${spec.maxRuntimeMinutes}min`);
|
|
224
|
+
if (spec.inputs.length) console.log(` ${chalk.bold('Inputs:')} ${spec.inputs.length} path(s)`);
|
|
225
|
+
if (spec.outputs.length) console.log(` ${chalk.bold('Outputs:')} ${spec.outputs.join(', ')}`);
|
|
226
|
+
console.log();
|
|
227
|
+
|
|
228
|
+
let inputsArchiveUrl = null;
|
|
229
|
+
if (spec.inputs.length > 0) {
|
|
230
|
+
try {
|
|
231
|
+
inputsArchiveUrl = await _uploadInputsArchive(config, spec.inputs, chalk);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const effectiveTier = normalizeTier(flags.tier);
|
|
240
|
+
const envObj = { ...spec.env };
|
|
241
|
+
if (inputsArchiveUrl) envObj.BADGR_INPUTS_ARCHIVE_URL = inputsArchiveUrl;
|
|
242
|
+
if (spec.outputs.length > 0) envObj.BADGR_OUTPUT_PATHS_JSON = JSON.stringify(spec.outputs);
|
|
243
|
+
|
|
244
|
+
function buildBody(tierOverride) {
|
|
245
|
+
return {
|
|
246
|
+
image: spec.image,
|
|
247
|
+
command: spec.command,
|
|
248
|
+
custom_image: true, // run spec.image directly — it must embed the badgr-runner entrypoint contract
|
|
249
|
+
gpu: flags.gpu ? flags.gpu.toUpperCase().replace(/-/g, '_') : 'AUTO',
|
|
250
|
+
gpu_count: 1,
|
|
251
|
+
...(flags.region ? { region: flags.region.toUpperCase() } : {}),
|
|
252
|
+
tier: tierOverride || effectiveTier,
|
|
253
|
+
env: envObj,
|
|
254
|
+
name: spec.name,
|
|
255
|
+
max_cost_usd: spec.maxCost,
|
|
256
|
+
max_runtime_seconds: spec.maxRuntimeMinutes * 60,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.stdout.write(chalk.dim(' Finding suitable capacity...\n'));
|
|
261
|
+
|
|
262
|
+
let dep;
|
|
263
|
+
try {
|
|
264
|
+
dep = await callWithFallback(
|
|
265
|
+
'/run',
|
|
266
|
+
{ apiKey: config.apiKey, baseUrl: config.baseUrl },
|
|
267
|
+
buildBody,
|
|
268
|
+
effectiveTier,
|
|
269
|
+
chalk,
|
|
270
|
+
{ thing: 'batch workload', cmd: 'badgr batch run' },
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err.isPaymentRequired) {
|
|
274
|
+
console.error(chalk.yellow(err.message));
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.error(err.message);
|
|
279
|
+
process.exitCode = 1;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const rcptId = dep.receipt_id || generateReceiptId();
|
|
284
|
+
addReceipt({
|
|
285
|
+
receiptId: rcptId,
|
|
286
|
+
action: 'badgr batch run',
|
|
287
|
+
deploymentId: dep.deployment_id,
|
|
288
|
+
workloadName: spec.name,
|
|
289
|
+
workloadImage: spec.image,
|
|
290
|
+
outputsDeclared: spec.outputs,
|
|
291
|
+
successMetric: spec.successMetric,
|
|
292
|
+
gpu: dep.gpu_type,
|
|
293
|
+
providerRoute: dep.provider ?? null,
|
|
294
|
+
tier: dep.tier ?? null,
|
|
295
|
+
maxCost: spec.maxCost,
|
|
296
|
+
maxRuntime: spec.maxRuntimeMinutes,
|
|
297
|
+
status: dep.status,
|
|
298
|
+
createdAt: new Date().toISOString(),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const rate = dep.cost_per_hour || 0;
|
|
302
|
+
console.log(chalk.dim(' Capacity found.\n'));
|
|
303
|
+
console.log(chalk.bold(` Run ID: ${chalk.cyan(dep.deployment_id)}`));
|
|
304
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
305
|
+
if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
|
|
306
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
307
|
+
|
|
308
|
+
const result = await monitorBatchJob(config, dep.deployment_id, rcptId, {
|
|
309
|
+
chalk,
|
|
310
|
+
maxRuntimeMs: spec.maxRuntimeMinutes * 60 * 1000,
|
|
311
|
+
maxCost: spec.maxCost,
|
|
312
|
+
ratePerHour: rate,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
console.log();
|
|
316
|
+
const finalCost = rate * (result.runtimeMs / 3_600_000);
|
|
317
|
+
console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(result.runtimeMs)}`);
|
|
318
|
+
if (rate > 0) console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)}`);
|
|
319
|
+
|
|
320
|
+
let dep2;
|
|
321
|
+
try {
|
|
322
|
+
dep2 = await apiRequest(config, `/deployments/${dep.deployment_id}`);
|
|
323
|
+
} catch {
|
|
324
|
+
dep2 = null;
|
|
325
|
+
}
|
|
326
|
+
const failureReason = displayFailureReason(dep2?.failure_reason);
|
|
327
|
+
if (failureReason) console.log(` ${chalk.bold('Failure reason:')} ${chalk.red(failureReason)}`);
|
|
328
|
+
console.log(` ${chalk.bold('Teardown:')} ${dep2?.teardown_ok === 'ok' ? chalk.green('ok') : dep2?.teardown_ok === 'failed' ? chalk.red('failed') : chalk.dim('n/a')}`);
|
|
329
|
+
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
330
|
+
|
|
331
|
+
// Artifacts — best-effort; a job that never produced output paths (or
|
|
332
|
+
// that failed before the runner could upload) simply has none.
|
|
333
|
+
let missing = [];
|
|
334
|
+
if (spec.outputs.length > 0) {
|
|
335
|
+
try {
|
|
336
|
+
const destDir = await downloadAndExtractArtifacts(config, dep.deployment_id);
|
|
337
|
+
missing = missingOutputPaths(destDir, spec.outputs);
|
|
338
|
+
console.log(` ${chalk.bold('Artifacts:')} ${destDir}`);
|
|
339
|
+
if (missing.length > 0) {
|
|
340
|
+
console.log(chalk.yellow(` Missing declared output paths: ${missing.join(', ')}`));
|
|
341
|
+
}
|
|
342
|
+
if (spec.successMetric) {
|
|
343
|
+
const metrics = readMetricFile(destDir, spec.successMetric.file);
|
|
344
|
+
if (metrics && spec.successMetric.key in metrics) {
|
|
345
|
+
const value = metrics[spec.successMetric.key];
|
|
346
|
+
console.log(` ${chalk.bold(spec.successMetric.key + ':')} ${value}`);
|
|
347
|
+
if (Array.isArray(metrics.scenarios)) {
|
|
348
|
+
const failed = metrics.scenarios.filter(s => s.passed === false).map(s => s.id);
|
|
349
|
+
if (failed.length > 0) console.log(` ${chalk.bold('Failed scenarios:')} ${failed.join(', ')}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.log(chalk.dim(` Artifacts not available: ${err.message}`));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
updateReceipt(rcptId, {
|
|
359
|
+
status: dep2?.status ?? result.status,
|
|
360
|
+
failureReason: dep2?.failure_reason ?? null,
|
|
361
|
+
teardownOk: dep2?.teardown_ok ?? null,
|
|
362
|
+
runtimeSeconds: dep2?.runtime_seconds ?? Math.round(result.runtimeMs / 1000),
|
|
363
|
+
finalCost: dep2?.accrued_cost_usd ?? finalCost,
|
|
364
|
+
provider: dep2?.provider ?? null,
|
|
365
|
+
missingOutputs: missing,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const incomplete = spec.outputs.length > 0 && missing.length > 0;
|
|
369
|
+
const succeeded = ['succeeded', 'completed'].includes(dep2?.status ?? result.status) && !incomplete;
|
|
370
|
+
if (succeeded) {
|
|
371
|
+
console.log(chalk.green('\n ✓ Batch run complete\n'));
|
|
372
|
+
} else if (incomplete) {
|
|
373
|
+
console.error(chalk.red(`\n ✗ Batch run incomplete — missing declared outputs\n`));
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
} else {
|
|
376
|
+
console.error(chalk.red('\n ✗ Batch run failed\n'));
|
|
377
|
+
process.exitCode = 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// badgr batch status <run_id>
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
async function statusSubcommand(config, args, chalk) {
|
|
386
|
+
const runId = args[0];
|
|
387
|
+
if (!runId) {
|
|
388
|
+
console.error(chalk.red('\n Usage: badgr batch status <run_id>\n'));
|
|
389
|
+
process.exitCode = 1;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
requireApiKey(config);
|
|
393
|
+
|
|
394
|
+
let dep;
|
|
395
|
+
try {
|
|
396
|
+
dep = await apiRequest(config, `/deployments/${runId}`);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(chalk.red(`\n ✗ Could not fetch status: ${err.message}\n`));
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const local = findBatchRun(runId);
|
|
404
|
+
console.log(chalk.bold(`\n📦 Batch run: ${runId}\n`));
|
|
405
|
+
if (local?.workloadName) console.log(` ${chalk.bold('Workload:')} ${local.workloadName}`);
|
|
406
|
+
console.log(` ${chalk.bold('Status:')} ${dep.status}`);
|
|
407
|
+
if (dep.provider) console.log(` ${chalk.bold('Provider:')} ${dep.provider}`);
|
|
408
|
+
if (dep.runtime_seconds != null) console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(dep.runtime_seconds * 1000)}`);
|
|
409
|
+
if (dep.accrued_cost_usd != null) console.log(` ${chalk.bold('Cost:')} $${dep.accrued_cost_usd.toFixed(4)}`);
|
|
410
|
+
const failureReason = displayFailureReason(dep.failure_reason);
|
|
411
|
+
if (failureReason) console.log(` ${chalk.bold('Failure:')} ${chalk.red(failureReason)}`);
|
|
412
|
+
console.log(` ${chalk.bold('Teardown:')} ${dep.teardown_ok === 'ok' ? 'ok' : dep.teardown_ok === 'failed' ? 'failed' : 'n/a'}`);
|
|
413
|
+
console.log();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// badgr batch artifacts <run_id>
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
async function artifactsSubcommand(config, args, chalk) {
|
|
421
|
+
const runId = args[0];
|
|
422
|
+
if (!runId) {
|
|
423
|
+
console.error(chalk.red('\n Usage: badgr batch artifacts <run_id>\n'));
|
|
424
|
+
process.exitCode = 1;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
requireApiKey(config);
|
|
428
|
+
|
|
429
|
+
let destDir;
|
|
430
|
+
try {
|
|
431
|
+
destDir = await downloadAndExtractArtifacts(config, runId);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(chalk.red(`\n ✗ Could not fetch artifacts: ${err.message}\n`));
|
|
434
|
+
process.exitCode = 1;
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const local = findBatchRun(runId);
|
|
439
|
+
console.log(chalk.bold(`\n📦 Artifacts: ${runId}\n`));
|
|
440
|
+
console.log(` ${chalk.bold('Extracted to:')} ${destDir}\n`);
|
|
441
|
+
|
|
442
|
+
function walk(dir, prefix = '') {
|
|
443
|
+
for (const entry of readdirSync(dir)) {
|
|
444
|
+
const full = join(dir, entry);
|
|
445
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
446
|
+
if (statSync(full).isDirectory()) {
|
|
447
|
+
walk(full, rel);
|
|
448
|
+
} else {
|
|
449
|
+
console.log(` ${chalk.dim(rel)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
walk(destDir);
|
|
454
|
+
|
|
455
|
+
if (local?.outputsDeclared?.length) {
|
|
456
|
+
const missing = missingOutputPaths(destDir, local.outputsDeclared);
|
|
457
|
+
if (missing.length > 0) {
|
|
458
|
+
console.log(chalk.yellow(`\n Missing declared output paths: ${missing.join(', ')}`));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
console.log();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// badgr batch receipt <run_id>
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
async function receiptSubcommand(config, args, chalk) {
|
|
469
|
+
const runId = args[0];
|
|
470
|
+
if (!runId) {
|
|
471
|
+
console.error(chalk.red('\n Usage: badgr batch receipt <run_id>\n'));
|
|
472
|
+
process.exitCode = 1;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
requireApiKey(config);
|
|
476
|
+
|
|
477
|
+
const local = findBatchRun(runId);
|
|
478
|
+
let dep = null;
|
|
479
|
+
try {
|
|
480
|
+
dep = await apiRequest(config, `/deployments/${runId}`);
|
|
481
|
+
} catch {
|
|
482
|
+
dep = null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!local && !dep) {
|
|
486
|
+
console.error(chalk.red(`\n Receipt not found: ${runId}\n`));
|
|
487
|
+
process.exitCode = 1;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
console.log(chalk.bold(`\n🧾 Batch receipt: ${runId}\n`));
|
|
492
|
+
console.log(` ${chalk.bold('Workload:')} ${local?.workloadName ?? '—'}`);
|
|
493
|
+
console.log(` ${chalk.bold('Image:')} ${local?.workloadImage ?? '—'}`);
|
|
494
|
+
console.log(` ${chalk.bold('Status:')} ${dep?.status ?? local?.status ?? '—'}`);
|
|
495
|
+
console.log(` ${chalk.bold('Provider:')} ${dep?.provider ?? local?.provider ?? '—'}`);
|
|
496
|
+
const runtimeS = dep?.runtime_seconds ?? local?.runtimeSeconds;
|
|
497
|
+
if (runtimeS != null) console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeS * 1000)}`);
|
|
498
|
+
const cost = dep?.accrued_cost_usd ?? local?.finalCost;
|
|
499
|
+
if (cost != null) console.log(` ${chalk.bold('Cost:')} $${cost.toFixed(4)}`);
|
|
500
|
+
const failureReason = displayFailureReason(dep?.failure_reason ?? local?.failureReason);
|
|
501
|
+
if (failureReason) console.log(` ${chalk.bold('Failure:')} ${chalk.red(failureReason)}`);
|
|
502
|
+
const teardownOk = dep?.teardown_ok ?? local?.teardownOk;
|
|
503
|
+
console.log(` ${chalk.bold('Teardown:')} ${teardownOk === 'ok' ? 'ok' : teardownOk === 'failed' ? 'failed' : 'n/a'}`);
|
|
504
|
+
console.log(` ${chalk.bold('Artifacts:')} ${existsSync(artifactsDir(runId)) ? artifactsDir(runId) : chalk.dim('not fetched — run `badgr batch artifacts ' + runId + '`')}`);
|
|
505
|
+
if (local?.missingOutputs?.length) {
|
|
506
|
+
console.log(chalk.yellow(` Missing outputs: ${local.missingOutputs.join(', ')}`));
|
|
507
|
+
}
|
|
508
|
+
console.log();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// badgr batch compare <run_a> <run_b>
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
export async function resolveMetric(config, ref, flags, chalk) {
|
|
516
|
+
// Direct path to a metrics.json file — for easy testing without a live run.
|
|
517
|
+
if (ref.endsWith('.json') && existsSync(resolve(ref))) {
|
|
518
|
+
const metrics = JSON.parse(readFileSync(resolve(ref), 'utf8'));
|
|
519
|
+
const key = flags.key ?? 'pass_rate';
|
|
520
|
+
const higherIsBetter = flags.higherIsBetter ?? true;
|
|
521
|
+
return { ref, key, higherIsBetter, value: metrics[key] };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Run ID — use the declared success_metric from the local receipt.
|
|
525
|
+
const local = findBatchRun(ref);
|
|
526
|
+
if (!local?.successMetric) {
|
|
527
|
+
throw new Error(`No success_metric known for run ${ref} (run \`badgr batch run\` with success_metric set, or pass a metrics.json path directly)`);
|
|
528
|
+
}
|
|
529
|
+
const destDir = existsSync(artifactsDir(ref)) ? artifactsDir(ref) : await downloadAndExtractArtifacts(config, ref);
|
|
530
|
+
const metrics = readMetricFile(destDir, local.successMetric.file);
|
|
531
|
+
if (!metrics) throw new Error(`Could not read ${local.successMetric.file} for run ${ref}`);
|
|
532
|
+
return {
|
|
533
|
+
ref,
|
|
534
|
+
key: local.successMetric.key,
|
|
535
|
+
higherIsBetter: local.successMetric.higherIsBetter,
|
|
536
|
+
value: metrics[local.successMetric.key],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function compareSubcommand(config, args, chalk) {
|
|
541
|
+
const flags = {};
|
|
542
|
+
const positional = [];
|
|
543
|
+
for (let i = 0; i < args.length; i++) {
|
|
544
|
+
if (args[i] === '--key') { flags.key = args[++i]; continue; }
|
|
545
|
+
if (args[i] === '--higher-is-better') { flags.higherIsBetter = args[++i] !== 'false'; continue; }
|
|
546
|
+
positional.push(args[i]);
|
|
547
|
+
}
|
|
548
|
+
const [runA, runB] = positional;
|
|
549
|
+
if (!runA || !runB) {
|
|
550
|
+
console.error(chalk.red('\n Usage: badgr batch compare <run_a> <run_b>\n'));
|
|
551
|
+
process.exitCode = 1;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let a, b;
|
|
556
|
+
try {
|
|
557
|
+
a = await resolveMetric(config, runA, flags, chalk);
|
|
558
|
+
b = await resolveMetric(config, runB, flags, chalk);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
561
|
+
process.exitCode = 1;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (typeof a.value !== 'number' || typeof b.value !== 'number') {
|
|
566
|
+
console.error(chalk.red(`\n ✗ Metric "${a.key}" is not numeric on one or both runs\n`));
|
|
567
|
+
process.exitCode = 1;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const delta = b.value - a.value;
|
|
572
|
+
const higherIsBetter = a.higherIsBetter;
|
|
573
|
+
const improved = higherIsBetter ? delta > 0 : delta < 0;
|
|
574
|
+
const regressed = higherIsBetter ? delta < 0 : delta > 0;
|
|
575
|
+
|
|
576
|
+
console.log(chalk.bold(`\n📊 Compare: ${runA} vs ${runB}\n`));
|
|
577
|
+
console.log(` ${chalk.bold('Metric:')} ${a.key}`);
|
|
578
|
+
console.log(` ${chalk.bold(runA + ':')} ${a.value}`);
|
|
579
|
+
console.log(` ${chalk.bold(runB + ':')} ${b.value}`);
|
|
580
|
+
console.log(` ${chalk.bold('Delta:')} ${delta >= 0 ? '+' : ''}${delta}`);
|
|
581
|
+
|
|
582
|
+
if (regressed) {
|
|
583
|
+
console.log(chalk.red(`\n ✗ Regression — ${runB} is worse than ${runA} on ${a.key}\n`));
|
|
584
|
+
process.exitCode = 1;
|
|
585
|
+
} else if (improved) {
|
|
586
|
+
console.log(chalk.green(`\n ✓ Improvement — ${runB} is better than ${runA} on ${a.key}\n`));
|
|
587
|
+
} else {
|
|
588
|
+
console.log(chalk.dim(`\n No change on ${a.key}\n`));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// Router
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
export async function batchCommand(config, args, chalk) {
|
|
597
|
+
const sub = args[0];
|
|
598
|
+
const rest = args.slice(1);
|
|
599
|
+
|
|
600
|
+
switch (sub) {
|
|
601
|
+
case 'run': return runSubcommand(config, rest, chalk);
|
|
602
|
+
case 'status': return statusSubcommand(config, rest, chalk);
|
|
603
|
+
case 'logs': return logsCommand(config, rest, chalk);
|
|
604
|
+
case 'artifacts': return artifactsSubcommand(config, rest, chalk);
|
|
605
|
+
case 'receipt': return receiptSubcommand(config, rest, chalk);
|
|
606
|
+
case 'compare': return compareSubcommand(config, rest, chalk);
|
|
607
|
+
default:
|
|
608
|
+
console.error(chalk.red(`Unknown batch subcommand: ${sub || '(none)'}\n`));
|
|
609
|
+
console.log([
|
|
610
|
+
'Usage:',
|
|
611
|
+
' badgr batch run <workload.yml>',
|
|
612
|
+
' badgr batch status <run_id>',
|
|
613
|
+
' badgr batch logs <run_id>',
|
|
614
|
+
' badgr batch artifacts <run_id>',
|
|
615
|
+
' badgr batch receipt <run_id>',
|
|
616
|
+
' badgr batch compare <run_a> <run_b>',
|
|
617
|
+
].join('\n'));
|
|
618
|
+
process.exitCode = 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { requireApiKey } from '../config.js';
|
|
2
|
+
import { findDeployment, addDeployment, addReceipt, generateReceiptId } from '../store.js';
|
|
3
|
+
import { rerunDeployment } from '../api.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* badgr rerun <deployment-id|name>
|
|
7
|
+
*
|
|
8
|
+
* Replays a previous job or endpoint with its exact original spec — same
|
|
9
|
+
* image, command, env vars, GPU, and cost/runtime caps. Works for one-off
|
|
10
|
+
* jobs (unlike `badgr restart`, which is endpoint-only) and never tears
|
|
11
|
+
* down the source deployment. Always creates a new deployment_id.
|
|
12
|
+
*/
|
|
13
|
+
export async function rerunCommand(config, args, chalk) {
|
|
14
|
+
const idOrName = args.find(a => !a.startsWith('--'));
|
|
15
|
+
|
|
16
|
+
if (!idOrName) {
|
|
17
|
+
console.error(chalk.red('Usage: badgr rerun <deployment-id|name>'));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
requireApiKey(config);
|
|
22
|
+
|
|
23
|
+
const localDep = findDeployment(idOrName);
|
|
24
|
+
const deploymentId = localDep?.id ?? idOrName;
|
|
25
|
+
|
|
26
|
+
process.stdout.write(chalk.dim(` Replaying ${deploymentId}...`));
|
|
27
|
+
|
|
28
|
+
let dep;
|
|
29
|
+
try {
|
|
30
|
+
dep = await rerunDeployment(config, deploymentId);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
process.stdout.write('\n');
|
|
33
|
+
console.error(chalk.red(`\n ✗ Could not rerun deployment: ${err.message}\n`));
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stdout.write('\n');
|
|
39
|
+
|
|
40
|
+
addDeployment({
|
|
41
|
+
id: dep.deployment_id,
|
|
42
|
+
name: dep.name,
|
|
43
|
+
type: dep.workload_type,
|
|
44
|
+
model: dep.model,
|
|
45
|
+
gpu: dep.gpu_type,
|
|
46
|
+
count: dep.gpu_count,
|
|
47
|
+
status: dep.status,
|
|
48
|
+
endpointUrl: dep.endpoint_url || dep.openai_base_url,
|
|
49
|
+
receiptId: dep.receipt_id,
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
costPerHour: dep.cost_per_hour || 0,
|
|
52
|
+
providerRoute: dep.provider ?? null,
|
|
53
|
+
tier: dep.tier ?? null,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const rcptId = dep.receipt_id || generateReceiptId();
|
|
57
|
+
addReceipt({
|
|
58
|
+
receiptId: rcptId,
|
|
59
|
+
action: 'badgr rerun',
|
|
60
|
+
deploymentId: dep.deployment_id,
|
|
61
|
+
gpu: dep.gpu_type,
|
|
62
|
+
status: dep.status,
|
|
63
|
+
rerunOf: deploymentId,
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const endpointUrl = dep.endpoint_url || dep.openai_base_url;
|
|
68
|
+
console.log(chalk.green('\n ✓ Rerun submitted\n'));
|
|
69
|
+
console.log(` ${chalk.bold('Replayed from:')} ${chalk.dim(deploymentId)}`);
|
|
70
|
+
console.log(` ${chalk.bold('New deployment:')} ${chalk.cyan(dep.deployment_id)}`);
|
|
71
|
+
if (endpointUrl) console.log(` ${chalk.bold('Base URL:')} ${chalk.cyan(endpointUrl)}`);
|
|
72
|
+
console.log(` ${chalk.bold('Status:')} badgr status ${dep.deployment_id}`);
|
|
73
|
+
console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
|
|
74
|
+
console.log();
|
|
75
|
+
}
|