badgr-cli 1.0.37 → 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 +23 -1
- package/src/catalog.js +479 -0
- package/src/commands/comfyui.js +1 -1
- package/src/commands/receipts.js +2 -1
- package/src/commands/run.js +134 -25
- package/src/commands/serve.js +100 -10
- package/src/commands/template.js +119 -0
- 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/serve-lifecycle.test.js +165 -0
- package/tests/template.test.js +551 -0
- 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
|
@@ -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
|
});
|