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.
- package/HOW_IT_WORKS.md +4 -4
- package/README.md +57 -27
- package/package.json +14 -6
- package/src/api.js +138 -20
- package/src/badgr.js +81 -37
- package/src/commands/billing.js +93 -0
- package/src/commands/capacity.js +111 -0
- package/src/commands/down.js +26 -23
- package/src/commands/login.js +23 -7
- package/src/commands/logs.js +57 -5
- package/src/commands/models.js +25 -6
- package/src/commands/receipts.js +23 -4
- package/src/commands/run.js +551 -86
- package/src/commands/serve.js +343 -66
- package/src/commands/status.js +35 -48
- package/src/commands/test-run.js +240 -0
- package/src/commands/up.js +32 -26
- package/src/config.js +49 -4
- package/src/errors.js +299 -0
- package/src/fallback.js +219 -0
- package/src/router.js +17 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +246 -2
- package/tests/config.test.js +24 -1
- package/tests/errors.test.js +130 -0
- package/tests/launch-readiness.test.js +326 -0
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +508 -0
- package/tests/serve-lifecycle.test.js +499 -0
- package/tests/store.test.js +41 -1
package/src/commands/run.js
CHANGED
|
@@ -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
|
|
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')
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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...>
|
|
85
|
-
console.error(chalk.red(' badgr run --image my/image:latest
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
apiKey: config.apiKey,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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:
|
|
128
|
-
action:
|
|
129
|
-
deploymentId:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
// ──
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
}
|