badgr-cli 1.0.38 → 1.0.39
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/README.md +1 -1
- package/package.json +5 -7
- package/src/badgr.js +8 -0
- package/src/catalog.js +193 -1
- package/src/commands/comfyui.js +1 -1
- package/src/commands/receipts.js +2 -1
- package/src/commands/run.js +111 -25
- package/src/commands/test-run.js +4 -4
- package/src/commands/workload.js +197 -0
- package/src/commands/workspace.js +136 -0
- package/tests/commands.test.js +48 -0
- package/tests/run-lifecycle.test.js +55 -18
- package/tests/template.test.js +6 -4
- package/tests/workload-rerun.test.js +56 -0
- package/tests/workload-templates.test.js +1 -1
- package/tests/workload-workspace-paths.test.js +46 -0
package/README.md
CHANGED
|
@@ -275,7 +275,7 @@ Additional GPU types may be routable depending on current capacity — check wit
|
|
|
275
275
|
|
|
276
276
|
Pricing is confirmed before provisioning. Use `--dry-run` to see pricing before committing.
|
|
277
277
|
|
|
278
|
-
Full GPU support details: see [
|
|
278
|
+
Full GPU support details: see [NOTES.md](../../NOTES.md#gpu-support) in the repo root.
|
|
279
279
|
|
|
280
280
|
---
|
|
281
281
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "badgr-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Badgr, run or serve GPU workloads from one command",
|
|
3
|
+
"version": "1.0.39",
|
|
4
|
+
"description": "DEPRECATED: Use badgr-agent CLI instead. Badgr, run or serve GPU workloads from one command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"badgr": "src/badgr.js"
|
|
@@ -12,23 +12,21 @@
|
|
|
12
12
|
"test:watch": "vitest"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"
|
|
16
|
-
"chalk": "^5.3.0"
|
|
15
|
+
"badgr-agent": "1.0.0"
|
|
17
16
|
},
|
|
18
17
|
"devDependencies": {
|
|
19
18
|
"vitest": "^4.1.8"
|
|
20
19
|
},
|
|
21
20
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
21
|
+
"node": ">=20.10.0"
|
|
23
22
|
},
|
|
24
23
|
"keywords": [
|
|
25
24
|
"gpu",
|
|
26
25
|
"cli",
|
|
27
26
|
"ai",
|
|
28
27
|
"compute",
|
|
29
|
-
"modal",
|
|
30
28
|
"gateway",
|
|
31
29
|
"openai"
|
|
32
30
|
],
|
|
33
|
-
"license": "
|
|
31
|
+
"license": "Apache-2.0"
|
|
34
32
|
}
|
package/src/badgr.js
CHANGED
|
@@ -18,6 +18,8 @@ import { trainCommand } from './commands/train.js';
|
|
|
18
18
|
import { transcribeCommand } from './commands/transcribe.js';
|
|
19
19
|
import { embedCommand } from './commands/embed.js';
|
|
20
20
|
import { templateCommand } from './commands/template.js';
|
|
21
|
+
import { workloadCommand } from './commands/workload.js';
|
|
22
|
+
import { workspaceCommand } from './commands/workspace.js';
|
|
21
23
|
|
|
22
24
|
const HELP = `
|
|
23
25
|
${chalk.bold('badgr')} — run or serve GPU workloads from one command
|
|
@@ -33,6 +35,10 @@ ${chalk.bold('COMMANDS')}
|
|
|
33
35
|
${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
|
|
34
36
|
${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
|
|
35
37
|
${chalk.cyan('badgr billing')} Show balance and add funds
|
|
38
|
+
${chalk.cyan('badgr workload list')} List saved workloads
|
|
39
|
+
${chalk.cyan('badgr workload run <name>')} Rerun a saved workload
|
|
40
|
+
${chalk.cyan('badgr workspace list')} List workspace trackers (job history + cost per named context)
|
|
41
|
+
${chalk.cyan('badgr workspace create')} Create a workspace tracker (link jobs to a named storage path)
|
|
36
42
|
|
|
37
43
|
${chalk.bold('SHORTCUTS')} ${chalk.dim('(wrappers around run / serve for common workloads)')}
|
|
38
44
|
${chalk.cyan('badgr comfyui run <workflow.json>')} Launch ComfyUI, return endpoint URL
|
|
@@ -147,6 +153,8 @@ async function main() {
|
|
|
147
153
|
case 'transcribe': return transcribeCommand(config, rest, chalk);
|
|
148
154
|
case 'embed': return embedCommand(config, rest, chalk);
|
|
149
155
|
case 'template': return templateCommand(config, rest, chalk);
|
|
156
|
+
case 'workload': return workloadCommand(config, rest, chalk);
|
|
157
|
+
case 'workspace': return workspaceCommand(config, rest, chalk);
|
|
150
158
|
// legacy aliases kept for compatibility
|
|
151
159
|
case 'up': return upCommand(config, rest, chalk);
|
|
152
160
|
case 'config': {
|
package/src/catalog.js
CHANGED
|
@@ -9,7 +9,7 @@ export const TEMPLATES = [
|
|
|
9
9
|
title: 'ComfyUI',
|
|
10
10
|
description: 'Stable Diffusion image generation with ComfyUI node editor',
|
|
11
11
|
type: 'endpoint',
|
|
12
|
-
image: 'yanwk/comfyui-boot:
|
|
12
|
+
image: process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak',
|
|
13
13
|
gpu: 'RTX_4090',
|
|
14
14
|
gpu_count: 1,
|
|
15
15
|
port: 8188,
|
|
@@ -222,6 +222,198 @@ export const TEMPLATES = [
|
|
|
222
222
|
'HF_TOKEN required for Llama, Gemma, and other gated models.',
|
|
223
223
|
],
|
|
224
224
|
},
|
|
225
|
+
{
|
|
226
|
+
name: 'auto1111',
|
|
227
|
+
title: 'AUTOMATIC1111',
|
|
228
|
+
description: 'Stable Diffusion web UI — the largest SD ecosystem with ControlNet, LoRA, img2img, inpainting, and upscaling',
|
|
229
|
+
type: 'endpoint',
|
|
230
|
+
image: 'aidockorg/stable-diffusion-webui-cuda:latest',
|
|
231
|
+
gpu: 'RTX_4090',
|
|
232
|
+
gpu_count: 1,
|
|
233
|
+
port: 7860,
|
|
234
|
+
health_path: '/',
|
|
235
|
+
min_vram_gb: 6,
|
|
236
|
+
env: {
|
|
237
|
+
WEBUI_FLAGS: '--xformers',
|
|
238
|
+
},
|
|
239
|
+
notes: [
|
|
240
|
+
'Web UI at the returned endpoint URL (port 7860).',
|
|
241
|
+
'Place model checkpoints under /opt/dl-ui/repositories/stable-diffusion-webui/models/Stable-diffusion/.',
|
|
242
|
+
'Override WEBUI_FLAGS to pass extra launch args (e.g. --medvram for lower VRAM).',
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'forge',
|
|
247
|
+
title: 'Stable Diffusion WebUI Forge',
|
|
248
|
+
description: 'Optimised fork of A1111 with GPU memory improvements — runs models that OOM on standard A1111',
|
|
249
|
+
type: 'endpoint',
|
|
250
|
+
image: 'aidockorg/stable-diffusion-webui-forge-cuda:latest',
|
|
251
|
+
gpu: 'RTX_4090',
|
|
252
|
+
gpu_count: 1,
|
|
253
|
+
port: 7860,
|
|
254
|
+
health_path: '/',
|
|
255
|
+
min_vram_gb: 4,
|
|
256
|
+
env: {
|
|
257
|
+
WEBUI_FLAGS: '--xformers',
|
|
258
|
+
},
|
|
259
|
+
notes: [
|
|
260
|
+
'Web UI at the returned endpoint URL (port 7860).',
|
|
261
|
+
'Forge uses less VRAM than A1111 for the same model — good for 8–12 GB cards.',
|
|
262
|
+
'Compatible with most A1111 extensions.',
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'nerfstudio',
|
|
267
|
+
title: 'Nerfstudio',
|
|
268
|
+
description: 'NeRF and 3D Gaussian Splatting training and reconstruction from image captures',
|
|
269
|
+
type: 'job',
|
|
270
|
+
image: 'ghcr.io/nerfstudio-project/nerfstudio:latest',
|
|
271
|
+
gpu: 'RTX_4090',
|
|
272
|
+
gpu_count: 1,
|
|
273
|
+
min_vram_gb: 16,
|
|
274
|
+
env: {
|
|
275
|
+
METHOD: 'nerfacto',
|
|
276
|
+
},
|
|
277
|
+
notes: [
|
|
278
|
+
'Mount your images to /workspace/data and your output dir to /workspace/outputs.',
|
|
279
|
+
'Override METHOD to switch between nerfacto, splatfacto, instant-ngp, etc.',
|
|
280
|
+
'Training a typical scene takes 15–45 min depending on method and image count.',
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'openfold',
|
|
285
|
+
title: 'OpenFold',
|
|
286
|
+
description: 'Trainable, memory-efficient GPU-friendly PyTorch reproduction of AlphaFold 2 for protein structure prediction',
|
|
287
|
+
type: 'job',
|
|
288
|
+
image: 'nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04',
|
|
289
|
+
gpu: 'A100',
|
|
290
|
+
gpu_count: 1,
|
|
291
|
+
min_vram_gb: 40,
|
|
292
|
+
env: {
|
|
293
|
+
OPENFOLD_REPO: 'https://github.com/aqlaboratory/openfold',
|
|
294
|
+
},
|
|
295
|
+
notes: [
|
|
296
|
+
'No pre-built public image exists — this uses a CUDA base; clone and install OpenFold at boot.',
|
|
297
|
+
'See https://github.com/aqlaboratory/openfold for full install steps.',
|
|
298
|
+
'Full inference on a single sequence: ~5–20 min on an A100.',
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'blender-render',
|
|
303
|
+
title: 'Blender GPU Render',
|
|
304
|
+
description: 'Headless Blender GPU rendering via CUDA — render .blend files to image sequences or video',
|
|
305
|
+
type: 'job',
|
|
306
|
+
image: 'blenderkit/headless-blender:blender-4.4',
|
|
307
|
+
gpu: 'RTX_4090',
|
|
308
|
+
gpu_count: 1,
|
|
309
|
+
min_vram_gb: 8,
|
|
310
|
+
env: {
|
|
311
|
+
BLEND_FILE: '/workspace/scene.blend',
|
|
312
|
+
FRAME_START: '1',
|
|
313
|
+
FRAME_END: '250',
|
|
314
|
+
},
|
|
315
|
+
notes: [
|
|
316
|
+
'Mount your .blend file to /workspace/scene.blend.',
|
|
317
|
+
'Set FRAME_START and FRAME_END to the frame range to render.',
|
|
318
|
+
'Output frames are written to /workspace/output/ by default.',
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'openmm',
|
|
323
|
+
title: 'OpenMM',
|
|
324
|
+
description: 'GPU-accelerated molecular dynamics simulation using the OpenMM toolkit',
|
|
325
|
+
type: 'job',
|
|
326
|
+
image: 'saladtechnologies/openmm:latest',
|
|
327
|
+
gpu: 'RTX_4090',
|
|
328
|
+
gpu_count: 1,
|
|
329
|
+
min_vram_gb: 8,
|
|
330
|
+
env: {
|
|
331
|
+
SIMULATION_SCRIPT: '/workspace/simulate.py',
|
|
332
|
+
},
|
|
333
|
+
notes: [
|
|
334
|
+
'Mount your Python simulation script to /workspace/simulate.py.',
|
|
335
|
+
'OpenMM auto-selects CUDA platform when a GPU is present.',
|
|
336
|
+
'Typical MD simulation runs: minutes to hours depending on system size and steps.',
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'gromacs',
|
|
341
|
+
title: 'GROMACS',
|
|
342
|
+
description: 'High-performance molecular dynamics package with GPU-accelerated bonded and non-bonded calculations',
|
|
343
|
+
type: 'job',
|
|
344
|
+
image: 'scientiflow/gromacs-gpu:2024.3',
|
|
345
|
+
gpu: 'RTX_4090',
|
|
346
|
+
gpu_count: 1,
|
|
347
|
+
min_vram_gb: 8,
|
|
348
|
+
env: {
|
|
349
|
+
GMX_GPU_DD_COMMS: 'true',
|
|
350
|
+
GMX_GPU_PME_PP_COMMS: 'true',
|
|
351
|
+
},
|
|
352
|
+
notes: [
|
|
353
|
+
'Mount your .tpr input file and output directory to /workspace.',
|
|
354
|
+
'GROMACS auto-detects CUDA GPU — no extra flags needed.',
|
|
355
|
+
'For multi-GPU: increase gpu_count and pass -ntmpi / -ntomp flags via command override.',
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: 'lammps',
|
|
360
|
+
title: 'LAMMPS',
|
|
361
|
+
description: 'Large-scale Atomic/Molecular Massively Parallel Simulator with GPU acceleration via Kokkos/CUDA',
|
|
362
|
+
type: 'job',
|
|
363
|
+
image: 'nvcr.io/hpc/lammps:release-29Sep2021',
|
|
364
|
+
gpu: 'RTX_4090',
|
|
365
|
+
gpu_count: 1,
|
|
366
|
+
min_vram_gb: 8,
|
|
367
|
+
env: {
|
|
368
|
+
LAMMPS_INPUT: '/workspace/in.lammps',
|
|
369
|
+
},
|
|
370
|
+
notes: [
|
|
371
|
+
'Mount your LAMMPS input script to /workspace/in.lammps.',
|
|
372
|
+
'GPU acceleration uses the Kokkos or GPU package — enable via -pk gpu 1 in your input.',
|
|
373
|
+
'Simulation runtime scales with system size; use --max-runtime to cap spend.',
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: 'diffusers',
|
|
378
|
+
title: 'Hugging Face Diffusers',
|
|
379
|
+
description: 'State-of-the-art diffusion model library for image, video, and audio generation',
|
|
380
|
+
type: 'job',
|
|
381
|
+
image: 'diffusers/diffusers-pytorch-cuda:latest',
|
|
382
|
+
gpu: 'RTX_4090',
|
|
383
|
+
gpu_count: 1,
|
|
384
|
+
min_vram_gb: 16,
|
|
385
|
+
env: {
|
|
386
|
+
HF_TOKEN: '<for-gated-models>',
|
|
387
|
+
MODEL_ID: 'stabilityai/stable-diffusion-xl-base-1.0',
|
|
388
|
+
SCRIPT: '/workspace/run.py',
|
|
389
|
+
},
|
|
390
|
+
notes: [
|
|
391
|
+
'Mount your generation or training script to /workspace/run.py.',
|
|
392
|
+
'HF_TOKEN required for gated models.',
|
|
393
|
+
'Supports SDXL, FLUX, Stable Diffusion 3, ControlNet, IP-Adapter, and more.',
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: 'torchtune',
|
|
398
|
+
title: 'torchtune',
|
|
399
|
+
description: 'PyTorch-native post-training library for LLM fine-tuning via config-driven YAML recipes',
|
|
400
|
+
type: 'job',
|
|
401
|
+
image: 'pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel',
|
|
402
|
+
gpu: 'A100',
|
|
403
|
+
gpu_count: 1,
|
|
404
|
+
min_vram_gb: 40,
|
|
405
|
+
env: {
|
|
406
|
+
HF_TOKEN: '<for-gated-models>',
|
|
407
|
+
RECIPE: 'lora_finetune_single_device',
|
|
408
|
+
CONFIG: '/workspace/config.yaml',
|
|
409
|
+
},
|
|
410
|
+
notes: [
|
|
411
|
+
'Install torchtune at boot: pip install torchtune.',
|
|
412
|
+
'Mount your YAML recipe config to /workspace/config.yaml.',
|
|
413
|
+
'HF_TOKEN required for Llama and other gated base models.',
|
|
414
|
+
'Supports LoRA, QLoRA, DPO, full fine-tune, and quantisation-aware training.',
|
|
415
|
+
],
|
|
416
|
+
},
|
|
225
417
|
];
|
|
226
418
|
|
|
227
419
|
export const TEMPLATE_MAP = Object.fromEntries(TEMPLATES.map(t => [t.name, t]));
|
package/src/commands/comfyui.js
CHANGED
|
@@ -12,7 +12,7 @@ import { addDeployment, addReceipt, updateReceipt, generateReceiptId } from '../
|
|
|
12
12
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
13
13
|
import { formatCliError } from '../errors.js';
|
|
14
14
|
|
|
15
|
-
const COMFYUI_IMAGE = 'yanwk/comfyui-boot:
|
|
15
|
+
const COMFYUI_IMAGE = process.env.COMFYUI_IMAGE || 'yanwk/comfyui-boot:cu126-megapak';
|
|
16
16
|
const HEALTH_PATH = '/system_stats';
|
|
17
17
|
const WAIT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — model downloads on first boot
|
|
18
18
|
const MAX_WORKFLOW_B = 1 * 1024 * 1024; // 1 MB workflow limit
|
package/src/commands/receipts.js
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -14,32 +14,42 @@ import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../cat
|
|
|
14
14
|
export function parseRunArgs(args) {
|
|
15
15
|
const flags = {};
|
|
16
16
|
const positional = [];
|
|
17
|
+
|
|
18
|
+
// Split at -- so badgr flags and the user command don't collide.
|
|
19
|
+
// Everything before -- is parsed for flags; everything after becomes commandArgv.
|
|
20
|
+
const sepIdx = args.indexOf('--');
|
|
21
|
+
const flagArgs = sepIdx === -1 ? args : args.slice(0, sepIdx);
|
|
22
|
+
const commandArgv = sepIdx === -1 ? null : args.slice(sepIdx + 1);
|
|
23
|
+
|
|
17
24
|
let i = 0;
|
|
18
|
-
while (i <
|
|
19
|
-
if (
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
22
|
-
if (
|
|
23
|
-
if (
|
|
24
|
-
if (
|
|
25
|
-
if (
|
|
26
|
-
if (
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
32
|
-
if (
|
|
33
|
-
if (
|
|
34
|
-
if (
|
|
35
|
-
|
|
25
|
+
while (i < flagArgs.length) {
|
|
26
|
+
if (flagArgs[i] === '--gpu') { flags.gpu = flagArgs[++i]; i++; continue; }
|
|
27
|
+
if (flagArgs[i] === '--image') { flags.image = flagArgs[++i]; i++; continue; }
|
|
28
|
+
if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
|
|
29
|
+
if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
|
|
30
|
+
if (flagArgs[i] === '--tier') { flags.tier = flagArgs[++i]; i++; continue; }
|
|
31
|
+
if (flagArgs[i] === '--max-price') { flags.maxPrice = parseFloat(flagArgs[++i]); i++; continue; }
|
|
32
|
+
if (flagArgs[i] === '--name') { flags.name = flagArgs[++i]; i++; continue; }
|
|
33
|
+
if (flagArgs[i] === '--detach') { flags.detach = true; i++; continue; }
|
|
34
|
+
if (flagArgs[i] === '--fallback') { flags.fallback = flagArgs[++i]; i++; continue; }
|
|
35
|
+
if (flagArgs[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
|
|
36
|
+
if (flagArgs[i] === '--strict-capacity') { flags.noFallback = true; i++; continue; }
|
|
37
|
+
if (flagArgs[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
|
|
38
|
+
if (flagArgs[i] === '--max-runtime') { flags.maxRuntime = parseFloat(flagArgs[++i]); i++; continue; }
|
|
39
|
+
if (flagArgs[i] === '--max-cost') { flags.maxCost = parseFloat(flagArgs[++i]); i++; continue; }
|
|
40
|
+
if (flagArgs[i] === '--min-vram') { flags.minVram = parseFloat(flagArgs[++i]); i++; continue; }
|
|
41
|
+
if (flagArgs[i] === '--dry-run') { flags.dryRun = true; i++; continue; }
|
|
42
|
+
if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
|
|
43
|
+
if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
|
|
44
|
+
if (flagArgs[i] === '--env') {
|
|
45
|
+
const kv = flagArgs[++i]; i++;
|
|
36
46
|
if (!flags.env) flags.env = [];
|
|
37
47
|
flags.env.push(kv);
|
|
38
48
|
continue;
|
|
39
49
|
}
|
|
40
|
-
positional.push(
|
|
50
|
+
positional.push(flagArgs[i++]);
|
|
41
51
|
}
|
|
42
|
-
return { flags, positional };
|
|
52
|
+
return { flags, positional, commandArgv };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
function parseEnvFlag(envList) {
|
|
@@ -332,6 +342,7 @@ const _KNOWN_RUN_FLAGS = new Set([
|
|
|
332
342
|
'--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
|
|
333
343
|
'--detach', '--fallback', '--no-fallback', '--strict-capacity',
|
|
334
344
|
'--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
|
|
345
|
+
'--dry-run',
|
|
335
346
|
]);
|
|
336
347
|
|
|
337
348
|
export async function runCommand(config, args, chalk) {
|
|
@@ -357,7 +368,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
357
368
|
return runCommand(config, expandedArgs, chalk);
|
|
358
369
|
}
|
|
359
370
|
|
|
360
|
-
const { flags, positional } = parseRunArgs(args);
|
|
371
|
+
const { flags, positional, commandArgv } = parseRunArgs(args);
|
|
361
372
|
|
|
362
373
|
// Detect flags that ended up in the command because of broken shell line continuation
|
|
363
374
|
// (e.g. `\ ` with trailing space instead of `\<newline>`).
|
|
@@ -376,12 +387,17 @@ export async function runCommand(config, args, chalk) {
|
|
|
376
387
|
return;
|
|
377
388
|
}
|
|
378
389
|
|
|
379
|
-
if (positional.length === 0 && !flags.image) {
|
|
380
|
-
console.error(chalk.red('Usage: badgr run <command...>'));
|
|
390
|
+
if (positional.length === 0 && commandArgv === null && !flags.image) {
|
|
391
|
+
console.error(chalk.red('Usage: badgr run --gpu <GPU> --image <image> --max-cost <n> -- <command...>'));
|
|
381
392
|
console.error(chalk.red(' badgr run --image my/image:latest'));
|
|
382
393
|
return;
|
|
383
394
|
}
|
|
384
|
-
|
|
395
|
+
if (commandArgv !== null && commandArgv.length === 0 && !flags.image) {
|
|
396
|
+
console.error(chalk.red(' ✗ No command after --. Provide a command or --image.'));
|
|
397
|
+
console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --max-cost 1 -- node script.js'));
|
|
398
|
+
process.exitCode = 1;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
385
401
|
requireApiKey(config);
|
|
386
402
|
|
|
387
403
|
// ── Validate flags early ───────────────────────────────────────────────────
|
|
@@ -405,7 +421,18 @@ export async function runCommand(config, args, chalk) {
|
|
|
405
421
|
process.exitCode = 1;
|
|
406
422
|
return;
|
|
407
423
|
}
|
|
408
|
-
|
|
424
|
+
|
|
425
|
+
if (!flags.maxCost && !flags.dryRun) {
|
|
426
|
+
console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
|
|
427
|
+
console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
|
|
428
|
+
process.exitCode = 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// commandArgv is set when -- separator was used; fall back to positional for legacy syntax.
|
|
433
|
+
const command = commandArgv !== null
|
|
434
|
+
? (commandArgv.length > 0 ? commandArgv : undefined)
|
|
435
|
+
: (positional.length > 0 ? positional : undefined);
|
|
409
436
|
const cmdStr = command ? command.join(' ') : '';
|
|
410
437
|
const isSmoke = cmdStr.length < 80 && /print\s*\(|['"]hello/i.test(cmdStr);
|
|
411
438
|
const inferredImage = isSmoke ? 'python:3.11-alpine' : 'python:3.11-slim';
|
|
@@ -424,6 +451,19 @@ export async function runCommand(config, args, chalk) {
|
|
|
424
451
|
|
|
425
452
|
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
|
|
426
453
|
|
|
454
|
+
if (flags.dryRun) {
|
|
455
|
+
console.log(chalk.bold('\n⚡ Dry run — no GPU will be provisioned\n'));
|
|
456
|
+
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
457
|
+
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
458
|
+
console.log(` ${chalk.bold('GPU:')} ${gpu || chalk.dim('auto')}`);
|
|
459
|
+
console.log(` ${chalk.bold('Max runtime:')} ${effectiveMaxRuntime}min`);
|
|
460
|
+
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
|
|
461
|
+
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
|
|
462
|
+
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
463
|
+
console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
427
467
|
console.log(chalk.bold('\n⚡ Running GPU job\n'));
|
|
428
468
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
429
469
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
@@ -441,6 +481,23 @@ export async function runCommand(config, args, chalk) {
|
|
|
441
481
|
console.log();
|
|
442
482
|
|
|
443
483
|
|
|
484
|
+
// Resolve --workspace name → ws_… ID before submitting
|
|
485
|
+
let resolvedWorkspaceId = flags.workspace ?? null;
|
|
486
|
+
if (resolvedWorkspaceId && !resolvedWorkspaceId.startsWith('ws_')) {
|
|
487
|
+
try {
|
|
488
|
+
const wsData = await callApi(`/workspaces?limit=100`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
489
|
+
const match = (wsData?.workspaces ?? []).find(w => w.name === resolvedWorkspaceId);
|
|
490
|
+
if (!match) {
|
|
491
|
+
console.error(chalk.red(`Workspace not found: ${resolvedWorkspaceId}`));
|
|
492
|
+
process.exitCode = 1; return;
|
|
493
|
+
}
|
|
494
|
+
resolvedWorkspaceId = match.workspace_id;
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.error(chalk.red(`Could not resolve workspace: ${err.message}`));
|
|
497
|
+
process.exitCode = 1; return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
444
501
|
console.log(chalk.dim(' Finding suitable capacity...'));
|
|
445
502
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
446
503
|
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
@@ -461,6 +518,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
461
518
|
...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
|
|
462
519
|
max_runtime_seconds: effectiveMaxRuntime * 60,
|
|
463
520
|
...(maxCost ? { max_cost_usd: maxCost } : {}),
|
|
521
|
+
...(resolvedWorkspaceId ? { workspace_id: resolvedWorkspaceId } : {}),
|
|
464
522
|
};
|
|
465
523
|
}
|
|
466
524
|
|
|
@@ -651,6 +709,34 @@ export async function runCommand(config, args, chalk) {
|
|
|
651
709
|
} catch { /* already stopped */ }
|
|
652
710
|
console.log(chalk.green(`\n ✓ Complete`));
|
|
653
711
|
console.log(chalk.dim(` Billing ended`));
|
|
712
|
+
|
|
713
|
+
if (flags.save && config.apiKey) {
|
|
714
|
+
try {
|
|
715
|
+
const saved = await callApi('/workloads', {
|
|
716
|
+
method: 'POST',
|
|
717
|
+
apiKey: config.apiKey,
|
|
718
|
+
baseUrl: config.baseUrl,
|
|
719
|
+
body: {
|
|
720
|
+
name: flags.save,
|
|
721
|
+
job_type: 'custom.run',
|
|
722
|
+
config: {
|
|
723
|
+
command: command || [],
|
|
724
|
+
image: image || 'python:3.11-slim',
|
|
725
|
+
gpu: gpu || 'auto',
|
|
726
|
+
...(flags.minVram ? { min_vram: flags.minVram } : {}),
|
|
727
|
+
gpu_count: flags.count || 1,
|
|
728
|
+
...(flags.maxPrice ? { max_price_per_hour: flags.maxPrice } : {}),
|
|
729
|
+
...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
|
|
730
|
+
},
|
|
731
|
+
default_max_cost: maxCost || 10.0,
|
|
732
|
+
default_max_runtime_minutes: effectiveMaxRuntime,
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
console.log(chalk.cyan(` Saved as workload: ${saved.name} (${saved.workload_id})`));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
console.log(chalk.yellow(` Could not save workload: ${err.message}`));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
654
740
|
console.log();
|
|
655
741
|
}
|
|
656
742
|
}
|
package/src/commands/test-run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { requireApiKey } from '../config.js';
|
|
|
2
2
|
import { callApi, terminateDeployment } from '../api.js';
|
|
3
3
|
import { addReceipt, generateReceiptId } from '../store.js';
|
|
4
4
|
|
|
5
|
-
// max $0.80/hr × 2 min ≈ $0.027 total spend cap
|
|
5
|
+
// max $0.80/hr × 2 min ≈ $0.027 total spend cap
|
|
6
6
|
const TEST_MAX_PRICE = 0.80;
|
|
7
7
|
const TEST_MAX_RUNTIME_MS = 2 * 60 * 1000;
|
|
8
8
|
const TEST_COMMAND = ['python', '-c', "print('hello from badgr')"];
|
|
@@ -12,8 +12,8 @@ const TEST_IMAGE = 'python:3.11-alpine';
|
|
|
12
12
|
const EXPECTED_OUTPUT = 'hello from badgr';
|
|
13
13
|
|
|
14
14
|
// --provider flag resolves to a backend tier value.
|
|
15
|
-
// 'tier1' → managed routing (default), 'tier2' → marketplace routing
|
|
16
|
-
const PROVIDER_TO_TIER = { tier1: '1', tier2: '2'
|
|
15
|
+
// 'tier1' → managed routing (default), 'tier2' → marketplace routing.
|
|
16
|
+
const PROVIDER_TO_TIER = { tier1: '1', tier2: '2' };
|
|
17
17
|
|
|
18
18
|
export function parseTestArgs(args) {
|
|
19
19
|
const flags = {};
|
|
@@ -95,7 +95,7 @@ export async function testCommand(config, args, chalk) {
|
|
|
95
95
|
} catch {
|
|
96
96
|
routes = null;
|
|
97
97
|
}
|
|
98
|
-
const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.
|
|
98
|
+
const secondaryRoute = Array.isArray(routes) ? routes.find(r => r.tier === '2') : null;
|
|
99
99
|
if (secondaryRoute?.available) {
|
|
100
100
|
step(chalk, true, 'Secondary provider configured');
|
|
101
101
|
console.log(chalk.green('\n ✓ Secondary dispatch provider is ready\n'));
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { callApi } from '../api.js';
|
|
2
|
+
import { fmtRuntime } from './receipts.js';
|
|
3
|
+
|
|
4
|
+
function fmtRate(n) {
|
|
5
|
+
return n != null ? `${(n * 100).toFixed(1)}%` : '—';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function fmtCost(n) {
|
|
9
|
+
return n != null ? `$${n.toFixed(4)}` : '—';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function printWorkload(w, chalk) {
|
|
13
|
+
const s = w.stats;
|
|
14
|
+
console.log(` ${chalk.cyan(w.workload_id)} ${chalk.bold(w.name)} ${chalk.dim(w.job_type)}`);
|
|
15
|
+
if (w.tags) console.log(` ${chalk.dim('tags:')} ${w.tags}`);
|
|
16
|
+
console.log(` runs: ${s.run_count} success: ${fmtRate(s.success_rate)} avg cost: ${fmtCost(s.avg_cost_usd)} avg runtime: ${fmtRuntime(s.avg_runtime_seconds)}`);
|
|
17
|
+
if (s.known_good_provider) {
|
|
18
|
+
console.log(` last good route: ${s.known_good_provider} / ${s.known_good_gpu_type}`);
|
|
19
|
+
}
|
|
20
|
+
console.log();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function apiRequest(config, path, opts = {}) {
|
|
24
|
+
return callApi(path, {
|
|
25
|
+
apiKey: config.apiKey,
|
|
26
|
+
baseUrl: config.baseUrl,
|
|
27
|
+
...opts,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolveWorkloadId(config, nameOrId) {
|
|
32
|
+
if (nameOrId.startsWith('wl_')) return nameOrId;
|
|
33
|
+
const data = await apiRequest(config, '/workloads?limit=100');
|
|
34
|
+
const match = (data?.workloads ?? []).find(x => x.name === nameOrId);
|
|
35
|
+
return match?.workload_id ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function workloadCommand(config, args, chalk) {
|
|
39
|
+
if (!config.apiKey) {
|
|
40
|
+
console.error(chalk.red('Not logged in. Run: badgr login'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sub = args[0];
|
|
45
|
+
const rest = args.slice(1);
|
|
46
|
+
|
|
47
|
+
if (!sub || sub === 'list') {
|
|
48
|
+
const limit = parseInt(rest[0] ?? '20', 10);
|
|
49
|
+
let data;
|
|
50
|
+
try {
|
|
51
|
+
data = await apiRequest(config, `/workloads?limit=${limit}`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(chalk.red(`Could not fetch workloads: ${err.message}`));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const workloads = data?.workloads ?? [];
|
|
57
|
+
if (workloads.length === 0) {
|
|
58
|
+
console.log(chalk.dim('\nNo saved workloads. Use `badgr run --save <name>` to create one.\n'));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log(chalk.bold(`\nSaved Workloads (${data.total} total)\n`));
|
|
62
|
+
workloads.forEach(w => printWorkload(w, chalk));
|
|
63
|
+
if (data.total > workloads.length) {
|
|
64
|
+
console.log(chalk.dim(` … ${data.total - workloads.length} more. badgr workload list ${limit * 2}\n`));
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (sub === 'info') {
|
|
70
|
+
const name = rest[0];
|
|
71
|
+
if (!name) {
|
|
72
|
+
console.error(chalk.red('Usage: badgr workload info <workload-id-or-name>'));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const id = await resolveWorkloadId(config, name);
|
|
76
|
+
if (!id) {
|
|
77
|
+
console.error(chalk.red(`Workload not found: ${name}`));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
let w;
|
|
81
|
+
try {
|
|
82
|
+
w = await apiRequest(config, `/workloads/${id}`);
|
|
83
|
+
} catch {
|
|
84
|
+
console.error(chalk.red(`Workload not found: ${name}`));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
console.log(chalk.bold(`\nWorkload: ${w.name}\n`));
|
|
88
|
+
console.log(` ID: ${chalk.cyan(w.workload_id)}`);
|
|
89
|
+
console.log(` Type: ${w.job_type}`);
|
|
90
|
+
console.log(` Tags: ${w.tags || '—'}`);
|
|
91
|
+
console.log(` Default max cost: $${w.default_max_cost}`);
|
|
92
|
+
console.log(` Default max runtime: ${w.default_max_runtime_minutes}m`);
|
|
93
|
+
const s = w.stats;
|
|
94
|
+
console.log(chalk.bold('\n Stats'));
|
|
95
|
+
console.log(` Runs: ${s.run_count}`);
|
|
96
|
+
console.log(` Success rate: ${fmtRate(s.success_rate)}`);
|
|
97
|
+
console.log(` Avg cost: ${fmtCost(s.avg_cost_usd)}`);
|
|
98
|
+
console.log(` Avg runtime: ${fmtRuntime(s.avg_runtime_seconds)}`);
|
|
99
|
+
console.log(` Total cost: ${fmtCost(s.total_cost_usd)}`);
|
|
100
|
+
if (s.recommended_route?.provider) {
|
|
101
|
+
console.log(` Recommended: ${s.recommended_route.provider} / ${s.recommended_route.gpu_type}`);
|
|
102
|
+
if (s.recommended_route.reason) console.log(` ${chalk.dim(s.recommended_route.reason)}`);
|
|
103
|
+
} else if (s.known_good_provider) {
|
|
104
|
+
console.log(` Good route: ${s.known_good_provider} / ${s.known_good_gpu_type}`);
|
|
105
|
+
}
|
|
106
|
+
if (s.bad_routes?.length) {
|
|
107
|
+
console.log(` Bad routes: ${s.bad_routes.map(r => `${r.provider}/${r.gpu_type}`).join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
if (s.last_failure_code) {
|
|
110
|
+
console.log(chalk.dim(` Last failure: ${s.last_failure_code}${s.last_failure_message ? ` — ${s.last_failure_message}` : ''}`));
|
|
111
|
+
}
|
|
112
|
+
if (w.recent_jobs?.length) {
|
|
113
|
+
console.log(chalk.bold('\n Recent Jobs'));
|
|
114
|
+
w.recent_jobs.forEach(j => {
|
|
115
|
+
const cost = j.charged_usd != null ? ` $${j.charged_usd.toFixed(4)}` : '';
|
|
116
|
+
const rt = j.runtime_seconds != null ? ` ${fmtRuntime(j.runtime_seconds)}` : '';
|
|
117
|
+
const statusColor = j.status === 'completed' ? chalk.green : j.status === 'failed' ? chalk.red : chalk.yellow;
|
|
118
|
+
console.log(` ${chalk.dim(j.job_id)} ${statusColor(j.status)}${cost}${rt}`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
console.log();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (sub === 'run') {
|
|
126
|
+
const name = rest[0];
|
|
127
|
+
if (!name) {
|
|
128
|
+
console.error(chalk.red('Usage: badgr workload run <workload-id-or-name> [--max-cost N] [--max-runtime N]'));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
// Parse overrides
|
|
132
|
+
const flags = {};
|
|
133
|
+
const overrides = {};
|
|
134
|
+
let i = 1;
|
|
135
|
+
while (i < rest.length) {
|
|
136
|
+
if (rest[i] === '--max-cost') { flags.maxCost = parseFloat(rest[++i]); i++; continue; }
|
|
137
|
+
if (rest[i] === '--max-runtime') { flags.maxRuntime = parseInt(rest[++i], 10); i++; continue; }
|
|
138
|
+
if (rest[i] === '--set') {
|
|
139
|
+
const kv = rest[++i]; i++;
|
|
140
|
+
const eq = kv.indexOf('=');
|
|
141
|
+
if (eq > 0) overrides[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const workloadId = await resolveWorkloadId(config, name);
|
|
148
|
+
if (!workloadId) {
|
|
149
|
+
console.error(chalk.red(`Workload not found: ${name}`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const body = { config_overrides: overrides };
|
|
154
|
+
if (flags.maxCost != null) body.max_cost = flags.maxCost;
|
|
155
|
+
if (flags.maxRuntime != null) body.max_runtime_minutes = flags.maxRuntime;
|
|
156
|
+
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = await apiRequest(config, `/workloads/${workloadId}/run`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
body,
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(chalk.red(`Run failed: ${err.message}`));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
console.log(chalk.green(`\nJob submitted: ${result.job_id}`));
|
|
168
|
+
console.log(` Status URL: ${result.status_url}`);
|
|
169
|
+
console.log(` Estimated: $${result.estimated_cost_usd}\n`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (sub === 'delete') {
|
|
174
|
+
const name = rest[0];
|
|
175
|
+
if (!name) {
|
|
176
|
+
console.error(chalk.red('Usage: badgr workload delete <workload-id-or-name>'));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const workloadId = await resolveWorkloadId(config, name);
|
|
180
|
+
if (!workloadId) {
|
|
181
|
+
console.error(chalk.red(`Workload not found: ${name}`));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await apiRequest(config, `/workloads/${workloadId}`, { method: 'DELETE' });
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error(chalk.red(`Delete failed: ${err.message}`));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
console.log(chalk.green(`\nWorkload deleted: ${workloadId}\n`));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.error(chalk.red(`Unknown workload subcommand: ${sub}`));
|
|
195
|
+
console.log(`\nUsage:\n badgr workload list\n badgr workload info <name>\n badgr workload run <name>\n badgr workload delete <name>\n`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { callApi } from '../api.js';
|
|
2
|
+
import { fmtRuntime } from './receipts.js';
|
|
3
|
+
|
|
4
|
+
function fmtCost(n) {
|
|
5
|
+
return n != null ? `$${n.toFixed(4)}` : '—';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function apiRequest(config, path, opts = {}) {
|
|
9
|
+
return callApi(path, { apiKey: config.apiKey, baseUrl: config.baseUrl, ...opts });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function resolveWorkspaceId(config, nameOrId) {
|
|
13
|
+
if (nameOrId.startsWith('ws_')) return nameOrId;
|
|
14
|
+
const data = await apiRequest(config, '/workspaces?limit=100');
|
|
15
|
+
const match = (data?.workspaces ?? []).find(x => x.name === nameOrId);
|
|
16
|
+
return match?.workspace_id ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function workspaceCommand(config, args, chalk) {
|
|
20
|
+
if (!config.apiKey) {
|
|
21
|
+
console.error(chalk.red('Not logged in. Run: badgr login'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sub = args[0];
|
|
26
|
+
const rest = args.slice(1);
|
|
27
|
+
|
|
28
|
+
// badgr workspace / badgr workspace list
|
|
29
|
+
if (!sub || sub === 'list') {
|
|
30
|
+
const limit = parseInt(rest[0] ?? '20', 10);
|
|
31
|
+
let data;
|
|
32
|
+
try {
|
|
33
|
+
data = await apiRequest(config, `/workspaces?limit=${limit}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(chalk.red(`Could not fetch workspaces: ${err.message}`));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const workspaces = data?.workspaces ?? [];
|
|
39
|
+
if (workspaces.length === 0) {
|
|
40
|
+
console.log(chalk.dim('\nNo workspace trackers yet. Run: badgr workspace create <name>\n'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
console.log(chalk.bold(`\nWorkspace Tracking (${data.total} total)\n`));
|
|
44
|
+
workspaces.forEach(ws => {
|
|
45
|
+
console.log(` ${chalk.cyan(ws.workspace_id)} ${chalk.bold(ws.name)}`);
|
|
46
|
+
if (ws.storage_path) console.log(` ${chalk.dim('storage:')} ${ws.storage_path}`);
|
|
47
|
+
console.log(` jobs: ${ws.total_jobs} total cost: ${fmtCost(ws.total_cost_usd)}`);
|
|
48
|
+
console.log();
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// badgr workspace create <name> [--storage <path>] [--desc <text>]
|
|
54
|
+
if (sub === 'create') {
|
|
55
|
+
const name = rest[0];
|
|
56
|
+
if (!name) {
|
|
57
|
+
console.error(chalk.red('Usage: badgr workspace create <name> [--storage <s3-path>] [--desc <text>]'));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const flags = {};
|
|
61
|
+
for (let i = 1; i < rest.length; i++) {
|
|
62
|
+
if (rest[i] === '--storage') { flags.storage_path = rest[++i]; continue; }
|
|
63
|
+
if (rest[i] === '--desc') { flags.description = rest[++i]; continue; }
|
|
64
|
+
}
|
|
65
|
+
let ws;
|
|
66
|
+
try {
|
|
67
|
+
ws = await apiRequest(config, '/workspaces', { method: 'POST', body: { name, ...flags } });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(chalk.red(`Create failed: ${err.message}`));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log(chalk.green(`\nWorkspace tracker created: ${ws.name} (${ws.workspace_id})`));
|
|
73
|
+
if (ws.storage_path) console.log(` Storage path: ${ws.storage_path}`);
|
|
74
|
+
console.log(` Link jobs to this workspace: badgr run --workspace ${ws.workspace_id} <command>\n`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// badgr workspace info <name-or-id>
|
|
79
|
+
if (sub === 'info') {
|
|
80
|
+
const name = rest[0];
|
|
81
|
+
if (!name) {
|
|
82
|
+
console.error(chalk.red('Usage: badgr workspace info <name-or-id>'));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const id = await resolveWorkspaceId(config, name);
|
|
86
|
+
if (!id) { console.error(chalk.red(`Workspace not found: ${name}`)); process.exit(1); }
|
|
87
|
+
let ws;
|
|
88
|
+
try {
|
|
89
|
+
ws = await apiRequest(config, `/workspaces/${id}`);
|
|
90
|
+
} catch {
|
|
91
|
+
console.error(chalk.red(`Workspace not found: ${name}`)); process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log(chalk.bold(`\nWorkspace: ${ws.name}\n`));
|
|
94
|
+
console.log(` ID: ${chalk.cyan(ws.workspace_id)}`);
|
|
95
|
+
if (ws.description) console.log(` Desc: ${ws.description}`);
|
|
96
|
+
if (ws.storage_path) console.log(` Storage: ${ws.storage_path}`);
|
|
97
|
+
console.log(` Jobs: ${ws.total_jobs} Total cost: ${fmtCost(ws.total_cost_usd)}`);
|
|
98
|
+
if (ws.files?.length) {
|
|
99
|
+
console.log(chalk.bold('\n Files'));
|
|
100
|
+
ws.files.forEach(f => console.log(` ${f.name} ${f.size_bytes ? `(${(f.size_bytes / 1024).toFixed(1)} KB)` : ''}`));
|
|
101
|
+
}
|
|
102
|
+
if (ws.recent_jobs?.length) {
|
|
103
|
+
console.log(chalk.bold('\n Recent Jobs'));
|
|
104
|
+
ws.recent_jobs.forEach(j => {
|
|
105
|
+
const cost = j.charged_usd != null ? ` ${fmtCost(j.charged_usd)}` : '';
|
|
106
|
+
const rt = j.runtime_seconds != null ? ` ${fmtRuntime(j.runtime_seconds)}` : '';
|
|
107
|
+
const col = j.status === 'completed' ? chalk.green : j.status === 'failed' ? chalk.red : chalk.yellow;
|
|
108
|
+
console.log(` ${chalk.dim(j.job_id)} ${col(j.status)}${cost}${rt}`);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
console.log();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// badgr workspace delete <name-or-id>
|
|
116
|
+
if (sub === 'delete') {
|
|
117
|
+
const name = rest[0];
|
|
118
|
+
if (!name) {
|
|
119
|
+
console.error(chalk.red('Usage: badgr workspace delete <name-or-id>'));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const id = await resolveWorkspaceId(config, name);
|
|
123
|
+
if (!id) { console.error(chalk.red(`Workspace not found: ${name}`)); process.exit(1); }
|
|
124
|
+
try {
|
|
125
|
+
await apiRequest(config, `/workspaces/${id}`, { method: 'DELETE' });
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(chalk.red(`Delete failed: ${err.message}`)); process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
console.log(chalk.green(`\nWorkspace archived: ${id}\n`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.error(chalk.red(`Unknown workspace subcommand: ${sub}`));
|
|
134
|
+
console.log('\nUsage:\n badgr workspace list\n badgr workspace create <name>\n badgr workspace info <name>\n badgr workspace delete <name>\n');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
package/tests/commands.test.js
CHANGED
|
@@ -94,6 +94,54 @@ describe('parseRunArgs', () => {
|
|
|
94
94
|
expect(flags.gpu).toBe('A100');
|
|
95
95
|
expect(flags.minVram).toBe(40);
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
it('-- separator: badgr flags before --, command argv after', () => {
|
|
99
|
+
const { flags, commandArgv, positional } = parseRunArgs([
|
|
100
|
+
'--gpu', 'RTX_4090', '--image', 'node:20', '--max-cost', '1', '--',
|
|
101
|
+
'node', '-e', "console.log('hello')",
|
|
102
|
+
]);
|
|
103
|
+
expect(flags.gpu).toBe('RTX_4090');
|
|
104
|
+
expect(flags.image).toBe('node:20');
|
|
105
|
+
expect(flags.maxCost).toBe(1);
|
|
106
|
+
expect(commandArgv).toEqual(['node', '-e', "console.log('hello')"]);
|
|
107
|
+
expect(positional).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('-- separator: commandArgv is empty array when nothing follows --', () => {
|
|
111
|
+
const { commandArgv } = parseRunArgs(['--gpu', 'A100', '--max-cost', '2', '--']);
|
|
112
|
+
expect(commandArgv).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('commandArgv is null when -- is not used (legacy syntax)', () => {
|
|
116
|
+
const { commandArgv, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100']);
|
|
117
|
+
expect(commandArgv).toBeNull();
|
|
118
|
+
expect(positional).toEqual(['python', 'train.py']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('parses --dry-run flag', () => {
|
|
122
|
+
const { flags } = parseRunArgs(['--dry-run', '--gpu', 'RTX_4090', '--image', 'node:20']);
|
|
123
|
+
expect(flags.dryRun).toBe(true);
|
|
124
|
+
expect(flags.gpu).toBe('RTX_4090');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('--dry-run with -- separator keeps command argv intact', () => {
|
|
128
|
+
const { flags, commandArgv } = parseRunArgs([
|
|
129
|
+
'--dry-run', '--gpu', 'RTX_4090', '--image', 'node:20', '--',
|
|
130
|
+
'node', '-e', "console.log('dry')",
|
|
131
|
+
]);
|
|
132
|
+
expect(flags.dryRun).toBe(true);
|
|
133
|
+
expect(commandArgv).toEqual(['node', '-e', "console.log('dry')"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('-- separator: command tokens with --flags are not parsed as badgr flags', () => {
|
|
137
|
+
// --json is a user command flag here, not a badgr flag
|
|
138
|
+
const { flags, commandArgv } = parseRunArgs([
|
|
139
|
+
'--gpu', 'A100', '--max-cost', '2', '--',
|
|
140
|
+
'gpu-monitor', 'check', '--json',
|
|
141
|
+
]);
|
|
142
|
+
expect(commandArgv).toEqual(['gpu-monitor', 'check', '--json']);
|
|
143
|
+
expect(flags.gpu).toBe('A100');
|
|
144
|
+
});
|
|
97
145
|
});
|
|
98
146
|
|
|
99
147
|
describe('classifyFailure', () => {
|
|
@@ -120,7 +120,7 @@ afterEach(() => {
|
|
|
120
120
|
describe('GPU / provider matrix', () => {
|
|
121
121
|
it('A100 via RunPod (tier-1) completes successfully', async () => {
|
|
122
122
|
setupSuccessfulRun({ gpu_type: 'A100', provider: 'runpod', tier: '1' });
|
|
123
|
-
const p = runCommand(config, ['python', 'train.py', '--gpu', 'A100'], chalk);
|
|
123
|
+
const p = runCommand(config, ['python', 'train.py', '--gpu', 'A100', '--max-cost', '5'], chalk);
|
|
124
124
|
await vi.advanceTimersByTimeAsync(5000);
|
|
125
125
|
await p;
|
|
126
126
|
|
|
@@ -142,7 +142,7 @@ describe('GPU / provider matrix', () => {
|
|
|
142
142
|
.mockResolvedValueOnce(makeDep({ gpu_type: 'L40S', provider: 'vastai', tier: '1', cost_per_hour: 1.80 }))
|
|
143
143
|
.mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
|
|
144
144
|
.mockResolvedValueOnce({ logs: [] });
|
|
145
|
-
const p = runCommand(config, ['python', 'train.py', '--gpu', 'L40S'], chalk);
|
|
145
|
+
const p = runCommand(config, ['python', 'train.py', '--gpu', 'L40S', '--max-cost', '5'], chalk);
|
|
146
146
|
await vi.advanceTimersByTimeAsync(5000);
|
|
147
147
|
await p;
|
|
148
148
|
|
|
@@ -168,7 +168,7 @@ describe('non-zero exit code', () => {
|
|
|
168
168
|
.mockResolvedValueOnce(makeDep())
|
|
169
169
|
.mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
|
|
170
170
|
.mockResolvedValueOnce({ logs: ['Traceback (most recent call last)'] });
|
|
171
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
171
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
172
172
|
await vi.advanceTimersByTimeAsync(5000);
|
|
173
173
|
await p;
|
|
174
174
|
|
|
@@ -184,7 +184,7 @@ describe('non-zero exit code', () => {
|
|
|
184
184
|
.mockResolvedValueOnce(makeDep())
|
|
185
185
|
.mockResolvedValueOnce({ status: 'failed', exit_code: null })
|
|
186
186
|
.mockResolvedValueOnce({ logs: [] });
|
|
187
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
187
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
188
188
|
await vi.advanceTimersByTimeAsync(5000);
|
|
189
189
|
await p;
|
|
190
190
|
|
|
@@ -203,7 +203,7 @@ describe('container fails to start', () => {
|
|
|
203
203
|
api.callApi
|
|
204
204
|
.mockResolvedValueOnce(makeDep({ status: 'failed' })) // POST /run returns already-failed
|
|
205
205
|
.mockResolvedValueOnce({ logs: [] });
|
|
206
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
206
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
207
207
|
await vi.advanceTimersByTimeAsync(1000);
|
|
208
208
|
await p;
|
|
209
209
|
|
|
@@ -215,7 +215,7 @@ describe('container fails to start', () => {
|
|
|
215
215
|
api.callApi
|
|
216
216
|
.mockResolvedValueOnce(makeDep({ status: 'provisioning' })) // POST /run → provisioning
|
|
217
217
|
.mockResolvedValueOnce({ status: 'failed' }); // poll → failed
|
|
218
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
218
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
219
219
|
// advance past POLL_MS (3000ms) in waitForRunning
|
|
220
220
|
await vi.advanceTimersByTimeAsync(4000);
|
|
221
221
|
await p;
|
|
@@ -237,7 +237,7 @@ describe('max-runtime cap', () => {
|
|
|
237
237
|
.mockResolvedValueOnce({ logs: [] }); // logs 1
|
|
238
238
|
api.terminateDeployment.mockResolvedValue({});
|
|
239
239
|
|
|
240
|
-
const p = runCommand(config, ['python', 'train.py', '--max-runtime', '0.05'], chalk);
|
|
240
|
+
const p = runCommand(config, ['python', 'train.py', '--max-runtime', '0.05', '--max-cost', '5'], chalk);
|
|
241
241
|
// attachToJob POLL_MS = 4000, maxRuntime = 0.05 min = 3000ms → cap fires after 4000ms (first elapsedMs check)
|
|
242
242
|
await vi.advanceTimersByTimeAsync(5000);
|
|
243
243
|
await p;
|
|
@@ -294,7 +294,7 @@ describe('heartbeat lost', () => {
|
|
|
294
294
|
.mockRejectedValue(new Error('ETIMEDOUT')); // all subsequent dep/logs polls fail
|
|
295
295
|
api.terminateDeployment.mockResolvedValue({});
|
|
296
296
|
|
|
297
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
297
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
298
298
|
// Advance past 1 successful poll (4000ms) + 15 failed polls (60000ms) = 64000ms
|
|
299
299
|
await vi.advanceTimersByTimeAsync(66000);
|
|
300
300
|
await p;
|
|
@@ -333,7 +333,7 @@ describe('log streaming', () => {
|
|
|
333
333
|
'Epoch 2/3: loss=0.31', // should appear
|
|
334
334
|
]});
|
|
335
335
|
|
|
336
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
336
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
337
337
|
await vi.advanceTimersByTimeAsync(5000);
|
|
338
338
|
await p;
|
|
339
339
|
|
|
@@ -357,11 +357,11 @@ describe('routing fallback', () => {
|
|
|
357
357
|
});
|
|
358
358
|
api.callApi
|
|
359
359
|
.mockRejectedValueOnce(capacityErr) // tier-1 attempt
|
|
360
|
-
.mockResolvedValueOnce(makeDep({ provider: '
|
|
360
|
+
.mockResolvedValueOnce(makeDep({ provider: 'vastai', tier: '2' })) // tier-2 succeeds
|
|
361
361
|
.mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
|
|
362
362
|
.mockResolvedValueOnce({ logs: [] });
|
|
363
363
|
|
|
364
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
364
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
365
365
|
await vi.advanceTimersByTimeAsync(5000);
|
|
366
366
|
await p;
|
|
367
367
|
|
|
@@ -370,7 +370,7 @@ describe('routing fallback', () => {
|
|
|
370
370
|
expect(postCalls.length).toBeGreaterThanOrEqual(2);
|
|
371
371
|
|
|
372
372
|
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
373
|
-
providerRoute: '
|
|
373
|
+
providerRoute: 'vastai',
|
|
374
374
|
tier: '2',
|
|
375
375
|
}));
|
|
376
376
|
expect(process.exitCode).toBeFalsy();
|
|
@@ -386,7 +386,7 @@ describe('routing fallback', () => {
|
|
|
386
386
|
const logs = [];
|
|
387
387
|
console.error.mockImplementation(msg => logs.push(msg));
|
|
388
388
|
|
|
389
|
-
await runCommand(config, ['python', 'train.py'], chalk);
|
|
389
|
+
await runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
390
390
|
|
|
391
391
|
expect(process.exitCode).toBe(1);
|
|
392
392
|
const combined = logs.join('\n');
|
|
@@ -400,7 +400,7 @@ describe('routing fallback', () => {
|
|
|
400
400
|
});
|
|
401
401
|
api.callApi.mockRejectedValueOnce(capacityErr);
|
|
402
402
|
|
|
403
|
-
await runCommand(config, ['python', 'train.py', '--no-fallback'], chalk);
|
|
403
|
+
await runCommand(config, ['python', 'train.py', '--no-fallback', '--max-cost', '5'], chalk);
|
|
404
404
|
|
|
405
405
|
// Only one POST /run call was made (no tier-2 expansion)
|
|
406
406
|
const postCalls = api.callApi.mock.calls.filter(c => c[1]?.method === 'POST');
|
|
@@ -418,7 +418,7 @@ describe('routing fallback', () => {
|
|
|
418
418
|
const errLines = [];
|
|
419
419
|
console.error.mockImplementation(msg => errLines.push(msg));
|
|
420
420
|
|
|
421
|
-
await runCommand(config, ['python', 'train.py'], chalk);
|
|
421
|
+
await runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
422
422
|
|
|
423
423
|
expect(process.exitCode).toBe(1);
|
|
424
424
|
const combined = errLines.join('\n');
|
|
@@ -442,7 +442,7 @@ describe('payment required', () => {
|
|
|
442
442
|
const errLines = [];
|
|
443
443
|
console.error.mockImplementation(msg => errLines.push(msg));
|
|
444
444
|
|
|
445
|
-
await runCommand(config, ['python', 'train.py', '--gpu', 'A100'], chalk);
|
|
445
|
+
await runCommand(config, ['python', 'train.py', '--gpu', 'A100', '--max-cost', '5'], chalk);
|
|
446
446
|
|
|
447
447
|
expect(process.exitCode).toBe(1);
|
|
448
448
|
const combined = errLines.join('\n');
|
|
@@ -457,7 +457,7 @@ describe('payment required', () => {
|
|
|
457
457
|
describe('billing lifecycle', () => {
|
|
458
458
|
it('terminateDeployment is called after successful completion', async () => {
|
|
459
459
|
setupSuccessfulRun();
|
|
460
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
460
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
461
461
|
await vi.advanceTimersByTimeAsync(5000);
|
|
462
462
|
await p;
|
|
463
463
|
expect(api.terminateDeployment).toHaveBeenCalledWith(config, 'dep-launch-001');
|
|
@@ -465,7 +465,7 @@ describe('billing lifecycle', () => {
|
|
|
465
465
|
|
|
466
466
|
it('receipt records runtime and finalCost on completion', async () => {
|
|
467
467
|
setupSuccessfulRun({ cost_per_hour: 1.80 });
|
|
468
|
-
const p = runCommand(config, ['python', 'train.py'], chalk);
|
|
468
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
469
469
|
await vi.advanceTimersByTimeAsync(5000);
|
|
470
470
|
await p;
|
|
471
471
|
|
|
@@ -505,4 +505,41 @@ describe('input validation', () => {
|
|
|
505
505
|
await runCommand(config, [], chalk);
|
|
506
506
|
expect(api.callApi).not.toHaveBeenCalled();
|
|
507
507
|
});
|
|
508
|
+
|
|
509
|
+
it('rejects -- with nothing after and no --image', async () => {
|
|
510
|
+
await runCommand(config, ['--gpu', 'RTX_4090', '--max-cost', '1', '--'], chalk);
|
|
511
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
512
|
+
expect(process.exitCode).toBe(1);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
517
|
+
// 12. --dry-run: shows config, never provisions
|
|
518
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
describe('--dry-run', () => {
|
|
521
|
+
it('prints dry-run summary and never calls the API', async () => {
|
|
522
|
+
const output = [];
|
|
523
|
+
const dryChalk = { bold: (s) => s, dim: (s) => s, cyan: (s) => s, red: (s) => s, yellow: (s) => s, green: (s) => s };
|
|
524
|
+
const origLog = console.log;
|
|
525
|
+
console.log = (...args) => output.push(args.join(' '));
|
|
526
|
+
await runCommand(config, [
|
|
527
|
+
'--dry-run', '--gpu', 'RTX_4090', '--image', 'node:20', '--max-cost', '1', '--',
|
|
528
|
+
'node', '-e', "console.log('dry')",
|
|
529
|
+
], dryChalk);
|
|
530
|
+
console.log = origLog;
|
|
531
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
532
|
+
expect(output.some(l => /dry run/i.test(l))).toBe(true);
|
|
533
|
+
expect(output.some(l => /node -e/.test(l) || /console\.log/.test(l))).toBe(true);
|
|
534
|
+
expect(output.some(l => /RTX_4090/.test(l))).toBe(true);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('--dry-run works without --max-cost', async () => {
|
|
538
|
+
const dryChalk = { bold: (s) => s, dim: (s) => s, cyan: (s) => s, red: (s) => s, yellow: (s) => s, green: (s) => s };
|
|
539
|
+
await runCommand(config, [
|
|
540
|
+
'--dry-run', '--gpu', 'A100', '--image', 'node:20', '--',
|
|
541
|
+
'node', 'script.js',
|
|
542
|
+
], dryChalk);
|
|
543
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
544
|
+
});
|
|
508
545
|
});
|
package/tests/template.test.js
CHANGED
|
@@ -107,10 +107,12 @@ describe('TEMPLATES catalog', () => {
|
|
|
107
107
|
const EXPECTED_NAMES = [
|
|
108
108
|
'comfyui', 'axolotl', 'unsloth', 'vllm', 'llama-cpp',
|
|
109
109
|
'invokeai', 'kohya-ss', 'text-gen-webui', 'sglang', 'tgi',
|
|
110
|
+
'auto1111', 'forge', 'nerfstudio', 'openfold', 'blender-render',
|
|
111
|
+
'openmm', 'gromacs', 'lammps', 'diffusers', 'torchtune',
|
|
110
112
|
];
|
|
111
113
|
|
|
112
|
-
it('contains exactly
|
|
113
|
-
expect(TEMPLATES).toHaveLength(
|
|
114
|
+
it('contains exactly 20 templates', () => {
|
|
115
|
+
expect(TEMPLATES).toHaveLength(20);
|
|
114
116
|
});
|
|
115
117
|
|
|
116
118
|
it('contains all expected template names', () => {
|
|
@@ -178,7 +180,7 @@ describe('TEMPLATES catalog', () => {
|
|
|
178
180
|
for (const [k, t] of Object.entries(TEMPLATE_MAP)) {
|
|
179
181
|
expect(k).toBe(t.name);
|
|
180
182
|
}
|
|
181
|
-
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(
|
|
183
|
+
expect(Object.keys(TEMPLATE_MAP)).toHaveLength(20);
|
|
182
184
|
});
|
|
183
185
|
});
|
|
184
186
|
|
|
@@ -358,7 +360,7 @@ describe('badgr serve template <name>', () => {
|
|
|
358
360
|
await p;
|
|
359
361
|
|
|
360
362
|
const [, opts] = api.callApi.mock.calls[0];
|
|
361
|
-
expect(opts.body.image).toBe('yanwk/comfyui-boot:
|
|
363
|
+
expect(opts.body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
|
|
362
364
|
const fetchUrl = global.fetch.mock.calls[0]?.[0] ?? '';
|
|
363
365
|
expect(fetchUrl).toContain('/system_stats');
|
|
364
366
|
expect(process.exitCode).toBeFalsy();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Verify `badgr workload run` (rerun a saved workflow) resolves name→id and
|
|
4
|
+
// merges --set / --max-cost / --max-runtime into the POST body the server
|
|
5
|
+
// expects, without provisioning a GPU.
|
|
6
|
+
const calls = [];
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
callApi: vi.fn(async (path, opts = {}) => {
|
|
9
|
+
calls.push({ path, opts });
|
|
10
|
+
if (path.startsWith('/workloads?')) {
|
|
11
|
+
return { workloads: [{ name: 'mini-train', workload_id: 'wl_abc' }], total: 1 };
|
|
12
|
+
}
|
|
13
|
+
if (path === '/workloads/wl_abc/run') {
|
|
14
|
+
return { job_id: 'job_1', status_url: 'https://aibadgr.com/v1/jobs/job_1', estimated_cost_usd: 1.23 };
|
|
15
|
+
}
|
|
16
|
+
return {};
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const { workloadCommand } = await import('../src/commands/workload.js');
|
|
21
|
+
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
22
|
+
const config = { apiKey: 'k', baseUrl: 'https://aibadgr.com/v1' };
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
calls.length = 0;
|
|
26
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('badgr workload run (rerun saved workflow)', () => {
|
|
30
|
+
it('resolves name→id and merges --set / --max-cost / --max-runtime into the rerun body', async () => {
|
|
31
|
+
await workloadCommand(
|
|
32
|
+
config,
|
|
33
|
+
['run', 'mini-train', '--set', 'gpu=H100', '--max-cost', '8', '--max-runtime', '30'],
|
|
34
|
+
chalk,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const resolve = calls.find(c => c.path.startsWith('/workloads?'));
|
|
38
|
+
expect(resolve, 'should look up workload by name').toBeTruthy();
|
|
39
|
+
expect(resolve.path.startsWith('/v1/')).toBe(false);
|
|
40
|
+
|
|
41
|
+
const run = calls.find(c => c.path === '/workloads/wl_abc/run');
|
|
42
|
+
expect(run, 'should POST to the rerun endpoint').toBeTruthy();
|
|
43
|
+
expect(run.opts.method).toBe('POST');
|
|
44
|
+
expect(run.opts.body).toEqual({
|
|
45
|
+
config_overrides: { gpu: 'H100' },
|
|
46
|
+
max_cost: 8,
|
|
47
|
+
max_runtime_minutes: 30,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sends only config_overrides when no caps are passed', async () => {
|
|
52
|
+
await workloadCommand(config, ['run', 'mini-train', '--set', 'steps=50'], chalk);
|
|
53
|
+
const run = calls.find(c => c.path === '/workloads/wl_abc/run');
|
|
54
|
+
expect(run.opts.body).toEqual({ config_overrides: { steps: '50' } });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -239,7 +239,7 @@ describe('comfyuiCommand', () => {
|
|
|
239
239
|
const bodyBuilder = fallback.callWithFallback.mock.calls[0][2];
|
|
240
240
|
const body = bodyBuilder();
|
|
241
241
|
expect(body.env.COMFYUI_WORKFLOW_B64).toBe(Buffer.from(wfContent).toString('base64'));
|
|
242
|
-
expect(body.image).toBe('yanwk/comfyui-boot:
|
|
242
|
+
expect(body.image).toBe('yanwk/comfyui-boot:cu126-megapak');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
it('skips health check when --no-wait', async () => {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Capture every path passed to callApi so we can assert the CLI never builds a
|
|
4
|
+
// doubled `/v1/v1/...` URL. baseUrl already ends in `/v1`, so command paths must
|
|
5
|
+
// be unprefixed (e.g. `/workspaces`, not `/v1/workspaces`).
|
|
6
|
+
const calls = [];
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
callApi: vi.fn(async (path) => {
|
|
9
|
+
calls.push(path);
|
|
10
|
+
if (path.startsWith('/workspaces')) return { workspaces: [], total: 0 };
|
|
11
|
+
if (path.startsWith('/workloads')) return { workloads: [], total: 0 };
|
|
12
|
+
return {};
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { workspaceCommand } = await import('../src/commands/workspace.js');
|
|
17
|
+
const { workloadCommand } = await import('../src/commands/workload.js');
|
|
18
|
+
|
|
19
|
+
// chalk stub: any style method returns its first argument unchanged.
|
|
20
|
+
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
21
|
+
const config = { apiKey: 'test-key', baseUrl: 'https://aibadgr.com/v1' };
|
|
22
|
+
|
|
23
|
+
function assertNoDoubleV1() {
|
|
24
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
25
|
+
for (const path of calls) {
|
|
26
|
+
expect(path.startsWith('/v1/')).toBe(false);
|
|
27
|
+
expect(`${config.baseUrl}${path}`).not.toContain('/v1/v1/');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
calls.length = 0;
|
|
33
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('CLI workload/workspace request paths', () => {
|
|
37
|
+
it('workspace list targets /workspaces (no doubled /v1)', async () => {
|
|
38
|
+
await workspaceCommand(config, ['list'], chalk);
|
|
39
|
+
assertNoDoubleV1();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('workload list targets /workloads (no doubled /v1)', async () => {
|
|
43
|
+
await workloadCommand(config, ['list'], chalk);
|
|
44
|
+
assertNoDoubleV1();
|
|
45
|
+
});
|
|
46
|
+
});
|