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.
- package/HOW_IT_WORKS.md +4 -4
- package/README.md +37 -18
- package/package.json +12 -4
- 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 +19 -4
- package/src/commands/run.js +548 -90
- package/src/commands/serve.js +284 -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/fallback.js +179 -0
- package/src/router.js +16 -73
- package/src/store.js +10 -1
- package/tests/commands.test.js +234 -2
- package/tests/config.test.js +24 -1
- package/tests/router.test.js +9 -68
- package/tests/run-lifecycle.test.js +498 -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,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
|
|
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')
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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...>
|
|
85
|
-
console.error(chalk.red(' badgr run --image my/image:latest
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
});
|
|
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
|
-
|
|
122
|
-
|
|
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:
|
|
128
|
-
action:
|
|
129
|
-
deploymentId:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
// ──
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
}
|