badgr-cli 1.0.31 → 1.0.32

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