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 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 [GPU_SUPPORT.md](../../GPU_SUPPORT.md) in the repo root.
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.38",
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
- "@inquirer/prompts": "^8.5.2",
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": ">=18.0.0"
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": "MIT"
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:latest',
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]));
@@ -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:latest';
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
@@ -5,7 +5,8 @@ function fmtMs(ms) {
5
5
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
6
6
  }
7
7
 
8
- function fmtRuntime(s) {
8
+ export function fmtRuntime(s) {
9
+ if (s == null) return '—';
9
10
  if (s < 60) return `${s}s`;
10
11
  return `${Math.floor(s / 60)}m ${s % 60}s`;
11
12
  }
@@ -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 < args.length) {
19
- if (args[i] === '--gpu') { flags.gpu = args[++i]; i++; continue; }
20
- if (args[i] === '--image') { flags.image = args[++i]; i++; continue; }
21
- if (args[i] === '--count') { flags.count = parseInt(args[++i], 10); i++; continue; }
22
- if (args[i] === '--region') { flags.region = args[++i]; i++; continue; }
23
- if (args[i] === '--tier') { flags.tier = args[++i]; i++; continue; }
24
- if (args[i] === '--max-price') { flags.maxPrice = parseFloat(args[++i]); i++; continue; }
25
- if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
26
- if (args[i] === '--detach') { flags.detach = true; i++; continue; }
27
- if (args[i] === '--fallback') { flags.fallback = args[++i]; i++; continue; }
28
- if (args[i] === '--no-fallback') { flags.noFallback = true; i++; continue; }
29
- if (args[i] === '--strict-capacity') { flags.noFallback = true; i++; continue; }
30
- if (args[i] === '--no-expanded-search') { flags.noFallback = true; i++; continue; }
31
- if (args[i] === '--max-runtime') { flags.maxRuntime = parseFloat(args[++i]); i++; continue; }
32
- if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
33
- if (args[i] === '--min-vram') { flags.minVram = parseFloat(args[++i]); i++; continue; }
34
- if (args[i] === '--env') {
35
- const kv = args[++i]; i++;
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(args[i++]);
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
- const command = positional.length > 0 ? positional : undefined;
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
  }
@@ -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 (smoke / Modal T4 tier)
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, 'secondary' → secondary dispatch.
16
- const PROVIDER_TO_TIER = { tier1: '1', tier2: '2', secondary: 'modal' };
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.name === 'modal') : null;
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
+ }
@@ -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: 'modal', tier: '2' })) // tier-2 succeeds
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: 'modal',
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
  });
@@ -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 10 templates', () => {
113
- expect(TEMPLATES).toHaveLength(10);
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(10);
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:latest');
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:latest');
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
+ });