badgr-cli 1.1.3 → 1.1.5
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 +176 -13
- package/package.json +2 -1
- package/src/api.js +28 -0
- package/src/badgr.js +3 -3
- 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 +576 -82
- 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 +95 -18
- 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 +12 -3
- package/src/fallback.js +55 -32
- package/src/onboarding.js +63 -4
- package/src/progress.js +5 -2
package/src/commands/diagnose.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import { classifyPastedInput } from 'badgr-shared';
|
|
2
3
|
import { callApi } from '../api.js';
|
|
3
4
|
import { ensureLoggedIn } from '../onboarding.js';
|
|
5
|
+
import { webBaseUrl } from '../config.js';
|
|
4
6
|
|
|
5
7
|
const DIAGNOSE_HELP = `
|
|
6
|
-
Paste anything.
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
9
12
|
|
|
10
13
|
Usage:
|
|
11
14
|
badgr diagnose "<input>"
|
|
@@ -24,8 +27,14 @@ Input (auto-detected):
|
|
|
24
27
|
Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
|
|
25
28
|
|
|
26
29
|
Flags:
|
|
27
|
-
--approve
|
|
28
|
-
browser sign-in automatically if not logged in)
|
|
30
|
+
--approve Launch a real Badgr Run after diagnosis (opens
|
|
31
|
+
browser sign-in automatically if not logged in).
|
|
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.
|
|
29
38
|
--docker <image> Force Docker-image intake (override auto-detect)
|
|
30
39
|
--repo <url> Force repository intake (override auto-detect)
|
|
31
40
|
--comfyui <path> Force ComfyUI workflow intake (override auto-detect)
|
|
@@ -35,11 +44,63 @@ Flags:
|
|
|
35
44
|
--json Machine-readable JSON output
|
|
36
45
|
--help, -h Show this help
|
|
37
46
|
|
|
47
|
+
Status: every run prints one of NEEDS INFO / READY / SMOKE CHECKED / INVALID / 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).
|
|
60
|
+
|
|
61
|
+
Run page: READY and SMOKE CHECKED results also create a free, anonymous
|
|
62
|
+
"Run" link (the same prepared case /run-issue's own confirm form
|
|
63
|
+
creates) -- a saved, runnable configuration you or anyone with the
|
|
64
|
+
link can open and click Run on. NEEDS INFO and INVALID never get
|
|
65
|
+
one (nothing runnable to save, or a check just proved it broken)
|
|
66
|
+
-- both print the correction instead. The page shows the exact
|
|
67
|
+
canonical "badgr ..." command --approve would run, not merely the
|
|
68
|
+
source command extraction found. Still no GPU starts until that
|
|
69
|
+
click. --approve reuses this same case rather than creating a
|
|
70
|
+
second one.
|
|
71
|
+
|
|
38
72
|
Safety: nothing runs from AI-extracted data without --approve.
|
|
39
73
|
No GPU launches without explicit approval and a credit check.
|
|
74
|
+
Diagnosis (with or without its automatic checks) never provisions
|
|
75
|
+
a GPU and never requires login.
|
|
40
76
|
|
|
41
77
|
Full interactive flow: https://aibadgr.com/run-issue`;
|
|
42
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
|
+
|
|
43
104
|
function detectInput(raw, flags) {
|
|
44
105
|
if (flags.docker) {
|
|
45
106
|
return {
|
|
@@ -66,32 +127,27 @@ function detectInput(raw, flags) {
|
|
|
66
127
|
|
|
67
128
|
if (!raw) return null;
|
|
68
129
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
130
|
+
// Case-id / GitHub-URL-shape classification is shared with the web page
|
|
131
|
+
// (`classifyPastedInput`, packages/shared/src/index.ts) so the CLI and
|
|
132
|
+
// /run-issue never disagree about what a given paste is. A case_id (bare,
|
|
133
|
+
// or embedded in a /repro/<id> or /run-issue?case_id=<id> link — the two
|
|
134
|
+
// shapes an admin-prepared or self-created case gets shared as) resumes
|
|
135
|
+
// that existing case instead of diagnosing new input.
|
|
136
|
+
const classified = classifyPastedInput(raw);
|
|
137
|
+
if (classified.kind === 'existing_case') {
|
|
138
|
+
return { kind: 'existing_case', label: `Existing case: ${classified.body.case_id}`, caseId: classified.body.case_id };
|
|
77
139
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
kind: 'github_issue',
|
|
82
|
-
label: `GitHub issue: ${raw}`,
|
|
83
|
-
body: { github_url: raw },
|
|
84
|
-
};
|
|
140
|
+
if (classified.kind === 'github_issue') {
|
|
141
|
+
return { kind: 'github_issue', label: `GitHub issue: ${raw}`, body: classified.body };
|
|
85
142
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
kind: 'repo_url',
|
|
90
|
-
label: `GitHub repository: ${raw}`,
|
|
91
|
-
body: { repo_url: raw },
|
|
92
|
-
};
|
|
143
|
+
if (classified.kind === 'repo_url') {
|
|
144
|
+
return { kind: 'repo_url', label: `GitHub repository: ${raw}`, body: classified.body };
|
|
93
145
|
}
|
|
94
146
|
|
|
147
|
+
// Local filesystem paths -- only the CLI has a filesystem to check
|
|
148
|
+
// against, so this stays CLI-only, sitting between the shared URL checks
|
|
149
|
+
// above and the shared Docker-image/text fallback below (matches
|
|
150
|
+
// classifyPastedInput's own precedence for everything except this).
|
|
95
151
|
if (fs.existsSync(raw)) {
|
|
96
152
|
const content = fs.readFileSync(raw, 'utf8');
|
|
97
153
|
if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
|
|
@@ -112,27 +168,34 @@ function detectInput(raw, flags) {
|
|
|
112
168
|
};
|
|
113
169
|
}
|
|
114
170
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
/^[a-z0-9][a-z0-9._\-]*(?:\/[a-z0-9._\-]+)*(?::[a-zA-Z0-9._\-]+)?$/.test(raw) &&
|
|
118
|
-
raw.length < 200
|
|
119
|
-
) {
|
|
120
|
-
return {
|
|
121
|
-
kind: 'docker_image',
|
|
122
|
-
label: `Docker image: ${raw}`,
|
|
123
|
-
body: { docker_image: raw },
|
|
124
|
-
};
|
|
171
|
+
if (classified.kind === 'docker_image') {
|
|
172
|
+
return { kind: 'docker_image', label: `Docker image: ${raw}`, body: classified.body };
|
|
125
173
|
}
|
|
126
174
|
|
|
127
|
-
|
|
128
|
-
kind:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
};
|
|
175
|
+
if (classified.kind === 'command') {
|
|
176
|
+
return { kind: 'command', label: 'Explicit command', body: classified.body };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { kind: 'text', label: 'Text / conversation', body: classified.body };
|
|
180
|
+
}
|
|
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';
|
|
132
194
|
}
|
|
133
195
|
|
|
134
196
|
function _printResult(result, chalk) {
|
|
135
197
|
const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
|
|
198
|
+
const isService = _isServiceShape(e);
|
|
136
199
|
|
|
137
200
|
if (github_issue) {
|
|
138
201
|
console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
|
|
@@ -176,12 +239,19 @@ function _printResult(result, chalk) {
|
|
|
176
239
|
for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
|
|
177
240
|
} else if (wt) {
|
|
178
241
|
console.log();
|
|
179
|
-
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'}`);
|
|
180
245
|
}
|
|
181
246
|
|
|
182
247
|
if (multi_gpu_detected) {
|
|
183
|
-
console.log(` ${chalk.yellow('!')} Multi-GPU
|
|
184
|
-
|
|
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.')}`);
|
|
185
255
|
}
|
|
186
256
|
if (large_download) {
|
|
187
257
|
console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
|
|
@@ -199,16 +269,227 @@ function _printResult(result, chalk) {
|
|
|
199
269
|
console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
|
|
200
270
|
} else if (e.missing_information?.length) {
|
|
201
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.')}`);
|
|
202
280
|
} else {
|
|
203
|
-
|
|
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)`)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Local-machine file paths (e.g. a pasted .gguf path) can only be checked
|
|
293
|
+
// client-side -- the backend never sees this machine's filesystem, so it
|
|
294
|
+
// must not (and does not) claim to validate them. Only paths that look like
|
|
295
|
+
// a real filesystem reference (absolute, `./`, `../`, or `~/`) are checked;
|
|
296
|
+
// bare words, URLs, and Docker image references are left alone.
|
|
297
|
+
//
|
|
298
|
+
// Path checks must respect context (locked Free Command Check spec, problem
|
|
299
|
+
// #3): a repo path is checked against the repo tree, a container path
|
|
300
|
+
// against Dockerfile/image evidence -- both server-side, inside
|
|
301
|
+
// `mechanical_checks` -- and only a genuinely local path belongs to this
|
|
302
|
+
// machine. Once a repository or Docker image is in evidence for this
|
|
303
|
+
// workload, an absolute path *inside the resolved command* (e.g. "python3
|
|
304
|
+
// /src/main.py") is a repo/container path, not a claim about this laptop,
|
|
305
|
+
// so it must never be re-checked against the local filesystem here -- doing
|
|
306
|
+
// so is exactly the "/src/main.py NOT FOUND on this machine" false failure
|
|
307
|
+
// the spec calls out. `model_artifact` is different: it is always this
|
|
308
|
+
// machine's own claim about a locally-downloaded checkpoint, never repo or
|
|
309
|
+
// container evidence, so it stays checked regardless.
|
|
310
|
+
function _localPathCandidates(e) {
|
|
311
|
+
const candidates = new Set();
|
|
312
|
+
const hasRepoOrContainerContext = Boolean(
|
|
313
|
+
e.repositories?.length || e.docker_images?.length || e.dockerfile
|
|
314
|
+
);
|
|
315
|
+
const maybeAdd = (value) => {
|
|
316
|
+
if (typeof value !== 'string') return;
|
|
317
|
+
const s = value.trim().replace(/^['"]|['"]$/g, '');
|
|
318
|
+
if (!s || /^https?:\/\//i.test(s)) return;
|
|
319
|
+
if (s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.startsWith('~/')) {
|
|
320
|
+
candidates.add(s);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
maybeAdd(e.model_artifact);
|
|
324
|
+
if (!hasRepoOrContainerContext) {
|
|
325
|
+
for (const tok of (e.commands?.[0] || '').split(/\s+/)) maybeAdd(tok);
|
|
326
|
+
}
|
|
327
|
+
return [...candidates];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Locked status ladder (product-level labels, not the backend's internal
|
|
331
|
+
// proof_level enum): NEEDS INFO / READY / SMOKE CHECKED / INVALID / VERIFIED.
|
|
332
|
+
// Pure presentation over data /run-issue/extract already returns -- no new
|
|
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.
|
|
338
|
+
//
|
|
339
|
+
// The evidence-only judgment (needs_info/invalid/smoke_checked) is computed
|
|
340
|
+
// once, server-side, in `_compute_smoke_status` (backend/run_issue_routes.py)
|
|
341
|
+
// and read here via `e.smoke_status` -- this and /run-issue's `smokeStatus()`
|
|
342
|
+
// (frontend/app/run-issue/page.tsx) both just render that one backend
|
|
343
|
+
// verdict rather than each re-deriving pass/fail from mechanical_checks, so
|
|
344
|
+
// the CLI and the web page can never disagree on what a given result means.
|
|
345
|
+
// A `smoke_status` fallback derivation is kept only for a backend response
|
|
346
|
+
// that predates this field (defensive, not the normal path).
|
|
347
|
+
//
|
|
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) {
|
|
356
|
+
const e = result.extraction || {};
|
|
357
|
+
if (result.static_incompatibilities?.length || e.missing_information?.length) return 'NEEDS INFO';
|
|
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
|
+
);
|
|
370
|
+
const localFail = _localPathCandidates(e).some((p) => !fs.existsSync(p));
|
|
371
|
+
if (backendStatus === 'invalid' || localFail) return 'INVALID';
|
|
372
|
+
return backendStatus === 'ready' ? 'READY' : 'SMOKE CHECKED';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const _CHECK_LABELS = {
|
|
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',
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Compact, copy-pasteable result -- the literal STATUS word plus just enough
|
|
386
|
+
// to forward to someone else. Additive to the detailed report above it;
|
|
387
|
+
// does not replace it. Never prints "Verified" -- SMOKE CHECKED is the
|
|
388
|
+
// ceiling for this free path.
|
|
389
|
+
function _printCopySummary(result, chalk, caseData, config) {
|
|
390
|
+
const e = result.extraction || {};
|
|
391
|
+
const status = _statusWord(result);
|
|
392
|
+
|
|
393
|
+
console.log(` ${chalk.bold(`STATUS: ${status}`)}`);
|
|
394
|
+
console.log();
|
|
395
|
+
|
|
396
|
+
if (status === 'NEEDS INFO') {
|
|
397
|
+
// Static incompatibilities and missing-information both mean "no
|
|
398
|
+
// command yet" -- whichever fired is the reason to surface here.
|
|
399
|
+
const reasons = result.static_incompatibilities?.length
|
|
400
|
+
? result.static_incompatibilities
|
|
401
|
+
: (e.missing_information || []);
|
|
402
|
+
console.log(` ${chalk.bold('Missing:')}`);
|
|
403
|
+
for (const r of reasons) console.log(` - ${r}`);
|
|
404
|
+
console.log();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The prepared case's canonical `badgr ...` command (the exact line
|
|
409
|
+
// --approve would run) is preferred over the raw source command --
|
|
410
|
+
// /confirm can only compute it once a case exists (READY/SMOKE CHECKED),
|
|
411
|
+
// and only when there's enough evidence to convert (see
|
|
412
|
+
// _canonical_badgr_command's docstring, backend/run_issue_routes.py, for
|
|
413
|
+
// what it can't yet convert, e.g. training/ComfyUI). Falls back to the
|
|
414
|
+
// raw extracted command otherwise -- still true and still runnable input,
|
|
415
|
+
// just not guaranteed to already be `badgr`-shaped.
|
|
416
|
+
const badgrJob = caseData?.findings?.canonical_command || e.commands?.[0] || '(no command produced)';
|
|
417
|
+
console.log(` ${chalk.bold('Badgr job:')}`);
|
|
418
|
+
console.log(` ${badgrJob}`);
|
|
419
|
+
console.log();
|
|
420
|
+
|
|
421
|
+
if (caseData?.case_id) {
|
|
422
|
+
console.log(` ${chalk.bold('Run:')}`);
|
|
423
|
+
console.log(` ${chalk.cyan(_runPageUrl(caseData.case_id, config))}`);
|
|
424
|
+
console.log();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (status === 'SMOKE CHECKED' || status === 'INVALID') {
|
|
428
|
+
console.log(` ${chalk.bold('Checks:')}`);
|
|
429
|
+
const iconFor = (s) => (s === 'pass' ? '✓' : s === 'fail' ? '✗' : '-');
|
|
430
|
+
for (const check of (e.mechanical_checks || [])) {
|
|
431
|
+
console.log(` ${iconFor(check.status)} ${_CHECK_LABELS[check.name] || check.name}`);
|
|
432
|
+
}
|
|
433
|
+
const localPaths = _localPathCandidates(e);
|
|
434
|
+
if (!localPaths.length) {
|
|
435
|
+
console.log(' - local file check skipped');
|
|
436
|
+
} else {
|
|
437
|
+
const allExist = localPaths.every((p) => fs.existsSync(p));
|
|
438
|
+
console.log(` ${allExist ? '✓' : '✗'} local file check${allExist ? '' : ' (not found)'}`);
|
|
439
|
+
}
|
|
440
|
+
console.log();
|
|
441
|
+
if (status === 'INVALID') {
|
|
442
|
+
console.log(' A check above actually failed -- this command is not runnable as-is.');
|
|
443
|
+
} else {
|
|
444
|
+
console.log(' Not GPU-verified.');
|
|
445
|
+
}
|
|
446
|
+
console.log();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --smoke's mechanical checks: prints the backend's evidence-backed
|
|
451
|
+
// pass/fail/skip results (`extraction.mechanical_checks`, computed for
|
|
452
|
+
// free from data already gathered by /run-issue/extract -- no extra
|
|
453
|
+
// network call), plus this machine's own local-file existence check.
|
|
454
|
+
// Deliberately never prints "Verified" -- see AGENTS.md §12; a genuinely
|
|
455
|
+
// passing smoke check still only earns "Smoke Checked".
|
|
456
|
+
function _printSmokeChecks(result, chalk) {
|
|
457
|
+
const e = result.extraction || {};
|
|
458
|
+
const mech = e.mechanical_checks || [];
|
|
459
|
+
const localPaths = _localPathCandidates(e);
|
|
460
|
+
|
|
461
|
+
console.log();
|
|
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):')}`);
|
|
469
|
+
|
|
470
|
+
const icon = (status) => (
|
|
471
|
+
status === 'pass' ? chalk.green('✓') : status === 'fail' ? chalk.red('✗') : chalk.dim('-')
|
|
472
|
+
);
|
|
473
|
+
for (const check of mech) {
|
|
474
|
+
console.log(` ${icon(check.status)} ${chalk.dim(`[${check.name}]`)} ${check.detail}`);
|
|
475
|
+
}
|
|
476
|
+
for (const p of localPaths) {
|
|
477
|
+
const exists = fs.existsSync(p);
|
|
478
|
+
console.log(` ${exists ? chalk.green('✓') : chalk.red('✗')} ${chalk.dim('[local_file]')} ${p} ${exists ? 'exists on this machine' : 'NOT FOUND on this machine'}`);
|
|
479
|
+
}
|
|
480
|
+
if (!mech.length && !localPaths.length) {
|
|
481
|
+
console.log(` ${chalk.dim('No mechanical checks applicable to this input.')}`);
|
|
204
482
|
}
|
|
483
|
+
|
|
484
|
+
const proofLevel = e.smoke_check?.proof_level;
|
|
485
|
+
if (proofLevel === 'smoke_checked') {
|
|
486
|
+
console.log(` ${chalk.green('✓')} ${chalk.dim('[command_scope]')} Command is the repository/image/service's own documented default.`);
|
|
487
|
+
}
|
|
488
|
+
console.log();
|
|
205
489
|
}
|
|
206
490
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// required once we know a real GPU test needs approving (below).
|
|
210
|
-
const e = result.extraction;
|
|
211
|
-
const confirmBody = {
|
|
491
|
+
function _confirmBodyFromExtraction(e) {
|
|
492
|
+
return {
|
|
212
493
|
workload_type: e.workload_type || 'generic',
|
|
213
494
|
docker_image: e.docker_images?.[0] || null,
|
|
214
495
|
model_id: e.models?.[0] || null,
|
|
@@ -220,25 +501,76 @@ async function _doApprove(config, result, chalk) {
|
|
|
220
501
|
? e.gpu_requirements.join(', ')
|
|
221
502
|
: e.gpu_requirements || null,
|
|
222
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,
|
|
223
514
|
};
|
|
515
|
+
}
|
|
224
516
|
|
|
225
|
-
|
|
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);
|
|
226
538
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
539
|
+
return await promise;
|
|
540
|
+
} finally {
|
|
541
|
+
clearInterval(timer);
|
|
542
|
+
process.stdout.write('\r\x1b[2K');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Creates the free, anonymous "prepared run" case that backs a shareable
|
|
547
|
+
// Run Link -- the exact same /run-issue/confirm call and case object
|
|
548
|
+
// /run-issue's own confirm step creates, so the CLI and the web page never
|
|
549
|
+
// diverge on what a given diagnosis resolves to. No GPU, no login, no
|
|
550
|
+
// payment -- confirm only ever inspects and plans (see its own docstring).
|
|
551
|
+
// Called once per diagnosis (main flow), then reused by --approve below
|
|
552
|
+
// instead of confirming a second time.
|
|
553
|
+
//
|
|
554
|
+
// Returns `{ caseData }` on success, `{ error }` on a network/API failure --
|
|
555
|
+
// never throws, since a plain diagnosis must still succeed even if the
|
|
556
|
+
// free run-page creation itself fails.
|
|
557
|
+
async function _createRunPage(config, e, chalk) {
|
|
558
|
+
try {
|
|
559
|
+
const caseData = await callApi('/run-issue/confirm', {
|
|
229
560
|
method: 'POST',
|
|
230
561
|
apiKey: config.apiKey || '',
|
|
231
562
|
baseUrl: config.baseUrl,
|
|
232
|
-
body:
|
|
563
|
+
body: _confirmBodyFromExtraction(e),
|
|
233
564
|
timeoutMs: 20_000,
|
|
234
565
|
});
|
|
566
|
+
return caseData?.case_id ? { caseData } : { caseData: null };
|
|
235
567
|
} catch (err) {
|
|
236
|
-
|
|
237
|
-
process.exitCode = 1;
|
|
238
|
-
return;
|
|
568
|
+
return { error: err };
|
|
239
569
|
}
|
|
570
|
+
}
|
|
240
571
|
|
|
241
|
-
|
|
572
|
+
async function _doApprove(config, caseData, chalk) {
|
|
573
|
+
const { case_id: caseId, status, test_plan: plan, workload_plan: workloadPlan, missing_information: missing } = caseData;
|
|
242
574
|
|
|
243
575
|
if (status === 'incompatible') {
|
|
244
576
|
console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
|
|
@@ -262,13 +594,19 @@ async function _doApprove(config, result, chalk) {
|
|
|
262
594
|
return;
|
|
263
595
|
}
|
|
264
596
|
|
|
265
|
-
|
|
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) {
|
|
266
604
|
console.log();
|
|
267
|
-
if (
|
|
268
|
-
if (
|
|
269
|
-
if (
|
|
270
|
-
if (plan
|
|
271
|
-
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`);
|
|
272
610
|
}
|
|
273
611
|
|
|
274
612
|
await _approveAndRun(config, caseId, plan, chalk);
|
|
@@ -282,8 +620,33 @@ async function _doApprove(config, result, chalk) {
|
|
|
282
620
|
// it only redeems one a case already carries, same as normal billing.
|
|
283
621
|
async function _approveAndRun(config, caseId, plan, chalk) {
|
|
284
622
|
const printResumeHint = () =>
|
|
285
|
-
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
|
+
}
|
|
286
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.
|
|
287
650
|
let authConfig = config;
|
|
288
651
|
if (!authConfig.apiKey) {
|
|
289
652
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
@@ -318,13 +681,20 @@ async function _approveAndRun(config, caseId, plan, chalk) {
|
|
|
318
681
|
body: {
|
|
319
682
|
confirmed: true,
|
|
320
683
|
funding_approved: Boolean(plan?.requires_funding_approval),
|
|
321
|
-
|
|
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,
|
|
322
688
|
},
|
|
323
689
|
timeoutMs: 15_000,
|
|
324
690
|
});
|
|
325
691
|
} catch (err) {
|
|
326
|
-
|
|
327
|
-
|
|
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();
|
|
328
698
|
} else {
|
|
329
699
|
console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
|
|
330
700
|
}
|
|
@@ -332,24 +702,29 @@ async function _approveAndRun(config, caseId, plan, chalk) {
|
|
|
332
702
|
return;
|
|
333
703
|
}
|
|
334
704
|
|
|
335
|
-
console.log(chalk.dim('\n Launching
|
|
705
|
+
console.log(chalk.dim('\n Launching Badgr Run...'));
|
|
336
706
|
let ran;
|
|
337
707
|
try {
|
|
338
708
|
ran = await callApi(`/run-issue/cases/${caseId}/run`, {
|
|
339
709
|
method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
|
|
340
710
|
});
|
|
341
711
|
} catch (err) {
|
|
342
|
-
|
|
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
|
+
}
|
|
343
718
|
process.exitCode = 1;
|
|
344
719
|
return;
|
|
345
720
|
}
|
|
346
721
|
|
|
347
722
|
console.log();
|
|
348
|
-
console.log(chalk.green('
|
|
723
|
+
console.log(chalk.green(' Run started.'));
|
|
349
724
|
console.log(` ${chalk.bold('Case:')} ${caseId}`);
|
|
350
725
|
if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
|
|
351
726
|
if (ran?.free_run_used) console.log(` ${chalk.bold('Billing:')} Free run — no charge`);
|
|
352
|
-
console.log(` ${chalk.bold('Evidence:')}
|
|
727
|
+
console.log(` ${chalk.bold('Evidence:')} ${webBaseUrl(config)}/repro/${caseId}`);
|
|
353
728
|
console.log();
|
|
354
729
|
}
|
|
355
730
|
|
|
@@ -386,13 +761,39 @@ async function _diagnoseExistingCase(config, caseId, flags, chalk) {
|
|
|
386
761
|
for (const m of caseData.missing_information) console.log(` ? ${m}`);
|
|
387
762
|
}
|
|
388
763
|
|
|
764
|
+
// "Verified" is only ever earned here -- a case whose real GPU run
|
|
765
|
+
// actually completed (`lead_summary.status`, computed server-side by
|
|
766
|
+
// `_build_lead_summary` from `ReproCase.status`/`final_command`). Never
|
|
767
|
+
// reachable from --smoke, which never provisions anything.
|
|
768
|
+
console.log();
|
|
769
|
+
const existingStatus = caseData.lead_summary?.status === 'verified'
|
|
770
|
+
|| caseData.status === 'verified' || caseData.status === 'completed'
|
|
771
|
+
? 'VERIFIED'
|
|
772
|
+
: (caseData.missing_information || []).length ? 'NEEDS INFO' : 'READY';
|
|
773
|
+
console.log(` ${chalk.bold(`STATUS: ${existingStatus}`)}`);
|
|
774
|
+
if (existingStatus === 'VERIFIED') {
|
|
775
|
+
console.log();
|
|
776
|
+
console.log(` ${chalk.bold('Command:')}`);
|
|
777
|
+
console.log(` ${caseData.final_command || caseData.known_command}`);
|
|
778
|
+
console.log();
|
|
779
|
+
console.log(` ${chalk.bold('Result:')}`);
|
|
780
|
+
const resultLines = caseData.lead_summary?.test_checklist?.length
|
|
781
|
+
? caseData.lead_summary.test_checklist
|
|
782
|
+
: ['Verified'];
|
|
783
|
+
for (const line of resultLines) console.log(` - ${line}`);
|
|
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.`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
389
789
|
console.log();
|
|
390
790
|
if (caseData.free_verification_consumed) {
|
|
391
791
|
console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
|
|
392
792
|
} else if (caseData.free_verification_available) {
|
|
393
793
|
console.log(` ${chalk.green(`1 free verification job available — up to $${(caseData.free_verification_max_cost_usd ?? 5).toFixed(0)}`)}`);
|
|
394
|
-
} else if (caseData.test_plan) {
|
|
395
|
-
|
|
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)}`)}`);
|
|
396
797
|
}
|
|
397
798
|
console.log();
|
|
398
799
|
|
|
@@ -411,7 +812,15 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
411
812
|
}
|
|
412
813
|
|
|
413
814
|
const flags = {};
|
|
414
|
-
|
|
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 = [];
|
|
415
824
|
let i = 0;
|
|
416
825
|
while (i < args.length) {
|
|
417
826
|
const a = args[i];
|
|
@@ -420,10 +829,12 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
420
829
|
if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
|
|
421
830
|
if (a === '--github') { flags.github = args[++i]; i++; continue; }
|
|
422
831
|
if (a === '--approve') { flags.approve = true; i++; continue; }
|
|
832
|
+
if (a === '--smoke') { flags.smoke = true; i++; continue; }
|
|
423
833
|
if (a === '--json') { flags.json = true; i++; continue; }
|
|
424
|
-
if (!a.startsWith('-')
|
|
834
|
+
if (!a.startsWith('-')) positionalParts.push(a);
|
|
425
835
|
i++;
|
|
426
836
|
}
|
|
837
|
+
const positional = positionalParts.length ? positionalParts.join(' ') : null;
|
|
427
838
|
|
|
428
839
|
if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
|
|
429
840
|
console.log(DIAGNOSE_HELP);
|
|
@@ -456,38 +867,121 @@ export async function diagnoseCommand(config, args, chalk) {
|
|
|
456
867
|
if (!flags.json) {
|
|
457
868
|
console.log();
|
|
458
869
|
console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
|
|
459
|
-
|
|
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
|
+
}
|
|
460
877
|
}
|
|
461
878
|
|
|
462
879
|
let result;
|
|
463
880
|
try {
|
|
464
|
-
|
|
881
|
+
const extractPromise = callApi('/run-issue/extract', {
|
|
465
882
|
method: 'POST',
|
|
466
883
|
apiKey: config.apiKey || '',
|
|
467
884
|
baseUrl: config.baseUrl,
|
|
468
885
|
body: detected.body,
|
|
469
886
|
timeoutMs: 120_000,
|
|
470
887
|
});
|
|
888
|
+
result = flags.json
|
|
889
|
+
? await extractPromise
|
|
890
|
+
: await _withElapsed(chalk, 'Running free checks...', extractPromise);
|
|
471
891
|
} catch (err) {
|
|
472
892
|
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
473
893
|
process.exitCode = 1;
|
|
474
894
|
return;
|
|
475
895
|
}
|
|
476
896
|
|
|
897
|
+
const status = _statusWord(result);
|
|
898
|
+
|
|
899
|
+
// A command that's actually runnable also gets a free prepared Run
|
|
900
|
+
// page -- the same case /run-issue's own confirm step would create --
|
|
901
|
+
// so diagnosis ends at a page someone can click Run on, not just a
|
|
902
|
+
// printed command. Only READY and SMOKE CHECKED qualify: NEEDS INFO has
|
|
903
|
+
// nothing runnable to save yet, and INVALID is a command a real check
|
|
904
|
+
// just proved broken -- turning that into a clickable "Run" page would
|
|
905
|
+
// contradict the label. Both print their own guidance instead (Missing:
|
|
906
|
+
// / the failed check) and stop there.
|
|
907
|
+
let caseData = null;
|
|
908
|
+
let caseError = null;
|
|
909
|
+
if (status === 'READY' || status === 'SMOKE CHECKED') {
|
|
910
|
+
const createPromise = _createRunPage(config, result.extraction || {}, chalk);
|
|
911
|
+
const created = flags.json
|
|
912
|
+
? await createPromise
|
|
913
|
+
: await _withElapsed(chalk, 'Preparing Run link...', createPromise);
|
|
914
|
+
if (created.error) caseError = created.error;
|
|
915
|
+
else caseData = created.caseData;
|
|
916
|
+
}
|
|
917
|
+
|
|
477
918
|
if (flags.json) {
|
|
478
|
-
|
|
919
|
+
const output = { detected: detected.kind, ...result, status };
|
|
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
|
+
{
|
|
924
|
+
const e = result.extraction || {};
|
|
925
|
+
output.smoke = {
|
|
926
|
+
mechanical_checks: e.mechanical_checks || [],
|
|
927
|
+
local_file_checks: _localPathCandidates(e).map(p => ({ path: p, exists: fs.existsSync(p) })),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
if (caseData?.case_id) {
|
|
931
|
+
output.run = {
|
|
932
|
+
case_id: caseData.case_id,
|
|
933
|
+
url: _runPageUrl(caseData.case_id, config),
|
|
934
|
+
canonical_command: caseData.findings?.canonical_command || null,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
console.log(JSON.stringify(output, null, 2));
|
|
938
|
+
|
|
939
|
+
// --json must not silently skip --approve -- the JSON branch used to
|
|
940
|
+
// return here unconditionally, so a scripted `--approve --json` call
|
|
941
|
+
// never claimed/approved/launched anything (bug: approval was silently
|
|
942
|
+
// dropped in the one output mode automation actually uses). Approval
|
|
943
|
+
// output itself stays human-readable (chalk console lines), matching
|
|
944
|
+
// _diagnoseExistingCase's existing --json + --approve behaviour below.
|
|
945
|
+
if (flags.approve) {
|
|
946
|
+
if (caseError) {
|
|
947
|
+
console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
|
|
948
|
+
process.exitCode = 1;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (!caseData) {
|
|
952
|
+
console.error(chalk.yellow('\n Cannot approve — missing information above.\n'));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
await _doApprove(config, caseData, chalk);
|
|
956
|
+
}
|
|
479
957
|
return;
|
|
480
958
|
}
|
|
481
959
|
|
|
482
960
|
console.log();
|
|
483
961
|
_printResult(result, chalk);
|
|
484
962
|
|
|
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);
|
|
967
|
+
|
|
485
968
|
console.log();
|
|
969
|
+
_printCopySummary(result, chalk, caseData, config);
|
|
970
|
+
|
|
486
971
|
console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
|
|
487
|
-
console.log(chalk.dim(
|
|
972
|
+
console.log(chalk.dim(` Full interactive flow: ${webBaseUrl(config)}/run-issue`));
|
|
488
973
|
console.log();
|
|
489
974
|
|
|
490
975
|
if (flags.approve) {
|
|
491
|
-
|
|
976
|
+
if (caseError) {
|
|
977
|
+
console.error(chalk.red(`\n ✗ Could not create case: ${caseError.message}\n`));
|
|
978
|
+
process.exitCode = 1;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (!caseData) {
|
|
982
|
+
console.log(chalk.yellow('\n Cannot approve — missing information above.\n'));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
await _doApprove(config, caseData, chalk);
|
|
492
986
|
}
|
|
493
987
|
}
|