badgr-cli 1.1.4 → 1.1.6
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 +12 -14
- package/package.json +2 -2
- package/src/api.js +28 -0
- package/src/badgr.js +1 -1
- package/src/commands/artifacts.js +5 -3
- package/src/commands/batch.js +6 -5
- package/src/commands/billing.js +3 -3
- package/src/commands/capacity.js +2 -2
- package/src/commands/deploy.js +2 -2
- package/src/commands/diagnose.js +276 -87
- package/src/commands/down.js +69 -14
- package/src/commands/embed.js +2 -2
- package/src/commands/heartbeat.js +3 -3
- package/src/commands/job.js +14 -3
- package/src/commands/login.js +4 -1
- package/src/commands/logs.js +35 -18
- package/src/commands/pull.js +4 -3
- package/src/commands/rerun.js +5 -3
- package/src/commands/restart.js +5 -3
- package/src/commands/run.js +5 -23
- package/src/commands/sbatch.js +2 -1
- package/src/commands/serve.js +145 -24
- package/src/commands/shell.js +2 -2
- package/src/commands/status.js +57 -13
- package/src/commands/test-run.js +2 -2
- package/src/commands/train.js +3 -3
- package/src/commands/transcribe.js +2 -2
- package/src/commands/up.js +2 -1
- package/src/config.js +19 -0
- package/src/deploymentLog.js +45 -0
- package/src/errors.js +7 -3
- package/src/fallback.js +51 -31
- package/src/onboarding.js +63 -4
- package/src/progress.js +5 -2
package/src/commands/diagnose.js
CHANGED
|
@@ -2,15 +2,16 @@ import fs from 'fs';
|
|
|
2
2
|
import { classifyPastedInput } from 'badgr-shared';
|
|
3
3
|
import { callApi } from '../api.js';
|
|
4
4
|
import { ensureLoggedIn } from '../onboarding.js';
|
|
5
|
+
import { webBaseUrl } from '../config.js';
|
|
5
6
|
|
|
6
7
|
const DIAGNOSE_HELP = `
|
|
7
|
-
Paste anything.
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Paste anything. This IS the Badgr Smoke Test: free, no GPU, no login.
|
|
9
|
+
Badgr detects the input, resolves a command, and automatically runs every
|
|
10
|
+
applicable free structural check on it. No GPU launches without explicit
|
|
11
|
+
--approve, which starts a real Badgr Run.
|
|
10
12
|
|
|
11
13
|
Usage:
|
|
12
14
|
badgr diagnose "<input>"
|
|
13
|
-
badgr diagnose "<input>" --smoke
|
|
14
15
|
badgr diagnose "<input>" --approve
|
|
15
16
|
badgr diagnose "<input>" --json
|
|
16
17
|
badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
|
|
@@ -26,24 +27,14 @@ Input (auto-detected):
|
|
|
26
27
|
Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
|
|
27
28
|
|
|
28
29
|
Flags:
|
|
29
|
-
--
|
|
30
|
-
produced command on top of the normal diagnosis --
|
|
31
|
-
command syntax, CLI-entrypoint corroboration,
|
|
32
|
-
referenced-file presence, Docker ENTRYPOINT/CMD
|
|
33
|
-
consistency, required env vars -- plus a client-side
|
|
34
|
-
check of any local file path you pasted (e.g. a
|
|
35
|
-
.gguf path), which only this machine can see. Never
|
|
36
|
-
starts a GPU, never requires login, never prints
|
|
37
|
-
"Verified" -- only "Smoke Checked" (AGENTS.md proof
|
|
38
|
-
levels). Can be combined with --approve: the smoke
|
|
39
|
-
checks print first, then the normal approve flow
|
|
40
|
-
runs (they are independent, non-blocking steps).
|
|
41
|
-
Unrelated to "badgr run --smoke", which launches a
|
|
42
|
-
real, billable cheapest-GPU job.
|
|
43
|
-
--approve Approve the capped smoke test after diagnosis (opens
|
|
30
|
+
--approve Launch a real Badgr Run after diagnosis (opens
|
|
44
31
|
browser sign-in automatically if not logged in).
|
|
45
|
-
This is a real, billable GPU-provisioned
|
|
46
|
-
distinct from the free
|
|
32
|
+
This is a real, billable GPU-provisioned execution
|
|
33
|
+
-- distinct from the free diagnosis above.
|
|
34
|
+
--smoke Deprecated, no-op. The free mechanical checks this
|
|
35
|
+
used to gate are now always run automatically as
|
|
36
|
+
part of plain "badgr diagnose" -- kept only so
|
|
37
|
+
existing scripts that pass it don't break.
|
|
47
38
|
--docker <image> Force Docker-image intake (override auto-detect)
|
|
48
39
|
--repo <url> Force repository intake (override auto-detect)
|
|
49
40
|
--comfyui <path> Force ComfyUI workflow intake (override auto-detect)
|
|
@@ -54,11 +45,18 @@ Flags:
|
|
|
54
45
|
--help, -h Show this help
|
|
55
46
|
|
|
56
47
|
Status: every run prints one of NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
|
|
57
|
-
NEEDS INFO
|
|
58
|
-
READY
|
|
59
|
-
SMOKE CHECKED --
|
|
60
|
-
INVALID
|
|
61
|
-
VERIFIED
|
|
48
|
+
NEEDS INFO -- cannot yet produce a complete command
|
|
49
|
+
READY -- complete evidence-backed command, no applicable checks to run
|
|
50
|
+
SMOKE CHECKED -- applicable checks ran; all PASS or SKIPPED
|
|
51
|
+
INVALID -- an applicable check actually FAILED
|
|
52
|
+
VERIFIED -- only after a real Badgr Run succeeds (never from diagnosis alone)
|
|
53
|
+
|
|
54
|
+
Checks run automatically whenever applicable: command syntax,
|
|
55
|
+
CLI-entrypoint corroboration, referenced-file presence, Docker
|
|
56
|
+
ENTRYPOINT/CMD consistency, required env vars -- plus a client-side check
|
|
57
|
+
of any local file path you pasted (e.g. a .gguf path), which only this
|
|
58
|
+
machine can see. Never starts a GPU, never requires login, never prints
|
|
59
|
+
"Verified" -- only "Smoke Checked" (AGENTS.md proof levels).
|
|
62
60
|
|
|
63
61
|
Run page: READY and SMOKE CHECKED results also create a free, anonymous
|
|
64
62
|
"Run" link (the same prepared case /run-issue's own confirm form
|
|
@@ -73,10 +71,36 @@ Run page: READY and SMOKE CHECKED results also create a free, anonymous
|
|
|
73
71
|
|
|
74
72
|
Safety: nothing runs from AI-extracted data without --approve.
|
|
75
73
|
No GPU launches without explicit approval and a credit check.
|
|
76
|
-
|
|
74
|
+
Diagnosis (with or without its automatic checks) never provisions
|
|
75
|
+
a GPU and never requires login.
|
|
77
76
|
|
|
78
77
|
Full interactive flow: https://aibadgr.com/run-issue`;
|
|
79
78
|
|
|
79
|
+
// Best-effort, purely local (no network, no AI) runtime/model preview for
|
|
80
|
+
// an already-classified "command" input -- shown immediately, before the
|
|
81
|
+
// `/run-issue/extract` request (which still runs unchanged right after, so
|
|
82
|
+
// the full server-side checks -- static incompatibility, missing info,
|
|
83
|
+
// etc. -- are untouched). An obvious `vllm serve facebook/opt-125m` used to
|
|
84
|
+
// sit behind "Analysing... Xs" with zero feedback about what Badgr even
|
|
85
|
+
// detected until the AI-backed extract call finished; the deterministic
|
|
86
|
+
// parser already knows the runtime and model instantly, so there's no
|
|
87
|
+
// reason to make the user wait through that just to see it. Returns null
|
|
88
|
+
// (nothing printed) for any command shape this doesn't recognize --
|
|
89
|
+
// never a guess, and never a substitute for the real extract result below.
|
|
90
|
+
const _PREVIEW_PATTERNS = [
|
|
91
|
+
{ runtime: 'vLLM', re: /\bvllm\s+serve\s+(\S+)/i },
|
|
92
|
+
{ runtime: 'Ollama', re: /\bollama\s+run\s+(\S+)/i },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function _previewLaunchCommand(raw) {
|
|
96
|
+
if (!raw || raw.includes('\n')) return null;
|
|
97
|
+
for (const { runtime, re } of _PREVIEW_PATTERNS) {
|
|
98
|
+
const m = re.exec(raw);
|
|
99
|
+
if (m) return { runtime, model: m[1] };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
function detectInput(raw, flags) {
|
|
81
105
|
if (flags.docker) {
|
|
82
106
|
return {
|
|
@@ -148,11 +172,30 @@ function detectInput(raw, flags) {
|
|
|
148
172
|
return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
|
|
149
173
|
}
|
|
150
174
|
|
|
175
|
+
if (classified.kind === 'command') {
|
|
176
|
+
return { kind: 'command', label: 'Explicit command', body: classified.body };
|
|
177
|
+
}
|
|
178
|
+
|
|
151
179
|
return { kind: 'text', label: 'Text / conversation', body: classified.body };
|
|
152
180
|
}
|
|
153
181
|
|
|
182
|
+
// backend/run_issue_routes.py's /extract now returns the canonical
|
|
183
|
+
// "service" (persistent, badgr serve) vs "finite_job" (bounded, badgr
|
|
184
|
+
// run/train/comfyui) classification directly on the extraction as
|
|
185
|
+
// `workload_shape` (_workload_shape, the same predicate
|
|
186
|
+
// _canonical_badgr_command itself uses to pick a subcommand) -- read that
|
|
187
|
+
// instead of re-deriving an approximation here, so the CLI's wording can
|
|
188
|
+
// never drift from what the backend actually decided. The local fallback
|
|
189
|
+
// only matters for a response predating this field (e.g. a cached fixture).
|
|
190
|
+
function _isServiceShape(e) {
|
|
191
|
+
if (e.workload_shape) return e.workload_shape === 'service';
|
|
192
|
+
const wt = e.workload_type;
|
|
193
|
+
return Boolean(e.models?.length) && wt !== 'training' && wt !== 'comfyui';
|
|
194
|
+
}
|
|
195
|
+
|
|
154
196
|
function _printResult(result, chalk) {
|
|
155
197
|
const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
|
|
198
|
+
const isService = _isServiceShape(e);
|
|
156
199
|
|
|
157
200
|
if (github_issue) {
|
|
158
201
|
console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
|
|
@@ -196,12 +239,19 @@ function _printResult(result, chalk) {
|
|
|
196
239
|
for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
|
|
197
240
|
} else if (wt) {
|
|
198
241
|
console.log();
|
|
199
|
-
console.log(` ${chalk.bold('Diagnosis:')}
|
|
242
|
+
console.log(` ${chalk.bold('Diagnosis:')} ${isService
|
|
243
|
+
? 'Persistent service detected — ready to serve'
|
|
244
|
+
: 'Workload detected — ready for capped test'}`);
|
|
200
245
|
}
|
|
201
246
|
|
|
202
247
|
if (multi_gpu_detected) {
|
|
203
|
-
console.log(` ${chalk.yellow('!')} Multi-GPU
|
|
204
|
-
|
|
248
|
+
console.log(` ${chalk.yellow('!')} Multi-GPU requested: ${multi_gpu_detected.gpu_count} GPUs ${chalk.dim(`(${multi_gpu_detected.evidence})`)}`);
|
|
249
|
+
// Never claim an automatic single-GPU downgrade here -- this topology
|
|
250
|
+
// is preserved if Badgr's runner supports it, or the paid CTA is
|
|
251
|
+
// blocked with an explicit unsupported-topology message once a plan
|
|
252
|
+
// exists (see /confirm's requires_reduced_test); a reduced test is
|
|
253
|
+
// only ever a distinct, explicit secondary choice, never silent.
|
|
254
|
+
console.log(` ${chalk.dim('Badgr will preserve this topology if supported, or report it as unsupported before any GPU runs.')}`);
|
|
205
255
|
}
|
|
206
256
|
if (large_download) {
|
|
207
257
|
console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
|
|
@@ -219,8 +269,23 @@ function _printResult(result, chalk) {
|
|
|
219
269
|
console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
|
|
220
270
|
} else if (e.missing_information?.length) {
|
|
221
271
|
console.log(` ${chalk.bold('Recommended action:')} Provide the missing details above, then re-run`);
|
|
272
|
+
} else if (isService) {
|
|
273
|
+
// A persistent service has no lifetime cost cap by default -- the free
|
|
274
|
+
// verification step below is still capped (it's just a readiness
|
|
275
|
+
// check), but the resulting `badgr serve` endpoint keeps running and
|
|
276
|
+
// billing until stopped, or an explicit --max-cost/--max-runtime is
|
|
277
|
+
// supplied. See backend/run_issue_routes.py's _canonical_badgr_command.
|
|
278
|
+
console.log(` ${chalk.bold('Recommended action:')} Open the Run link below to verify readiness and start the service`);
|
|
279
|
+
console.log(` ${chalk.dim('The service keeps running after verification -- stop it with `badgr down`, or launch it with --max-cost/--persistent.')}`);
|
|
222
280
|
} else {
|
|
223
|
-
|
|
281
|
+
// The prepared case URL (printed below by _printCopySummary as "Run:")
|
|
282
|
+
// is the primary handoff -- it's the same Page 2 the web flow uses, with
|
|
283
|
+
// the real free-check evidence, the dynamic recommended cap, and login/
|
|
284
|
+
// approval built in. `--approve` remains available for backward
|
|
285
|
+
// compatibility (a same-terminal, no-browser capped test) but is no
|
|
286
|
+
// longer the recommended path.
|
|
287
|
+
console.log(` ${chalk.bold('Recommended action:')} Open the Run link below to review and approve a capped test`);
|
|
288
|
+
console.log(` ${chalk.dim(`(or run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch one directly from this terminal)`)}`);
|
|
224
289
|
}
|
|
225
290
|
}
|
|
226
291
|
|
|
@@ -265,8 +330,11 @@ function _localPathCandidates(e) {
|
|
|
265
330
|
// Locked status ladder (product-level labels, not the backend's internal
|
|
266
331
|
// proof_level enum): NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
|
|
267
332
|
// Pure presentation over data /run-issue/extract already returns -- no new
|
|
268
|
-
// endpoint, no new GPU behaviour.
|
|
269
|
-
// (never VERIFIED, see AGENTS.md §12 and _printCopySummary
|
|
333
|
+
// endpoint, no new GPU behaviour. Diagnosis alone can reach SMOKE CHECKED
|
|
334
|
+
// or INVALID (never VERIFIED, see AGENTS.md §12 and _printCopySummary
|
|
335
|
+
// below) -- `badgr diagnose` IS the Badgr Smoke Test, so applicable free
|
|
336
|
+
// checks always run and always count toward the printed status; there is
|
|
337
|
+
// no separate opt-in step for them.
|
|
270
338
|
//
|
|
271
339
|
// The evidence-only judgment (needs_info/invalid/smoke_checked) is computed
|
|
272
340
|
// once, server-side, in `_compute_smoke_status` (backend/run_issue_routes.py)
|
|
@@ -277,39 +345,50 @@ function _localPathCandidates(e) {
|
|
|
277
345
|
// A `smoke_status` fallback derivation is kept only for a backend response
|
|
278
346
|
// that predates this field (defensive, not the normal path).
|
|
279
347
|
//
|
|
280
|
-
// READY means "no
|
|
281
|
-
// itself proof of validity, so mechanical/local-file
|
|
282
|
-
// change the printed word when
|
|
283
|
-
// problem #6: a real FAIL must demote SMOKE CHECKED to
|
|
284
|
-
// checks never do).
|
|
285
|
-
//
|
|
286
|
-
// see.
|
|
287
|
-
function _statusWord(result
|
|
348
|
+
// READY means "no applicable checks to run" -- a command that's merely
|
|
349
|
+
// complete is never itself proof of validity, so mechanical/local-file
|
|
350
|
+
// results only ever change the printed word when a check actually ran
|
|
351
|
+
// (locked spec problem #6: a real FAIL must demote SMOKE CHECKED to
|
|
352
|
+
// INVALID; SKIPPED checks never do). The local-file half of this stays
|
|
353
|
+
// CLI-side since it depends on this machine's own filesystem, which the
|
|
354
|
+
// backend can never see.
|
|
355
|
+
function _statusWord(result) {
|
|
288
356
|
const e = result.extraction || {};
|
|
289
357
|
if (result.static_incompatibilities?.length || e.missing_information?.length) return 'NEEDS INFO';
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
358
|
+
// Mirrors backend/run_issue_routes.py's _compute_smoke_status exactly: a
|
|
359
|
+
// mechanical FAIL is invalid; otherwise only real command-scope evidence
|
|
360
|
+
// (smoke_check.proof_level === "smoke_checked" -- the repository/image/
|
|
361
|
+
// service's own documented default, not just a syntax-only pass) earns
|
|
362
|
+
// SMOKE CHECKED. Everything else is READY, even if mechanical checks ran
|
|
363
|
+
// and all passed -- a syntax-only pass proves the command is well formed,
|
|
364
|
+
// not that its runtime is available.
|
|
365
|
+
const backendStatus = e.smoke_status || (
|
|
366
|
+
(e.mechanical_checks || []).some((check) => check.status === 'fail')
|
|
367
|
+
? 'invalid'
|
|
368
|
+
: (e.smoke_check || {}).proof_level === 'smoke_checked' ? 'smoke_checked' : 'ready'
|
|
369
|
+
);
|
|
293
370
|
const localFail = _localPathCandidates(e).some((p) => !fs.existsSync(p));
|
|
294
371
|
if (backendStatus === 'invalid' || localFail) return 'INVALID';
|
|
295
372
|
return backendStatus === 'ready' ? 'READY' : 'SMOKE CHECKED';
|
|
296
373
|
}
|
|
297
374
|
|
|
298
375
|
const _CHECK_LABELS = {
|
|
299
|
-
command_syntax:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
376
|
+
command_syntax: 'command syntax',
|
|
377
|
+
supported_flags: 'known CLI flags',
|
|
378
|
+
cli_entrypoint: 'CLI entrypoint',
|
|
379
|
+
referenced_files: 'referenced repo files',
|
|
380
|
+
image_availability: 'image exists / accessible',
|
|
381
|
+
docker_entrypoint: 'Docker metadata',
|
|
382
|
+
required_env_vars: 'required env vars',
|
|
304
383
|
};
|
|
305
384
|
|
|
306
385
|
// Compact, copy-pasteable result -- the literal STATUS word plus just enough
|
|
307
386
|
// to forward to someone else. Additive to the detailed report above it;
|
|
308
387
|
// does not replace it. Never prints "Verified" -- SMOKE CHECKED is the
|
|
309
388
|
// ceiling for this free path.
|
|
310
|
-
function _printCopySummary(result,
|
|
389
|
+
function _printCopySummary(result, chalk, caseData, config) {
|
|
311
390
|
const e = result.extraction || {};
|
|
312
|
-
const status = _statusWord(result
|
|
391
|
+
const status = _statusWord(result);
|
|
313
392
|
|
|
314
393
|
console.log(` ${chalk.bold(`STATUS: ${status}`)}`);
|
|
315
394
|
console.log();
|
|
@@ -341,7 +420,7 @@ function _printCopySummary(result, flags, chalk, caseData) {
|
|
|
341
420
|
|
|
342
421
|
if (caseData?.case_id) {
|
|
343
422
|
console.log(` ${chalk.bold('Run:')}`);
|
|
344
|
-
console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id))}`);
|
|
423
|
+
console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id, config))}`);
|
|
345
424
|
console.log();
|
|
346
425
|
}
|
|
347
426
|
|
|
@@ -380,7 +459,13 @@ function _printSmokeChecks(result, chalk) {
|
|
|
380
459
|
const localPaths = _localPathCandidates(e);
|
|
381
460
|
|
|
382
461
|
console.log();
|
|
383
|
-
|
|
462
|
+
// Neutral header -- this section always runs and prints regardless of the
|
|
463
|
+
// final verdict (_statusWord can land on READY, NEEDS INFO, or INVALID
|
|
464
|
+
// just as easily as SMOKE CHECKED; see its own comment for why a
|
|
465
|
+
// syntax-only pass isn't proof of scope). A fixed "Smoke Checked" header
|
|
466
|
+
// here previously disagreed with a `STATUS: READY` line printed right
|
|
467
|
+
// after it -- this section is the free checks themselves, not the verdict.
|
|
468
|
+
console.log(` ${chalk.bold('Free checks (no GPU, no login):')}`);
|
|
384
469
|
|
|
385
470
|
const icon = (status) => (
|
|
386
471
|
status === 'pass' ? chalk.green('✓') : status === 'fail' ? chalk.red('✗') : chalk.dim('-')
|
|
@@ -416,11 +501,46 @@ function _confirmBodyFromExtraction(e) {
|
|
|
416
501
|
? e.gpu_requirements.join(', ')
|
|
417
502
|
: e.gpu_requirements || null,
|
|
418
503
|
source_summary: e.summary || null,
|
|
504
|
+
// Same free-check evidence /extract already computed for this session --
|
|
505
|
+
// /confirm only persists it (see ConfirmBody's docstring), it never
|
|
506
|
+
// re-derives it. Without these, a case created via `badgr diagnose`
|
|
507
|
+
// (as opposed to the web page's own /confirm call, which already sends
|
|
508
|
+
// them) persisted no evidence at all, so its Run Link's Page 2 showed
|
|
509
|
+
// no "Free Smoke Test" section on a cold load -- not because the
|
|
510
|
+
// section is missing, but because there was nothing in it to render.
|
|
511
|
+
mechanical_checks: e.mechanical_checks || null,
|
|
512
|
+
smoke_check: e.smoke_check || null,
|
|
513
|
+
resolution_evidence: e.resolved_workload || null,
|
|
419
514
|
};
|
|
420
515
|
}
|
|
421
516
|
|
|
422
|
-
function _runPageUrl(caseId) {
|
|
423
|
-
return
|
|
517
|
+
function _runPageUrl(caseId, config) {
|
|
518
|
+
return `${webBaseUrl(config)}/run-issue?case_id=${caseId}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// /run-issue/extract can legitimately take up to 120s (AI extraction) and
|
|
522
|
+
// /run-issue/confirm up to 20s -- both used to print one static line
|
|
523
|
+
// ("Analysing..." / nothing at all) and then go silent until the request
|
|
524
|
+
// resolved, which is indistinguishable from a hung terminal on a slow
|
|
525
|
+
// backend. This redraws a single in-place elapsed-time line every second
|
|
526
|
+
// so a slow response still reads as "working", not "stuck". Only runs when
|
|
527
|
+
// stdout is a TTY -- a piped/non-interactive run (e.g. --json in a script)
|
|
528
|
+
// must not get raw carriage-return control codes mixed into its output.
|
|
529
|
+
async function _withElapsed(chalk, label, promise) {
|
|
530
|
+
if (!process.stdout.isTTY) return promise;
|
|
531
|
+
const startMs = Date.now();
|
|
532
|
+
const tick = () => {
|
|
533
|
+
const elapsedSec = Math.floor((Date.now() - startMs) / 1000);
|
|
534
|
+
process.stdout.write(`\r\x1b[2K${chalk.dim(` ${label} ${elapsedSec}s`)}`);
|
|
535
|
+
};
|
|
536
|
+
tick();
|
|
537
|
+
const timer = setInterval(tick, 1000);
|
|
538
|
+
try {
|
|
539
|
+
return await promise;
|
|
540
|
+
} finally {
|
|
541
|
+
clearInterval(timer);
|
|
542
|
+
process.stdout.write('\r\x1b[2K');
|
|
543
|
+
}
|
|
424
544
|
}
|
|
425
545
|
|
|
426
546
|
// Creates the free, anonymous "prepared run" case that backs a shareable
|
|
@@ -450,7 +570,7 @@ async function _createRunPage(config, e, chalk) {
|
|
|
450
570
|
}
|
|
451
571
|
|
|
452
572
|
async function _doApprove(config, caseData, chalk) {
|
|
453
|
-
const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
|
|
573
|
+
const { case_id: caseId, status, test_plan: plan, workload_plan: workloadPlan, missing_information: missing } = caseData;
|
|
454
574
|
|
|
455
575
|
if (status === 'incompatible') {
|
|
456
576
|
console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
|
|
@@ -474,13 +594,19 @@ async function _doApprove(config, caseData, chalk) {
|
|
|
474
594
|
return;
|
|
475
595
|
}
|
|
476
596
|
|
|
477
|
-
|
|
597
|
+
// Display the canonical WorkloadPlan (backend/reliability_engine.py) --
|
|
598
|
+
// the same object creation -> routing -> Jobs -> watchdog all consume --
|
|
599
|
+
// rather than a second, independently-derived estimate. Falls back to the
|
|
600
|
+
// raw test_plan only for fields the canonical plan doesn't carry
|
|
601
|
+
// (requires_reduced_test / requires_funding_approval are smoke-check-only
|
|
602
|
+
// concerns, never part of WorkloadPlan).
|
|
603
|
+
if (workloadPlan || plan) {
|
|
478
604
|
console.log();
|
|
479
|
-
if (
|
|
480
|
-
if (
|
|
481
|
-
if (
|
|
482
|
-
if (plan
|
|
483
|
-
if (plan
|
|
605
|
+
if (workloadPlan?.gpu_type) console.log(` ${chalk.bold('GPU:')} ${workloadPlan.gpu_type}`);
|
|
606
|
+
if (workloadPlan?.estimated_cost_usd != null) console.log(` ${chalk.bold('Maximum cost:')} $${Number(workloadPlan.estimated_cost_usd).toFixed(2)}`);
|
|
607
|
+
if (workloadPlan?.deadline_secs) console.log(` ${chalk.bold('Max runtime:')} ${Math.round(workloadPlan.deadline_secs / 60)} min`);
|
|
608
|
+
if (plan?.requires_reduced_test) console.log(` ${chalk.red('!')} Requested topology not supported -- run blocked, see below`);
|
|
609
|
+
if (plan?.requires_funding_approval) console.log(` ${chalk.yellow('!')} Large download — funding required`);
|
|
484
610
|
}
|
|
485
611
|
|
|
486
612
|
await _approveAndRun(config, caseId, plan, chalk);
|
|
@@ -494,8 +620,33 @@ async function _doApprove(config, caseData, chalk) {
|
|
|
494
620
|
// it only redeems one a case already carries, same as normal billing.
|
|
495
621
|
async function _approveAndRun(config, caseId, plan, chalk) {
|
|
496
622
|
const printResumeHint = () =>
|
|
497
|
-
console.error(chalk.dim(` Resume this case:
|
|
623
|
+
console.error(chalk.dim(` Resume this case: ${webBaseUrl(config)}/run-issue?case_id=${caseId}\n`));
|
|
624
|
+
|
|
625
|
+
// A requested topology Badgr's current runner can't satisfy (e.g.
|
|
626
|
+
// --tensor-parallel-size 2 on a single-GPU-only runner) must never be
|
|
627
|
+
// silently downgraded to a reduced single-GPU test -- that used to
|
|
628
|
+
// happen here automatically (reduced_test_confirmed defaulted to
|
|
629
|
+
// Boolean(plan.requires_reduced_test), so plain --approve auto-consented
|
|
630
|
+
// on the caller's behalf). A reduced test is only ever a distinct,
|
|
631
|
+
// explicit secondary choice -- the web /run-issue page already offers
|
|
632
|
+
// that as its own "Run reduced 1-GPU acceptance test instead" action;
|
|
633
|
+
// the CLI has no equivalent explicit opt-in yet, so it blocks here
|
|
634
|
+
// rather than inventing implicit consent.
|
|
635
|
+
if (plan?.requires_reduced_test) {
|
|
636
|
+
const detected = plan.multi_gpu_detected;
|
|
637
|
+
console.error(chalk.red('\n ✗ Cannot run this workload as requested\n'));
|
|
638
|
+
if (detected?.gpu_count) {
|
|
639
|
+
console.error(` This command requires ${detected.gpu_count} GPUs. Badgr's current verification runner supports only 1 GPU.\n`);
|
|
640
|
+
}
|
|
641
|
+
console.error(chalk.dim(` A reduced 1-GPU test is available as an explicit separate choice on the case page, not via --approve:`));
|
|
642
|
+
console.error(chalk.dim(` ${webBaseUrl(config)}/run-issue?case_id=${caseId}\n`));
|
|
643
|
+
process.exitCode = 1;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
498
646
|
|
|
647
|
+
// Same just-in-time login flow badgr run/serve/comfyui/job trigger — no
|
|
648
|
+
// separate `badgr login` step, and once the browser login completes this
|
|
649
|
+
// function keeps running right here with the now-populated config.
|
|
499
650
|
let authConfig = config;
|
|
500
651
|
if (!authConfig.apiKey) {
|
|
501
652
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -530,13 +681,20 @@ async function _approveAndRun(config, caseId, plan, chalk) {
|
|
|
530
681
|
body: {
|
|
531
682
|
confirmed: true,
|
|
532
683
|
funding_approved: Boolean(plan?.requires_funding_approval),
|
|
533
|
-
|
|
684
|
+
// Always false: the guard above already returned before this point
|
|
685
|
+
// for any plan with requires_reduced_test set -- never re-derive
|
|
686
|
+
// implicit consent from the plan here.
|
|
687
|
+
reduced_test_confirmed: false,
|
|
534
688
|
},
|
|
535
689
|
timeoutMs: 15_000,
|
|
536
690
|
});
|
|
537
691
|
} catch (err) {
|
|
538
|
-
|
|
539
|
-
|
|
692
|
+
// Same isPaymentRequired convention every other paid command follows
|
|
693
|
+
// (run.js, serve.js, train.js, ...) -- api.js already builds the full
|
|
694
|
+
// balance/required/top-up message onto err.message for a 402.
|
|
695
|
+
if (err.isPaymentRequired) {
|
|
696
|
+
console.error(chalk.yellow(err.message));
|
|
697
|
+
printResumeHint();
|
|
540
698
|
} else {
|
|
541
699
|
console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
|
|
542
700
|
}
|
|
@@ -544,24 +702,29 @@ async function _approveAndRun(config, caseId, plan, chalk) {
|
|
|
544
702
|
return;
|
|
545
703
|
}
|
|
546
704
|
|
|
547
|
-
console.log(chalk.dim('\n Launching
|
|
705
|
+
console.log(chalk.dim('\n Launching Badgr Run...'));
|
|
548
706
|
let ran;
|
|
549
707
|
try {
|
|
550
708
|
ran = await callApi(`/run-issue/cases/${caseId}/run`, {
|
|
551
709
|
method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
|
|
552
710
|
});
|
|
553
711
|
} catch (err) {
|
|
554
|
-
|
|
712
|
+
if (err.isPaymentRequired) {
|
|
713
|
+
console.error(chalk.yellow(err.message));
|
|
714
|
+
printResumeHint();
|
|
715
|
+
} else {
|
|
716
|
+
console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
|
|
717
|
+
}
|
|
555
718
|
process.exitCode = 1;
|
|
556
719
|
return;
|
|
557
720
|
}
|
|
558
721
|
|
|
559
722
|
console.log();
|
|
560
|
-
console.log(chalk.green('
|
|
723
|
+
console.log(chalk.green(' Run started.'));
|
|
561
724
|
console.log(` ${chalk.bold('Case:')} ${caseId}`);
|
|
562
725
|
if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
|
|
563
726
|
if (ran?.free_run_used) console.log(` ${chalk.bold('Billing:')} Free run — no charge`);
|
|
564
|
-
console.log(` ${chalk.bold('Evidence:')}
|
|
727
|
+
console.log(` ${chalk.bold('Evidence:')} ${webBaseUrl(config)}/repro/${caseId}`);
|
|
565
728
|
console.log();
|
|
566
729
|
}
|
|
567
730
|
|
|
@@ -618,8 +781,8 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
|
|
|
618
781
|
? caseData.lead_summary.test_checklist
|
|
619
782
|
: ['Verified'];
|
|
620
783
|
for (const line of resultLines) console.log(` - ${line}`);
|
|
621
|
-
if (caseData.
|
|
622
|
-
console.log(` Verified on Badgr for
|
|
784
|
+
if (caseData.actual_cost_usd != null) {
|
|
785
|
+
console.log(` Verified on Badgr for $${Number(caseData.actual_cost_usd).toFixed(2)} USD. GPU torn down after the test.`);
|
|
623
786
|
}
|
|
624
787
|
}
|
|
625
788
|
|
|
@@ -628,8 +791,9 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
|
|
|
628
791
|
console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
|
|
629
792
|
} else if (caseData.free_verification_available) {
|
|
630
793
|
console.log(` ${chalk.green(`1 free verification job available — up to $${(caseData.free_verification_max_cost_usd ?? 5).toFixed(0)}`)}`);
|
|
631
|
-
} else if (caseData.test_plan) {
|
|
632
|
-
|
|
794
|
+
} else if (caseData.workload_plan || caseData.test_plan) {
|
|
795
|
+
const capUsd = caseData.workload_plan?.recommended_cap_usd ?? caseData.test_plan?.max_cost_usd ?? 0;
|
|
796
|
+
console.log(` ${chalk.dim(`Verification available — max $${Number(capUsd).toFixed(2)}`)}`);
|
|
633
797
|
}
|
|
634
798
|
console.log();
|
|
635
799
|
|
|
@@ -648,7 +812,15 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
648
812
|
}
|
|
649
813
|
|
|
650
814
|
const flags = {};
|
|
651
|
-
|
|
815
|
+
// Every non-flag token is joined into one input string, not just the
|
|
816
|
+
// first -- `badgr diagnose vllm serve facebook/opt-125m` (unquoted) must
|
|
817
|
+
// diagnose the same thing as `badgr diagnose "vllm serve
|
|
818
|
+
// facebook/opt-125m"` (quoted). Previously only args[0] after flags
|
|
819
|
+
// ("vllm") became `positional` and every token after it was silently
|
|
820
|
+
// dropped, so an unquoted multi-word command was diagnosed as if the
|
|
821
|
+
// user had only pasted its first word -- bizarrely resolving to a
|
|
822
|
+
// Docker-image lookup for an image literally named "vllm".
|
|
823
|
+
const positionalParts = [];
|
|
652
824
|
let i = 0;
|
|
653
825
|
while (i < args.length) {
|
|
654
826
|
const a = args[i];
|
|
@@ -659,9 +831,10 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
659
831
|
if (a === '--approve') { flags.approve = true; i++; continue; }
|
|
660
832
|
if (a === '--smoke') { flags.smoke = true; i++; continue; }
|
|
661
833
|
if (a === '--json') { flags.json = true; i++; continue; }
|
|
662
|
-
if (!a.startsWith('-')
|
|
834
|
+
if (!a.startsWith('-')) positionalParts.push(a);
|
|
663
835
|
i++;
|
|
664
836
|
}
|
|
837
|
+
const positional = positionalParts.length ? positionalParts.join(' ') : null;
|
|
665
838
|
|
|
666
839
|
if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
|
|
667
840
|
console.log(DIAGNOSE_HELP);
|
|
@@ -694,25 +867,34 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
694
867
|
if (!flags.json) {
|
|
695
868
|
console.log();
|
|
696
869
|
console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
|
|
697
|
-
|
|
870
|
+
if (detected.kind === 'command') {
|
|
871
|
+
const preview = _previewLaunchCommand(positional);
|
|
872
|
+
if (preview) {
|
|
873
|
+
console.log(` ${chalk.bold('Runtime:')} ${preview.runtime}`);
|
|
874
|
+
console.log(` ${chalk.bold('Model:')} ${preview.model}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
698
877
|
}
|
|
699
878
|
|
|
700
879
|
let result;
|
|
701
880
|
try {
|
|
702
|
-
|
|
881
|
+
const extractPromise = callApi('/run-issue/extract', {
|
|
703
882
|
method: 'POST',
|
|
704
883
|
apiKey: config.apiKey || '',
|
|
705
884
|
baseUrl: config.baseUrl,
|
|
706
885
|
body: detected.body,
|
|
707
886
|
timeoutMs: 120_000,
|
|
708
887
|
});
|
|
888
|
+
result = flags.json
|
|
889
|
+
? await extractPromise
|
|
890
|
+
: await _withElapsed(chalk, 'Running free checks...', extractPromise);
|
|
709
891
|
} catch (err) {
|
|
710
892
|
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
711
893
|
process.exitCode = 1;
|
|
712
894
|
return;
|
|
713
895
|
}
|
|
714
896
|
|
|
715
|
-
const status = _statusWord(result
|
|
897
|
+
const status = _statusWord(result);
|
|
716
898
|
|
|
717
899
|
// A command that's actually runnable also gets a free prepared Run
|
|
718
900
|
// page -- the same case /run-issue's own confirm step would create --
|
|
@@ -725,14 +907,20 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
725
907
|
let caseData = null;
|
|
726
908
|
let caseError = null;
|
|
727
909
|
if (status === 'READY' || status === 'SMOKE CHECKED') {
|
|
728
|
-
const
|
|
910
|
+
const createPromise = _createRunPage(config, result.extraction || {}, chalk);
|
|
911
|
+
const created = flags.json
|
|
912
|
+
? await createPromise
|
|
913
|
+
: await _withElapsed(chalk, 'Preparing Run link...', createPromise);
|
|
729
914
|
if (created.error) caseError = created.error;
|
|
730
915
|
else caseData = created.caseData;
|
|
731
916
|
}
|
|
732
917
|
|
|
733
918
|
if (flags.json) {
|
|
734
919
|
const output = { detected: detected.kind, ...result, status };
|
|
735
|
-
|
|
920
|
+
// Applicable free checks always run as part of plain diagnosis now
|
|
921
|
+
// (see _statusWord's comment) -- always included here too, not gated
|
|
922
|
+
// behind the deprecated --smoke flag.
|
|
923
|
+
{
|
|
736
924
|
const e = result.extraction || {};
|
|
737
925
|
output.smoke = {
|
|
738
926
|
mechanical_checks: e.mechanical_checks || [],
|
|
@@ -742,7 +930,7 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
742
930
|
if (caseData?.case_id) {
|
|
743
931
|
output.run = {
|
|
744
932
|
case_id: caseData.case_id,
|
|
745
|
-
url: _runPageUrl(caseData.case_id),
|
|
933
|
+
url: _runPageUrl(caseData.case_id, config),
|
|
746
934
|
canonical_command: caseData.findings?.canonical_command || null,
|
|
747
935
|
};
|
|
748
936
|
}
|
|
@@ -772,15 +960,16 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
772
960
|
console.log();
|
|
773
961
|
_printResult(result, chalk);
|
|
774
962
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
963
|
+
// Applicable free checks always run as part of plain diagnosis now
|
|
964
|
+
// (see _statusWord's comment) -- always printed, not gated behind the
|
|
965
|
+
// deprecated --smoke flag.
|
|
966
|
+
_printSmokeChecks(result, chalk);
|
|
778
967
|
|
|
779
968
|
console.log();
|
|
780
|
-
_printCopySummary(result,
|
|
969
|
+
_printCopySummary(result, chalk, caseData, config);
|
|
781
970
|
|
|
782
971
|
console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
|
|
783
|
-
console.log(chalk.dim(
|
|
972
|
+
console.log(chalk.dim(` Full interactive flow: ${webBaseUrl(config)}/run-issue`));
|
|
784
973
|
console.log();
|
|
785
974
|
|
|
786
975
|
if (flags.approve) {
|