badgr-cli 1.0.38 → 1.0.40

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.
@@ -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
+ });