badgr-cli 1.0.25 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,18 +81,36 @@ function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxR
81
81
  return chalk.dim(' ' + parts.join(' • '));
82
82
  }
83
83
 
84
- // Wait for status to leave 'starting' (smoke check running on backend).
84
+ // Wait for status to leave 'starting'/'queued'/'provisioning'.
85
+ // This covers image pull time — runtime limit does NOT start until this returns.
85
86
  // Returns the final dep object with status 'running' or 'failed'.
86
87
  async function waitForRunning(config, depId, chalk) {
87
- const POLL_MS = 3000;
88
- const TIMEOUT_MS = 120_000; // 2 min max for smoke check
89
- const startMs = Date.now();
90
- let dots = 0;
88
+ const POLL_MS = 3000;
89
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 min startup grace (image pull + container init)
90
+ const startMs = Date.now();
91
+ const PHASES = [
92
+ { afterMs: 0, label: ' Starting machine' },
93
+ { afterMs: 15000, label: ' Pulling image' },
94
+ { afterMs: 60000, label: ' Starting container' },
95
+ { afterMs: 180000, label: ' Running command' },
96
+ ];
97
+
98
+ let lastPhaseIdx = -1;
99
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
91
100
 
92
- process.stdout.write(chalk.dim(' Waiting for container to start'));
93
101
  const ticker = setInterval(() => {
102
+ const elapsed = Date.now() - startMs;
103
+ // Find the latest phase whose afterMs has been passed
104
+ let phaseIdx = 0;
105
+ for (let i = 0; i < PHASES.length; i++) {
106
+ if (elapsed >= PHASES[i].afterMs) phaseIdx = i;
107
+ }
108
+ if (phaseIdx !== lastPhaseIdx) {
109
+ process.stdout.write('\r\x1b[2K');
110
+ process.stdout.write(chalk.dim(PHASES[phaseIdx].label));
111
+ lastPhaseIdx = phaseIdx;
112
+ }
94
113
  process.stdout.write('.');
95
- dots++;
96
114
  }, 1000);
97
115
 
98
116
  try {
@@ -102,23 +120,22 @@ async function waitForRunning(config, depId, chalk) {
102
120
  apiKey: config.apiKey,
103
121
  baseUrl: config.baseUrl,
104
122
  });
105
- if (dep.status !== 'starting') {
106
- clearInterval(ticker);
123
+ if (!STARTUP_STATES.has(dep.status)) {
107
124
  process.stdout.write('\n');
108
125
  return dep;
109
126
  }
110
127
  }
111
128
  } finally {
112
129
  clearInterval(ticker);
113
- if (dots > 0) process.stdout.write('\n');
130
+ process.stdout.write('\n');
114
131
  }
115
132
 
116
- // Timed out waiting — return whatever we have
133
+ // Timed out — return last known state
117
134
  return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
118
135
  }
119
136
 
120
137
  async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
121
- const TERMINAL = new Set(['stopped', 'failed', 'completed']);
138
+ const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
122
139
  const POLL_MS = 4000;
123
140
  let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
124
141
  let lastStatus = '';
@@ -342,7 +359,11 @@ export async function runCommand(config, args, chalk) {
342
359
  requireApiKey(config);
343
360
 
344
361
  const command = positional.length > 0 ? positional : undefined;
345
- const image = flags.image || (command ? 'python:3.11-slim' : undefined);
362
+ // Use alpine for smoke tests (7MB vs 50MB — much faster pull), slim for general workloads
363
+ const inferredImage = (command && inferWorkload(command) === 'smoke_test')
364
+ ? 'python:3.11-alpine'
365
+ : 'python:3.11-slim';
366
+ const image = flags.image || (command ? inferredImage : undefined);
346
367
  const detach = flags.detach || false;
347
368
  const fallbackMode = flags.noFallback ? 'none' : (flags.fallback || 'closest');
348
369
  const maxRuntimeMs = flags.maxRuntime ? flags.maxRuntime * 60 * 1000 : null;
@@ -354,54 +375,18 @@ export async function runCommand(config, args, chalk) {
354
375
  : (flags.tier || '1');
355
376
 
356
377
  // ── Auto GPU selection (no --gpu specified) ────────────────────────────────
357
- let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : null;
358
- let autoRegion = null;
378
+ let gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : 'auto';
359
379
 
360
380
  // Infer workload from the command so the backend can apply the correct VRAM floor.
361
381
  const workload = command ? inferWorkload(command) : 'general';
362
382
  const workloadLabel = WORKLOAD_LABELS[workload] || 'GPU job';
363
383
 
364
- if (!gpu) {
384
+ if (gpu === 'auto') {
365
385
  console.log(chalk.bold(`\n⚡ Running ${workloadLabel}\n`));
366
386
  if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
367
387
  if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
368
388
  if (effectiveTier === '2') console.log(` ${chalk.dim('(tier 2 — marketplace routing)')}`)
369
389
  console.log();
370
- process.stdout.write(chalk.dim(' Finding best GPU...'));
371
-
372
- let best;
373
- try {
374
- best = await findAutoGpu(config, chalk, effectiveTier, workload);
375
- } catch (err) {
376
- process.stdout.write('\n');
377
- console.error(chalk.red(`\n ✗ Could not find GPU capacity: ${err.message}\n`));
378
- process.exit(1);
379
- }
380
-
381
- process.stdout.write('\n');
382
-
383
- if (!best) {
384
- console.error(chalk.red('\n ✗ No GPU capacity available right now.\n'));
385
- console.error(chalk.dim(' Run `badgr capacity` for details, or try again in a few minutes.'));
386
- process.exit(1);
387
- }
388
-
389
- const vramNote = best.min_vram_gb ? chalk.dim(` (${best.min_vram_gb}GB VRAM)`) : '';
390
- console.log(`\n ${chalk.bold('Selected:')} ${chalk.cyan(best.gpu)} in ${best.region} — ${chalk.green('$' + best.price.toFixed(2) + '/hr')}${vramNote}`);
391
- console.log();
392
-
393
- if (effectiveTier === '2' && process.stdin.isTTY) {
394
- // Budget mode: confirm because the user is being routed to a less reliable provider.
395
- const answer = await askConfirm(` Press ${chalk.bold('Enter')} to run on tier 2 (marketplace), or ${chalk.bold('q')} to cancel: `);
396
- if (answer.toLowerCase() === 'q') {
397
- console.log(chalk.dim('\n Cancelled.\n'));
398
- process.exit(0);
399
- }
400
- }
401
-
402
- gpu = best.gpu;
403
- autoRegion = best.region;
404
- console.log();
405
390
  } else {
406
391
  // Specific GPU requested — show header
407
392
  console.log(chalk.bold('\n⚡ Running GPU job\n'));
@@ -419,15 +404,13 @@ export async function runCommand(config, args, chalk) {
419
404
  }
420
405
  }
421
406
 
422
- console.log(chalk.dim(' Finding best available GPU capacity...'));
407
+ console.log(chalk.dim(' Starting machine...'));
423
408
  if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
424
409
  console.log(chalk.dim(` API: ${config.baseUrl}`));
425
410
  }
426
411
 
427
- function buildBody(gpuOverride, regionOverride) {
428
- const effectiveRegion = regionOverride
429
- ?? autoRegion
430
- ?? (flags.region ? flags.region.toUpperCase() : undefined);
412
+ function buildBody(gpuOverride) {
413
+ const effectiveRegion = flags.region ? flags.region.toUpperCase() : undefined;
431
414
  return {
432
415
  command,
433
416
  image,
@@ -544,8 +527,10 @@ export async function runCommand(config, args, chalk) {
544
527
  return;
545
528
  }
546
529
 
547
- // If the backend is still running the smoke check, wait for it to finish.
548
- if (dep.status === 'starting') {
530
+ // Wait through startup phases (queued provisioning starting running).
531
+ // The max-runtime clock does NOT start until this returns — image pull time is free.
532
+ const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
533
+ if (STARTUP_STATES.has(dep.status)) {
549
534
  console.log();
550
535
  dep = await waitForRunning(config, dep.deployment_id, chalk);
551
536
  }
@@ -559,7 +544,7 @@ export async function runCommand(config, args, chalk) {
559
544
  }
560
545
 
561
546
  const ratePerHour = dep.cost_per_hour || 0;
562
- console.log(chalk.dim('\n ── Live status (Ctrl+C to stop) ─────────────────────────────────\n'));
547
+ console.log(chalk.dim('\n ── Running command (Ctrl+C to stop) ────────────────────────────\n'));
563
548
 
564
549
  async function teardown(reason) {
565
550
  const labels = {
@@ -621,7 +606,7 @@ export async function runCommand(config, args, chalk) {
621
606
  }
622
607
  console.log();
623
608
  process.exit(exitCode ?? 1);
624
- } else if (finalStatus === 'completed' && (exitCode === 0 || exitCode === null)) {
625
- console.log(chalk.green(`\n ✓ Complete\n`));
609
+ } else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
610
+ console.log(chalk.green(`\n ✓ Job complete\n`));
626
611
  }
627
612
  }
@@ -6,7 +6,9 @@ import { addReceipt, generateReceiptId } from '../store.js';
6
6
  const TEST_MAX_PRICE = 1.50;
7
7
  const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
8
8
  const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
9
- const TEST_IMAGE = 'python:3.11-slim';
9
+ // Use alpine (7MB) instead of slim (50MB) — dramatically faster image pull for smoke tests.
10
+ // Falls back gracefully: alpine has python3 and supports the test command identically.
11
+ const TEST_IMAGE = 'python:3.11-alpine';
10
12
  const EXPECTED_OUTPUT = 'hello from badgr';
11
13
 
12
14
  // --provider flag resolves to a backend tier value.
@@ -106,7 +108,7 @@ export async function testCommand(config, args, chalk) {
106
108
  const baseBody = {
107
109
  command: TEST_COMMAND,
108
110
  image: TEST_IMAGE,
109
- gpu: 'RTX_3080',
111
+ gpu: 'auto',
110
112
  max_price_per_hour: TEST_MAX_PRICE,
111
113
  };
112
114
  try {
@@ -190,18 +192,22 @@ export async function testCommand(config, args, chalk) {
190
192
  action: 'badgr test',
191
193
  deploymentId: depId,
192
194
  gpu: dep.gpu_type,
193
- status: 'test_complete',
195
+ status: gotOutput ? 'test_passed' : 'test_failed',
194
196
  createdAt: new Date().toISOString(),
195
197
  });
196
198
  step(chalk, true, 'Receipt created', rcptId);
197
199
 
198
200
  // ── Summary ──────────────────────────────────────────────────────────────
199
201
  console.log();
200
- const passed = stopped;
202
+ const passed = stopped && gotOutput;
201
203
  if (passed) {
202
204
  console.log(chalk.green(chalk.bold(' ✓ Test passed\n')));
203
205
  } else {
204
- console.log(chalk.red(chalk.bold(' ✗ Test failed\n')));
206
+ if (!gotOutput) {
207
+ console.error(chalk.red(' Test failed — expected output not found in logs\n'));
208
+ } else {
209
+ console.error(chalk.red(' Test failed — could not stop billing\n'));
210
+ }
205
211
  process.exit(1);
206
212
  }
207
213
  }