badgr-cli 1.0.31 → 1.0.34

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.
@@ -1,13 +1,14 @@
1
1
  import { requireApiKey } from '../config.js';
2
- import { callApi } from '../api.js';
3
- import { addReceipt, generateReceiptId } from '../store.js';
2
+ import { callApi, terminateDeployment } from '../api.js';
3
+ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
4
+ import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
5
+ import { formatCliError } from '../errors.js';
4
6
 
5
7
  /**
6
- * badgr run python train.py --gpu A100 # attached (default)
8
+ * badgr run python train.py # gpu=auto, attached
9
+ * badgr run python train.py --gpu A100 # specific GPU
7
10
  * badgr run --image my/image:latest --gpu L40S --detach
8
- *
9
- * Attaches by default: polls status + streams logs until the job finishes,
10
- * then exits with the job's exit code. Pass --detach to return immediately.
11
+ * badgr run python train.py --env HF_TOKEN=abc --env DATASET=my/data
11
12
  */
12
13
  export function parseRunArgs(args) {
13
14
  const flags = {};
@@ -18,144 +19,608 @@ export function parseRunArgs(args) {
18
19
  if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
19
20
  if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
20
21
  if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
22
+ if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
21
23
  if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
22
24
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
23
- if (args[i] === '--detach') { flags.detach = true; i++; continue; }
25
+ if (args[i] === '--detach') { flags.detach = true; i++; continue; }
26
+ if (args[i] === '--fallback') { flags.fallback = args[++i]; i++; continue; }
27
+ if (args[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
28
+ if (args[i] === '--strict-capacity') { flags.noFallback = true; i++; continue; }
29
+ if (args[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
30
+ if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
31
+ if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
32
+ if (args[i] === '--min-vram') { flags.minVram = parseFloat(args[++i]); i++; continue; }
33
+ if (args[i] === '--env') {
34
+ const kv = args[++i]; i++;
35
+ if (!flags.env) flags.env = [];
36
+ flags.env.push(kv);
37
+ continue;
38
+ }
24
39
  positional.push(args[i++]);
25
40
  }
26
41
  return { flags, positional };
27
42
  }
28
43
 
29
- // Poll /v1/deployments/:id until terminal status, streaming new log lines.
30
- async function attachToJob(config, depId, chalk) {
31
- const TERMINAL = new Set(['stopped', 'failed', 'completed']);
32
- const POLL_MS = 4000;
33
- let seenLines = 0;
34
- let lastStatus = '';
44
+ function parseEnvFlag(envList) {
45
+ const obj = {};
46
+ for (const kv of (envList || [])) {
47
+ const idx = kv.indexOf('=');
48
+ if (idx > 0) obj[kv.slice(0, idx)] = kv.slice(idx + 1);
49
+ }
50
+ return obj;
51
+ }
35
52
 
36
- while (true) {
37
- await new Promise(r => setTimeout(r, POLL_MS));
53
+ // Mirror of backend workload_profile.py — kept in sync for pre-flight display.
54
+ const _PROFILES = {
55
+ smoke_test: { label: 'smoke test', vram: '4 GB', gpus: ['RTX 3080', 'RTX 3090', 'RTX 4090'] },
56
+ lora_finetune: { label: 'fine-tuning (LoRA)', vram: '40+ GB', gpus: ['A6000', 'L40S', 'A100'] },
57
+ image_gen: { label: 'image generation', vram: '16+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] },
58
+ inference_small: { label: 'inference (7B–8B model)', vram: '24+ GB', gpus: ['RTX 4090', 'A6000', 'L40S'] },
59
+ general: { label: 'GPU job', vram: '16+ GB', gpus: ['RTX 4090', 'RTX 3090', 'A6000', 'L40S'] },
60
+ };
38
61
 
39
- // Fetch status
40
- let dep;
41
- try {
42
- dep = await callApi(`/deployments/${depId}`, {
43
- apiKey: config.apiKey,
44
- baseUrl: config.baseUrl,
45
- });
46
- } catch {
47
- // transient network error — keep trying
48
- continue;
49
- }
62
+ function inferProfileFromCommand(cmdStr) {
63
+ const s = cmdStr.toLowerCase();
64
+ if (/print\s*\(|['"]hello/.test(s) && s.length < 100) return 'smoke_test';
65
+ if (/lora|qlora|finetune|fine[_-]tun|peft/.test(s)) return 'lora_finetune';
66
+ if (/diffusion|stable.?diff|sdxl|sd.?xl|comfyui|a1111|invoke|kohya/.test(s)) return 'image_gen';
67
+ if (/vllm|tgi|text.generation.inference/.test(s)) return 'inference_small';
68
+ if (/\btrain\.py\b/.test(s)) return 'lora_finetune';
69
+ return 'general';
70
+ }
50
71
 
51
- const status = dep.status;
52
- if (status !== lastStatus) {
53
- if (status === 'running' && lastStatus === 'provisioning') {
54
- console.log(chalk.dim(' [running]'));
55
- }
56
- lastStatus = status;
72
+ function fmtRuntime(ms) {
73
+ const s = Math.round(ms / 1000);
74
+ if (s < 60) return `${s}s`;
75
+ const m = Math.floor(s / 60);
76
+ return `${m}m ${s % 60}s`;
77
+ }
78
+
79
+ const HEARTBEAT_WARN_POLLS = 3;
80
+ const HEARTBEAT_KILL_POLLS = 15;
81
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
82
+
83
+ export function classifyFailure(finalStatus, exitCode) {
84
+ if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
85
+ if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
86
+ return null;
87
+ }
88
+
89
+ // Lines the log stream never needs to print — we surface them in the status bar instead.
90
+ const LOG_META_RE = /^\[dep-[^\]]+\] (status|gpu|region|cost|receipt|provider_status|uptime)=/;
91
+
92
+ // Extract structured values from provider status lines so we can show them nicely.
93
+ function parseProviderLine(line) {
94
+ const gpuUtil = line.match(/\bgpu_util=([\d.]+)%/);
95
+ const cpuUtil = line.match(/\bcpu_util=([\d.]+)%/);
96
+ const ssh = line.match(/\bssh=(\S+)/);
97
+ const provSt = line.match(/\bprovider_status=(\S+)/);
98
+ return {
99
+ gpuUtil: gpuUtil ? parseFloat(gpuUtil[1]) : null,
100
+ cpuUtil: cpuUtil ? parseFloat(cpuUtil[1]) : null,
101
+ ssh: ssh ? ssh[1] : null,
102
+ providerStatus: provSt ? provSt[1] : null,
103
+ };
104
+ }
105
+
106
+ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost }) {
107
+ const spent = ratePerHour * (elapsedMs / 3_600_000);
108
+ const parts = [`⏱ ${fmtRuntime(elapsedMs)}`];
109
+ if (ratePerHour > 0) parts.push(`$${spent.toFixed(4)} spent`);
110
+ if (gpuUtil !== null) parts.push(`GPU ${gpuUtil.toFixed(0)}%`);
111
+ if (cpuUtil !== null) parts.push(`CPU ${cpuUtil.toFixed(0)}%`);
112
+ if (maxRuntimeMs) {
113
+ const left = Math.max(0, maxRuntimeMs - elapsedMs);
114
+ parts.push(`${fmtRuntime(left)} left`);
115
+ }
116
+ if (maxCost && ratePerHour > 0) {
117
+ const budgetLeft = Math.max(0, maxCost - spent);
118
+ parts.push(`$${budgetLeft.toFixed(4)} budget left`);
119
+ }
120
+ parts.push('Ctrl+C to stop');
121
+ return chalk.dim(' ' + parts.join(' • '));
122
+ }
123
+
124
+ // Wait for status to leave 'starting'/'queued'/'provisioning'.
125
+ // Returns the dep once it leaves startup states (or the last known state on timeout).
126
+ async function waitForRunning(config, depId, chalk) {
127
+ const POLL_MS = 3000;
128
+ const TIMEOUT_MS = 5 * 60 * 1000;
129
+ const startMs = Date.now();
130
+ const PHASES = [
131
+ { afterMs: 0, label: ' Starting container' },
132
+ { afterMs: 15000, label: ' Pulling image' },
133
+ { afterMs: 60000, label: ' Starting container' },
134
+ { afterMs: 180000, label: ' Running command' },
135
+ ];
136
+
137
+ let lastPhaseIdx = -1;
138
+
139
+ const ticker = setInterval(() => {
140
+ const elapsed = Date.now() - startMs;
141
+ let phaseIdx = 0;
142
+ for (let i = 0; i < PHASES.length; i++) {
143
+ if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
57
144
  }
145
+ if (phaseIdx !== lastPhaseIdx) {
146
+ process.stdout.write('\r\x1b[2K');
147
+ process.stdout.write(chalk.dim(PHASES[phaseIdx].label));
148
+ lastPhaseIdx = phaseIdx;
149
+ }
150
+ process.stdout.write('.');
151
+ }, 1000);
58
152
 
59
- // Stream any new log lines
60
- try {
61
- const logData = await callApi(`/deployments/${depId}/logs`, {
153
+ try {
154
+ while (Date.now() - startMs < TIMEOUT_MS) {
155
+ await new Promise(r => setTimeout(r, POLL_MS));
156
+ const dep = await callApi(`/deployments/${depId}`, {
62
157
  apiKey: config.apiKey,
63
158
  baseUrl: config.baseUrl,
159
+ timeoutMs: 10_000,
64
160
  });
65
- const lines = logData?.logs ?? [];
66
- for (let i = seenLines; i < lines.length; i++) {
67
- console.log(` ${chalk.dim(lines[i])}`);
161
+ if (!STARTUP_STATES.has(dep.status)) {
162
+ process.stdout.write('\n');
163
+ return dep;
68
164
  }
69
- seenLines = lines.length;
70
- } catch {
71
- // logs not ready yet
72
165
  }
166
+ } finally {
167
+ clearInterval(ticker);
168
+ process.stdout.write('\n');
169
+ }
170
+
171
+ return await callApi(`/deployments/${depId}`, {
172
+ apiKey: config.apiKey,
173
+ baseUrl: config.baseUrl,
174
+ timeoutMs: 10_000,
175
+ });
176
+ }
177
+
178
+ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown }) {
179
+ const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
180
+ const POLL_MS = 4000;
181
+ let seenContent = new Set();
182
+ let lastStatus = '';
183
+ let consecutiveErrs = 0;
184
+ let gpuUtil = null;
185
+ let cpuUtil = null;
186
+ let sshShown = false;
187
+ let statusBarActive = false;
188
+ const startMs = Date.now();
189
+
190
+ // tearing: guards against double-teardown for cap/heartbeat paths within this function.
191
+ let tearing = false;
192
+
193
+ let tickerInterval = null;
194
+ const startTicker = () => {
195
+ if (tickerInterval) return;
196
+ statusBarActive = true;
197
+ tickerInterval = setInterval(() => {
198
+ const bar = renderStatusBar(chalk, {
199
+ elapsedMs: Date.now() - startMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost,
200
+ });
201
+ process.stdout.write(`\r${bar} `);
202
+ }, 1000);
203
+ };
204
+ const stopTicker = () => {
205
+ if (tickerInterval) { clearInterval(tickerInterval); tickerInterval = null; }
206
+ if (statusBarActive) { process.stdout.write('\r\x1b[2K'); statusBarActive = false; }
207
+ };
208
+
209
+ try {
210
+ while (true) {
211
+ // Exit loop if SIGINT handler has started shutdown externally.
212
+ if (isShuttingDown()) break;
213
+ if (tearing) break;
214
+
215
+ await new Promise(r => setTimeout(r, POLL_MS));
216
+
217
+ if (isShuttingDown()) break;
218
+ if (tearing) break;
219
+
220
+ const elapsedMs = Date.now() - startMs;
221
+ const spentSoFar = ratePerHour * (elapsedMs / 3_600_000);
222
+
223
+ if (maxCost !== null && spentSoFar >= maxCost) {
224
+ tearing = true;
225
+ stopTicker();
226
+ await onTeardown('max-cost');
227
+ return { status: 'capped', reason: 'max-cost', exitCode: null, runtimeMs: elapsedMs, failureType: null };
228
+ }
73
229
 
74
- if (TERMINAL.has(status)) {
75
- return status;
230
+ if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
231
+ tearing = true;
232
+ stopTicker();
233
+ await onTeardown('max-runtime');
234
+ return { status: 'capped', reason: 'max-runtime', exitCode: null, runtimeMs: elapsedMs, failureType: null };
235
+ }
236
+
237
+ let dep;
238
+ try {
239
+ dep = await callApi(`/deployments/${depId}`, {
240
+ apiKey: config.apiKey,
241
+ baseUrl: config.baseUrl,
242
+ timeoutMs: 10_000,
243
+ });
244
+ consecutiveErrs = 0;
245
+ } catch {
246
+ consecutiveErrs++;
247
+ if (lastStatus === 'running') {
248
+ const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
249
+ if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
250
+ stopTicker();
251
+ console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
252
+ startTicker();
253
+ } else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
254
+ tearing = true;
255
+ stopTicker();
256
+ await onTeardown('heartbeat-lost');
257
+ return { status: 'failed', reason: 'heartbeat-lost', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
258
+ }
259
+ }
260
+ continue;
261
+ }
262
+
263
+ const status = dep.status;
264
+ if (status !== lastStatus) {
265
+ if (status === 'running' && lastStatus === 'provisioning') {
266
+ stopTicker();
267
+ console.log(chalk.dim(' [running]'));
268
+ }
269
+ lastStatus = status;
270
+ }
271
+
272
+ try {
273
+ const logData = await callApi(`/deployments/${depId}/logs`, {
274
+ apiKey: config.apiKey,
275
+ baseUrl: config.baseUrl,
276
+ timeoutMs: 10_000,
277
+ });
278
+ const lines = logData?.logs ?? [];
279
+
280
+ for (const line of lines) {
281
+ const parsed = parseProviderLine(line);
282
+ if (parsed.gpuUtil !== null) gpuUtil = parsed.gpuUtil;
283
+ if (parsed.cpuUtil !== null) cpuUtil = parsed.cpuUtil;
284
+
285
+ if (parsed.ssh && !sshShown) {
286
+ stopTicker();
287
+ console.log(` ${chalk.bold('SSH:')} ${chalk.cyan(parsed.ssh)}`);
288
+ sshShown = true;
289
+ startTicker();
290
+ }
291
+
292
+ if (seenContent.has(line)) continue;
293
+ seenContent.add(line);
294
+ if (LOG_META_RE.test(line)) continue;
295
+ if (/\b(gpu_util|cpu_util|provider_status|uptime)=/.test(line)) continue;
296
+
297
+ const isErrorLine = /^error\b/i.test(line) || /Error response from daemon/i.test(line);
298
+ stopTicker();
299
+ console.log(` ${isErrorLine ? chalk.red(line) : chalk.dim(line)}`);
300
+ startTicker();
301
+ }
302
+ } catch {
303
+ // logs not ready yet
304
+ }
305
+
306
+ if (status === 'running') startTicker();
307
+
308
+ if (TERMINAL.has(status)) {
309
+ stopTicker();
310
+ const exitCode = dep.exit_code ?? null;
311
+ return {
312
+ status,
313
+ reason: null,
314
+ exitCode,
315
+ runtimeMs: Date.now() - startMs,
316
+ failureType: classifyFailure(status, exitCode),
317
+ };
318
+ }
76
319
  }
320
+ } finally {
321
+ stopTicker();
77
322
  }
323
+
324
+ // Reached when SIGINT (isShuttingDown) or duplicate tearing flag breaks the loop.
325
+ // The SIGINT handleShutdown() is managing teardown + exit.
326
+ return { status: 'interrupted', reason: 'signal', exitCode: null, runtimeMs: Date.now() - startMs, failureType: null };
78
327
  }
79
328
 
329
+ // Known badgr run flags — used to detect broken shell line continuation.
330
+ const _KNOWN_RUN_FLAGS = new Set([
331
+ '--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
332
+ '--detach', '--fallback', '--no-fallback', '--strict-capacity',
333
+ '--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
334
+ ]);
335
+
80
336
  export async function runCommand(config, args, chalk) {
81
337
  const { flags, positional } = parseRunArgs(args);
82
338
 
339
+ // Detect flags that ended up in the command because of broken shell line continuation
340
+ // (e.g. `\ ` with trailing space instead of `\<newline>`).
341
+ const misplaced = positional.filter(a => _KNOWN_RUN_FLAGS.has(a));
342
+ if (misplaced.length > 0) {
343
+ console.error(chalk.red(`\n ✗ These look like badgr flags but were treated as command arguments:`));
344
+ console.error(chalk.red(` ${misplaced.join(', ')}`));
345
+ console.error(chalk.dim(''));
346
+ console.error(chalk.dim(' This usually means a line continuation has a trailing space.'));
347
+ console.error(chalk.dim(' Use a single line, or end each continued line with \\ and no space after:'));
348
+ console.error(chalk.dim(''));
349
+ console.error(chalk.dim(' badgr run python script.py \\'));
350
+ console.error(chalk.dim(' --gpu RTX_4090 --max-cost 10 --max-runtime 60'));
351
+ console.error(chalk.dim(''));
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+
83
356
  if (positional.length === 0 && !flags.image) {
84
- console.error(chalk.red('Usage: badgr run <command...> --gpu <type>'));
85
- console.error(chalk.red(' badgr run --image my/image:latest --gpu A100'));
357
+ console.error(chalk.red('Usage: badgr run <command...>'));
358
+ console.error(chalk.red(' badgr run --image my/image:latest'));
86
359
  return;
87
360
  }
88
361
 
89
362
  requireApiKey(config);
90
363
 
91
- const command = positional.length > 0 ? positional : undefined;
92
- const gpu = flags.gpu || 'RTX_4090';
93
- const image = flags.image || (command ? 'python:3.11-slim' : undefined);
94
- const detach = flags.detach || false;
364
+ // ── Validate flags early ───────────────────────────────────────────────────
365
+ if (flags.count !== undefined && (!Number.isFinite(flags.count) || flags.count < 1)) {
366
+ console.error(chalk.red(' --count must be an integer greater than 0'));
367
+ process.exitCode = 1;
368
+ return;
369
+ }
370
+ if (flags.maxCost !== undefined && (!Number.isFinite(flags.maxCost) || flags.maxCost <= 0)) {
371
+ console.error(chalk.red(' ✗ --max-cost must be a number greater than 0'));
372
+ process.exitCode = 1;
373
+ return;
374
+ }
375
+ if (flags.maxPrice !== undefined && (!Number.isFinite(flags.maxPrice) || flags.maxPrice <= 0)) {
376
+ console.error(chalk.red(' ✗ --max-price must be a number greater than 0'));
377
+ process.exitCode = 1;
378
+ return;
379
+ }
380
+ if (flags.region !== undefined && !['US', 'EU', 'AU'].includes(flags.region.toUpperCase())) {
381
+ console.error(chalk.red(' ✗ --region must be US, EU, or AU'));
382
+ process.exitCode = 1;
383
+ return;
384
+ }
385
+ const command = positional.length > 0 ? positional : undefined;
386
+ const cmdStr = command ? command.join(' ') : '';
387
+ const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
388
+ const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
389
+ const image = flags.image || (command ? inferredImage : undefined);
390
+ const detach = flags.detach || false;
391
+ const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
392
+ const maxCost = flags.maxCost ?? null;
393
+ const envObj = parseEnvFlag(flags.env);
394
+ const effectiveTier = normalizeTier(flags.tier);
395
+
396
+ const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
95
397
 
96
398
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
97
399
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
98
400
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
99
- console.log(` ${chalk.bold('GPU:')} ${gpu}`);
100
- if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
401
+ if (gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
402
+ else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
403
+ if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
404
+ if (flags.maxRuntime) console.log(` ${chalk.bold('Max runtime:')} ${flags.maxRuntime}min`);
405
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
406
+ if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
407
+ if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
408
+ if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
101
409
  console.log();
102
- console.log(chalk.dim(' Finding best available GPU capacity...'));
410
+
411
+ if (!detach && !flags.maxRuntime && !maxCost) {
412
+ console.log(chalk.dim(' Tip: add --max-runtime 60 or --max-cost 5.00 to cap spend automatically'));
413
+ }
414
+
415
+
416
+ console.log(chalk.dim(' Finding suitable capacity...'));
417
+ if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
418
+ console.log(chalk.dim(` API: ${config.baseUrl}`));
419
+ }
420
+
421
+ function buildBody(gpuOverride, tierOverride) {
422
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
423
+ return {
424
+ command,
425
+ image,
426
+ gpu: gpuOverride || gpu || 'auto',
427
+ ...(flags.minVram ? { min_vram: flags.minVram } : {}),
428
+ gpu_count: flags.count || 1,
429
+ ...(effectiveRegion ? { region: effectiveRegion } : {}),
430
+ max_price_per_hour: flags.maxPrice,
431
+ name: flags.name,
432
+ tier: tierOverride || effectiveTier,
433
+ ...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
434
+ };
435
+ }
103
436
 
104
437
  let dep;
105
438
  try {
106
- dep = await callApi('/run', {
107
- method: 'POST',
108
- apiKey: config.apiKey,
109
- baseUrl: config.baseUrl,
110
- body: {
111
- command,
112
- image,
113
- gpu: gpu.toUpperCase().replace('-', '_'),
114
- gpu_count: flags.count || 1,
115
- region: flags.region || 'US',
116
- max_price_per_hour: flags.maxPrice,
117
- name: flags.name,
118
- },
119
- });
439
+ dep = await callWithFallback(
440
+ '/run',
441
+ { apiKey: config.apiKey, baseUrl: config.baseUrl },
442
+ (tierOverride) => buildBody(undefined, tierOverride),
443
+ effectiveTier,
444
+ chalk,
445
+ { thing: 'job', cmd: 'badgr run' },
446
+ { allowTier2Fallback: !flags.noFallback },
447
+ );
120
448
  } catch (err) {
121
- console.error(chalk.red(`\n ✗ Job failed to start: ${err.message}\n`));
122
- process.exit(1);
449
+ if (err.isPaymentRequired) {
450
+ console.error(chalk.yellow(err.message));
451
+ const rerun = ['badgr run', ...args].join(' ');
452
+ console.error(chalk.dim(`After payment, rerun:\n ${rerun}\n`));
453
+ process.exitCode = 1;
454
+ return;
455
+ }
456
+ // CapacityError message is pre-formatted with chalk
457
+ console.error(err.message);
458
+ process.exitCode = 1;
459
+ return;
123
460
  }
124
461
 
125
462
  const rcptId = dep.receipt_id || generateReceiptId();
126
463
  addReceipt({
127
- receiptId: rcptId,
128
- action: 'badgr run',
129
- deploymentId: dep.deployment_id,
130
- provider: dep.provider,
131
- gpu: dep.gpu_type,
132
- status: dep.status,
133
- createdAt: new Date().toISOString(),
464
+ receiptId: rcptId,
465
+ action: 'badgr run',
466
+ deploymentId: dep.deployment_id,
467
+ gpu: dep.gpu_type,
468
+ providerRoute: dep.provider ?? null,
469
+ tier: dep.tier ?? null,
470
+ maxCost: maxCost ?? null,
471
+ maxRuntime: flags.maxRuntime ?? null,
472
+ status: dep.status,
473
+ createdAt: new Date().toISOString(),
134
474
  });
135
475
 
136
- console.log(chalk.bold(`\n Job ID: ${chalk.cyan(dep.deployment_id)}`));
476
+ const rate = dep.cost_per_hour || 0;
477
+
478
+ console.log(chalk.dim(' Capacity found.\n'));
479
+ console.log(chalk.bold(` Job ID: ${chalk.cyan(dep.deployment_id)}`));
137
480
  console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
138
- if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
481
+ if (rate > 0) console.log(` ${chalk.bold('Rate:')} $${rate.toFixed(2)}/hr`);
482
+ if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
139
483
  console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
140
484
 
485
+ if (rate > HIGH_RATE_THRESHOLD && !maxCost) {
486
+ console.log(chalk.yellow(`\n Selected capacity rate: $${rate.toFixed(2)}/hr`));
487
+ console.log(chalk.dim(' Tip: use --max-cost to enforce a hard ceiling.'));
488
+ }
489
+
141
490
  if (detach) {
142
491
  console.log(`\n ${chalk.bold('Logs:')} ${dep.logs_url || `badgr logs ${dep.deployment_id}`}`);
143
492
  console.log(chalk.dim(`\n Detached. Track progress: badgr logs ${dep.deployment_id}\n`));
144
493
  return;
145
494
  }
146
495
 
147
- // ── Attached mode: stream logs until job completes ───────────────────────
148
- console.log(chalk.dim('\n ── Attaching (Ctrl+C to detach) ──────────────────────────────\n'));
496
+ // ── Teardown helper ────────────────────────────────────────────────────────
497
+ // Does NOT call process.exit() only terminates the deployment and updates receipt.
498
+ const ratePerHour = dep.cost_per_hour || 0;
499
+ let teardownCalled = false;
500
+ let attachStart = Date.now();
501
+
502
+ async function teardown(reason) {
503
+ if (teardownCalled) return;
504
+ teardownCalled = true;
505
+
506
+ const labels = {
507
+ 'max-runtime': chalk.yellow('\n ⏱ Max runtime reached — stopping job...'),
508
+ 'max-cost': chalk.yellow('\n 💰 Spend cap reached — stopping job...'),
509
+ 'heartbeat-lost': chalk.red('\n ✗ No response from machine — stopping job...'),
510
+ 'interrupted': chalk.yellow('\n Stopping job...'),
511
+ };
512
+ console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
513
+
514
+ try {
515
+ await terminateDeployment(config, dep.deployment_id);
516
+ } catch {
517
+ // terminateDeployment retries 3×; best-effort if all fail
518
+ }
519
+
520
+ const runtimeMs = Date.now() - attachStart;
521
+ const finalCost = ratePerHour * (runtimeMs / 3_600_000);
522
+ updateReceipt(rcptId, {
523
+ status: reason,
524
+ teardownStatus: 'terminated',
525
+ runtimeSeconds: Math.round(runtimeMs / 1000),
526
+ finalCost,
527
+ });
528
+ console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Est. cost: $${finalCost.toFixed(4)}`));
529
+ console.log(chalk.dim(' Job stopped. Billing ended.\n'));
530
+ }
531
+
532
+ // ── SIGINT handler — installed immediately after we have a deployment ID ───
533
+ // Covers: queued, provisioning, starting, running phases.
534
+ // Does NOT call process.exit() inside teardown; exits here after await.
535
+ let shuttingDown = false;
536
+
537
+ async function handleShutdown(reason) {
538
+ if (shuttingDown) return;
539
+ shuttingDown = true;
540
+ try {
541
+ await teardown(reason);
542
+ } catch {
543
+ console.error(formatCliError('TEARDOWN_FAILED', { deploymentId: dep.deployment_id, receiptId: rcptId }, chalk));
544
+ }
545
+ process.exit(reason === 'interrupted' ? 0 : 1);
546
+ }
547
+
548
+ process.once('SIGINT', () => { void handleShutdown('interrupted'); });
549
+
550
+ // ── Wait through startup phases ────────────────────────────────────────────
551
+ if (STARTUP_STATES.has(dep.status)) {
552
+ console.log();
553
+ dep = await waitForRunning(config, dep.deployment_id, chalk);
554
+ }
555
+
556
+ if (dep.status === 'failed') {
557
+ process.removeListener('SIGINT', handleShutdown);
558
+ console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
559
+ process.exitCode = 1;
560
+ return;
561
+ }
562
+
563
+ console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
564
+
565
+ attachStart = Date.now();
566
+ const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
567
+ chalk,
568
+ maxRuntimeMs,
569
+ maxCost,
570
+ ratePerHour,
571
+ onTeardown: teardown,
572
+ isShuttingDown: () => shuttingDown,
573
+ });
574
+
575
+ // Remove SIGINT handler — job is done (or SIGINT was handled)
576
+ process.removeListener('SIGINT', handleShutdown);
149
577
 
150
- const finalStatus = await attachToJob(config, dep.deployment_id, chalk);
578
+ // 'interrupted' = SIGINT handler is managing teardown + exit — don't duplicate
579
+ if (finalStatus === 'interrupted') return;
151
580
 
152
581
  console.log();
153
582
 
154
- if (finalStatus === 'failed') {
155
- console.error(chalk.red(`\n ✗ Job failed (${dep.deployment_id})\n`));
156
- console.error(chalk.dim(` Logs: badgr logs ${dep.deployment_id}\n`));
157
- process.exit(1);
158
- } else {
159
- console.log(chalk.green(`\n ✓ Job complete (${finalStatus})\n`));
583
+ const finalCost = ratePerHour * (runtimeMs / 3_600_000);
584
+ updateReceipt(rcptId, {
585
+ status: finalStatus,
586
+ exitCode,
587
+ runtimeSeconds: Math.round(runtimeMs / 1000),
588
+ finalCost,
589
+ failureType,
590
+ teardownStatus: (finalStatus === 'completed' || finalStatus === 'succeeded') ? 'terminated' : 'failed',
591
+ });
592
+
593
+ console.log(` ${chalk.bold('Runtime:')} ${fmtRuntime(runtimeMs)}`);
594
+ if (ratePerHour > 0) {
595
+ console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
596
+ }
597
+ if (exitCode !== null && exitCode !== undefined) {
598
+ console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
599
+ }
600
+ console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
601
+
602
+ // 'capped' = max-runtime or max-cost path; teardown message already printed
603
+ if (finalStatus === 'capped') {
604
+ process.exitCode = 1;
605
+ return;
606
+ }
607
+
608
+ if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
609
+ if (failureType === 'infrastructure') {
610
+ console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
611
+ } else {
612
+ console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
613
+ }
614
+ process.exitCode = exitCode ?? 1;
615
+ return;
616
+ }
617
+
618
+ if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
619
+ try {
620
+ await terminateDeployment(config, dep.deployment_id);
621
+ } catch { /* already stopped */ }
622
+ console.log(chalk.green(`\n ✓ Complete`));
623
+ console.log(chalk.dim(` Billing ended`));
624
+ console.log();
160
625
  }
161
626
  }