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 +1 -1
- package/src/commands/run.js +46 -61
- package/src/commands/test-run.js +11 -5
package/package.json
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -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'
|
|
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
|
|
88
|
-
const TIMEOUT_MS
|
|
89
|
-
const startMs
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
130
|
+
process.stdout.write('\n');
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
// Timed out
|
|
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
|
-
|
|
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('-', '_') :
|
|
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 (
|
|
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('
|
|
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
|
|
428
|
-
const effectiveRegion =
|
|
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
|
-
//
|
|
548
|
-
|
|
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 ──
|
|
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 ✓
|
|
609
|
+
} else if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
|
|
610
|
+
console.log(chalk.green(`\n ✓ Job complete\n`));
|
|
626
611
|
}
|
|
627
612
|
}
|
package/src/commands/test-run.js
CHANGED
|
@@ -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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
}
|