badgr-cli 1.0.45 → 1.0.47
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/package.json +4 -2
- package/src/api.js +57 -0
- package/src/badgr.js +21 -0
- package/src/commands/batch.js +612 -0
- package/src/commands/heartbeat.js +38 -0
- package/src/commands/rerun.js +75 -0
- package/src/commands/restart.js +74 -0
- package/src/commands/run.js +3 -19
- package/src/commands/serve.js +26 -4
- package/src/commands/train.js +49 -21
- package/src/store.js +27 -0
- package/src/workloadSpec.js +126 -0
- package/tests/api.test.js +29 -1
- package/tests/batch.test.js +329 -0
- package/tests/heartbeat.test.js +70 -0
- package/tests/rerun.test.js +94 -0
- package/tests/restart.test.js +88 -0
- package/tests/serve-lifecycle.test.js +72 -0
- package/tests/train-lora-dataset.test.js +176 -0
- package/tests/workload-spec.test.js +180 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr batch — /run body shape, missing-artifact-path detection, and
|
|
3
|
+
* `badgr batch compare` regression logic against literal metrics.json
|
|
4
|
+
* fixtures (satisfies test scenario 5 without needing a live run).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
addReceipt: vi.fn(),
|
|
13
|
+
updateReceipt: vi.fn(),
|
|
14
|
+
generateReceiptId: vi.fn(() => 'rcpt-batch-001'),
|
|
15
|
+
loadStore: vi.fn(() => ({ receipts: [], deployments: [] })),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/api.js', () => ({
|
|
19
|
+
callApi: vi.fn(),
|
|
20
|
+
uploadBlob: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../src/fallback.js', async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal();
|
|
25
|
+
return { ...actual, callWithFallback: vi.fn() };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock('../src/batch.js', () => ({
|
|
29
|
+
monitorBatchJob: vi.fn(() => Promise.resolve({ status: 'succeeded', exitCode: 0, runtimeMs: 42000, reason: 'complete' })),
|
|
30
|
+
fmtRuntime: (ms) => `${Math.round(ms / 1000)}s`,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('archiver', () => ({
|
|
34
|
+
default: () => {
|
|
35
|
+
let out;
|
|
36
|
+
return {
|
|
37
|
+
on: () => {},
|
|
38
|
+
pipe: (o) => { out = o; },
|
|
39
|
+
directory: () => {},
|
|
40
|
+
file: () => {},
|
|
41
|
+
finalize: () => { out.end(); },
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
batchCommand,
|
|
48
|
+
displayFailureReason,
|
|
49
|
+
missingOutputPaths,
|
|
50
|
+
readMetricFile,
|
|
51
|
+
} from '../src/commands/batch.js';
|
|
52
|
+
import * as store from '../src/store.js';
|
|
53
|
+
import * as api from '../src/api.js';
|
|
54
|
+
import * as fallback from '../src/fallback.js';
|
|
55
|
+
|
|
56
|
+
const chalk = {
|
|
57
|
+
bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
61
|
+
|
|
62
|
+
let dir;
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
dir = mkdtempSync(join(tmpdir(), 'badgr-batch-cmd-test-'));
|
|
65
|
+
process.exitCode = undefined;
|
|
66
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
67
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
68
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
store.loadStore.mockReturnValue({ receipts: [], deployments: [] });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// displayFailureReason
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
describe('displayFailureReason', () => {
|
|
83
|
+
it('maps max_runtime to runtime_limit_exceeded', () => {
|
|
84
|
+
expect(displayFailureReason('max_runtime')).toBe('runtime_limit_exceeded');
|
|
85
|
+
});
|
|
86
|
+
it('maps max_cost to cost_cap_exceeded', () => {
|
|
87
|
+
expect(displayFailureReason('max_cost')).toBe('cost_cap_exceeded');
|
|
88
|
+
});
|
|
89
|
+
it('passes through unknown reasons unchanged', () => {
|
|
90
|
+
expect(displayFailureReason('something_else')).toBe('something_else');
|
|
91
|
+
});
|
|
92
|
+
it('returns null for no reason', () => {
|
|
93
|
+
expect(displayFailureReason(null)).toBeNull();
|
|
94
|
+
expect(displayFailureReason(undefined)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// missingOutputPaths / readMetricFile
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe('missingOutputPaths', () => {
|
|
103
|
+
it('reports declared paths that do not exist under the extraction dir', () => {
|
|
104
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
105
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
|
|
106
|
+
const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos', '/outputs/logs']);
|
|
107
|
+
expect(missing).toEqual(['/outputs/videos', '/outputs/logs']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports nothing missing when all declared paths exist', () => {
|
|
111
|
+
mkdirSync(join(dir, 'outputs', 'videos'), { recursive: true });
|
|
112
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), '{}');
|
|
113
|
+
const missing = missingOutputPaths(dir, ['/outputs/metrics.json', '/outputs/videos']);
|
|
114
|
+
expect(missing).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('readMetricFile', () => {
|
|
119
|
+
it('reads and parses a metrics JSON file', () => {
|
|
120
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
121
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), JSON.stringify({ pass_rate: 0.9 }));
|
|
122
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toEqual({ pass_rate: 0.9 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null when the file does not exist', () => {
|
|
126
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null on malformed JSON', () => {
|
|
130
|
+
mkdirSync(join(dir, 'outputs'), { recursive: true });
|
|
131
|
+
writeFileSync(join(dir, 'outputs', 'metrics.json'), 'not json');
|
|
132
|
+
expect(readMetricFile(dir, '/outputs/metrics.json')).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// badgr batch run — /run body shape (image/command/env/caps)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe('badgr batch run — /run request shape', () => {
|
|
141
|
+
it('builds the /run body from workload.yml with inputs/outputs env wiring', async () => {
|
|
142
|
+
writeFileSync(join(dir, 'policy.py'), 'def act(obs): return 0');
|
|
143
|
+
mkdirSync(join(dir, 'scenarios'));
|
|
144
|
+
writeFileSync(join(dir, 'scenarios', 's0.json'), '{}');
|
|
145
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
146
|
+
writeFileSync(yamlPath, `
|
|
147
|
+
name: toy-robot-eval
|
|
148
|
+
image: badgr/physical-ai-eval-lite:latest
|
|
149
|
+
command: [python, run_eval.py, --policy, /inputs/policy.py, --scenarios, /inputs/scenarios]
|
|
150
|
+
inputs:
|
|
151
|
+
- ./policy.py:/inputs/policy.py
|
|
152
|
+
- ./scenarios:/inputs/scenarios
|
|
153
|
+
outputs:
|
|
154
|
+
- /outputs/metrics.json
|
|
155
|
+
- /outputs/videos
|
|
156
|
+
- /outputs/logs
|
|
157
|
+
max_cost: 20
|
|
158
|
+
max_runtime_minutes: 60
|
|
159
|
+
success_metric:
|
|
160
|
+
file: /outputs/metrics.json
|
|
161
|
+
key: pass_rate
|
|
162
|
+
higher_is_better: true
|
|
163
|
+
`);
|
|
164
|
+
|
|
165
|
+
let capturedBody;
|
|
166
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
167
|
+
capturedBody = buildBody();
|
|
168
|
+
return Promise.resolve({
|
|
169
|
+
deployment_id: 'dep-abc123', receipt_id: 'rcpt-1', gpu_type: 'RTX_4090',
|
|
170
|
+
gpu_count: 1, cost_per_hour: 0.5, provider: 'runpod', tier: '1', status: 'running',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
api.callApi.mockImplementation((path) => {
|
|
175
|
+
if (path.endsWith('/artifacts')) return Promise.reject(new Error('no artifacts yet'));
|
|
176
|
+
if (path.startsWith('/deployments/')) {
|
|
177
|
+
return Promise.resolve({
|
|
178
|
+
status: 'succeeded', failure_reason: null, teardown_ok: 'ok',
|
|
179
|
+
runtime_seconds: 42, accrued_cost_usd: 0.01, provider: 'runpod',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return Promise.resolve({});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
api.uploadBlob.mockResolvedValue({ code_uri: 'https://cdn.example.com/inputs.tar.gz', upload_id: 'up_1' });
|
|
186
|
+
|
|
187
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
188
|
+
|
|
189
|
+
expect(capturedBody.image).toBe('badgr/physical-ai-eval-lite:latest');
|
|
190
|
+
expect(capturedBody.command).toEqual(['python', 'run_eval.py', '--policy', '/inputs/policy.py', '--scenarios', '/inputs/scenarios']);
|
|
191
|
+
expect(capturedBody.max_cost_usd).toBe(20);
|
|
192
|
+
expect(capturedBody.max_runtime_seconds).toBe(3600);
|
|
193
|
+
expect(capturedBody.custom_image).toBe(true);
|
|
194
|
+
expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBe('https://cdn.example.com/inputs.tar.gz');
|
|
195
|
+
expect(JSON.parse(capturedBody.env.BADGR_OUTPUT_PATHS_JSON)).toEqual([
|
|
196
|
+
'/outputs/metrics.json', '/outputs/videos', '/outputs/logs',
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects a workload.yml missing required fields before touching the network', async () => {
|
|
201
|
+
const yamlPath = join(dir, 'bad.yml');
|
|
202
|
+
writeFileSync(yamlPath, 'image: busybox\n');
|
|
203
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
204
|
+
expect(process.exitCode).toBe(1);
|
|
205
|
+
expect(fallback.callWithFallback).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('builds a body with no BADGR_* env vars when inputs/outputs are omitted', async () => {
|
|
209
|
+
const yamlPath = join(dir, 'workload.yml');
|
|
210
|
+
writeFileSync(yamlPath, `
|
|
211
|
+
name: simple-job
|
|
212
|
+
image: busybox
|
|
213
|
+
command: [echo, hi]
|
|
214
|
+
max_cost: 1
|
|
215
|
+
max_runtime_minutes: 5
|
|
216
|
+
`);
|
|
217
|
+
|
|
218
|
+
let capturedBody;
|
|
219
|
+
fallback.callWithFallback.mockImplementation((endpoint, callOpts, buildBody) => {
|
|
220
|
+
capturedBody = buildBody();
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
deployment_id: 'dep-xyz', receipt_id: 'rcpt-2', gpu_type: 'RTX_4090',
|
|
223
|
+
gpu_count: 1, cost_per_hour: 0.1, provider: 'runpod', tier: '1', status: 'running',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
api.callApi.mockImplementation((path) => {
|
|
227
|
+
if (path.startsWith('/deployments/')) {
|
|
228
|
+
return Promise.resolve({ status: 'succeeded', teardown_ok: 'ok' });
|
|
229
|
+
}
|
|
230
|
+
return Promise.resolve({});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await batchCommand(config, ['run', yamlPath], chalk);
|
|
234
|
+
|
|
235
|
+
expect(capturedBody.env.BADGR_INPUTS_ARCHIVE_URL).toBeUndefined();
|
|
236
|
+
expect(capturedBody.env.BADGR_OUTPUT_PATHS_JSON).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// badgr batch artifacts — clear, actionable failure messages
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
describe('badgr batch artifacts — error clarity', () => {
|
|
245
|
+
it('explains a 404 (no artifact ever uploaded) with likely causes and a next step', async () => {
|
|
246
|
+
const err = new Error('GET /deployments/dep-nofiles/artifacts → HTTP 404: artifact_not_found');
|
|
247
|
+
err.httpStatus = 404;
|
|
248
|
+
api.callApi.mockImplementation((path) => {
|
|
249
|
+
if (path.endsWith('/artifacts')) return Promise.reject(err);
|
|
250
|
+
return Promise.resolve({});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await batchCommand(config, ['artifacts', 'dep-nofiles'], chalk);
|
|
254
|
+
|
|
255
|
+
expect(process.exitCode).toBe(1);
|
|
256
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
257
|
+
expect(logged).toMatch(/No artifact was ever uploaded for run dep-nofiles/);
|
|
258
|
+
expect(logged).toMatch(/badgr batch logs dep-nofiles/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('explains a 410 (expired, 48h retention) plainly', async () => {
|
|
262
|
+
const err = new Error('GET /deployments/dep-old/artifacts → HTTP 410: artifact_expired');
|
|
263
|
+
err.httpStatus = 410;
|
|
264
|
+
api.callApi.mockImplementation((path) => {
|
|
265
|
+
if (path.endsWith('/artifacts')) return Promise.reject(err);
|
|
266
|
+
return Promise.resolve({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await batchCommand(config, ['artifacts', 'dep-old'], chalk);
|
|
270
|
+
|
|
271
|
+
expect(process.exitCode).toBe(1);
|
|
272
|
+
const logged = console.error.mock.calls.flat().join('\n');
|
|
273
|
+
expect(logged).toMatch(/expired \(48h retention\)/);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// badgr batch compare — direct metrics.json files (test scenario 5)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
describe('badgr batch compare', () => {
|
|
282
|
+
it('reports a regression with delta when run B is worse (higher_is_better)', async () => {
|
|
283
|
+
const a = join(dir, 'a.json');
|
|
284
|
+
const b = join(dir, 'b.json');
|
|
285
|
+
writeFileSync(a, JSON.stringify({ pass_rate: 0.92 }));
|
|
286
|
+
writeFileSync(b, JSON.stringify({ pass_rate: 0.87 }));
|
|
287
|
+
|
|
288
|
+
await batchCommand(config, ['compare', a, b], chalk);
|
|
289
|
+
|
|
290
|
+
expect(process.exitCode).toBe(1);
|
|
291
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
292
|
+
expect(logged).toMatch(/Regression/);
|
|
293
|
+
expect(logged).toContain('0.92');
|
|
294
|
+
expect(logged).toContain('0.87');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('reports an improvement when run B is better', async () => {
|
|
298
|
+
const a = join(dir, 'a.json');
|
|
299
|
+
const b = join(dir, 'b.json');
|
|
300
|
+
writeFileSync(a, JSON.stringify({ pass_rate: 0.80 }));
|
|
301
|
+
writeFileSync(b, JSON.stringify({ pass_rate: 0.95 }));
|
|
302
|
+
|
|
303
|
+
await batchCommand(config, ['compare', a, b], chalk);
|
|
304
|
+
|
|
305
|
+
expect(process.exitCode).toBeUndefined();
|
|
306
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
307
|
+
expect(logged).toMatch(/Improvement/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('respects --key and --higher-is-better overrides for raw files', async () => {
|
|
311
|
+
const a = join(dir, 'a.json');
|
|
312
|
+
const b = join(dir, 'b.json');
|
|
313
|
+
writeFileSync(a, JSON.stringify({ error_rate: 0.05 }));
|
|
314
|
+
writeFileSync(b, JSON.stringify({ error_rate: 0.10 }));
|
|
315
|
+
|
|
316
|
+
// error_rate: lower is better, so b (higher) is a regression.
|
|
317
|
+
await batchCommand(config, ['compare', a, b, '--key', 'error_rate', '--higher-is-better', 'false'], chalk);
|
|
318
|
+
|
|
319
|
+
expect(process.exitCode).toBe(1);
|
|
320
|
+
const logged = console.log.mock.calls.flat().join('\n');
|
|
321
|
+
expect(logged).toMatch(/Regression/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('errors out when a run_id has no known success_metric and no local YAML', async () => {
|
|
325
|
+
store.loadStore.mockReturnValueOnce({ receipts: [], deployments: [] });
|
|
326
|
+
await batchCommand(config, ['compare', 'dep-unknown-a', 'dep-unknown-b'], chalk);
|
|
327
|
+
expect(process.exitCode).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr heartbeat — resets an endpoint's idle-timeout clock
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { heartbeatCommand } from '../src/commands/heartbeat.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
heartbeatDeployment: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
findDeployment: vi.fn(() => null),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/config.js', () => ({
|
|
16
|
+
requireApiKey: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import * as api from '../src/api.js';
|
|
20
|
+
import * as store from '../src/store.js';
|
|
21
|
+
|
|
22
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
23
|
+
|
|
24
|
+
const chalk = {
|
|
25
|
+
bold: s => s,
|
|
26
|
+
dim: s => s,
|
|
27
|
+
red: s => s,
|
|
28
|
+
yellow: s => s,
|
|
29
|
+
green: s => s,
|
|
30
|
+
cyan: s => s,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
process.exitCode = undefined;
|
|
35
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
36
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
37
|
+
vi.resetAllMocks();
|
|
38
|
+
store.findDeployment.mockReturnValue(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
process.exitCode = undefined;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('requires a deployment id', async () => {
|
|
47
|
+
await heartbeatCommand(config, [], chalk);
|
|
48
|
+
expect(api.heartbeatDeployment).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('calls heartbeatDeployment and prints confirmation', async () => {
|
|
52
|
+
api.heartbeatDeployment.mockResolvedValue({ deployment_id: 'dep-abc123', last_activity_at: 1700000000 });
|
|
53
|
+
|
|
54
|
+
const lines = [];
|
|
55
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
56
|
+
|
|
57
|
+
await heartbeatCommand(config, ['dep-abc123'], chalk);
|
|
58
|
+
|
|
59
|
+
expect(api.heartbeatDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
|
|
60
|
+
expect(lines.some(l => l.includes('Heartbeat recorded for dep-abc123'))).toBe(true);
|
|
61
|
+
expect(process.exitCode).toBeFalsy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports an error and sets exitCode on failure', async () => {
|
|
65
|
+
api.heartbeatDeployment.mockRejectedValue(new Error('deployment not found'));
|
|
66
|
+
|
|
67
|
+
await heartbeatCommand(config, ['dep-missing'], chalk);
|
|
68
|
+
|
|
69
|
+
expect(process.exitCode).toBe(1);
|
|
70
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr rerun — replays a past job or endpoint with its exact original spec
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { rerunCommand } from '../src/commands/rerun.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
rerunDeployment: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
findDeployment: vi.fn(() => null),
|
|
13
|
+
addDeployment: vi.fn(),
|
|
14
|
+
addReceipt: vi.fn(),
|
|
15
|
+
generateReceiptId: vi.fn(() => 'rcpt-rerun-001'),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../src/config.js', () => ({
|
|
19
|
+
requireApiKey: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import * as api from '../src/api.js';
|
|
23
|
+
import * as store from '../src/store.js';
|
|
24
|
+
|
|
25
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
26
|
+
|
|
27
|
+
const chalk = {
|
|
28
|
+
bold: s => s,
|
|
29
|
+
dim: s => s,
|
|
30
|
+
red: s => s,
|
|
31
|
+
yellow: s => s,
|
|
32
|
+
green: s => s,
|
|
33
|
+
cyan: s => s,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
process.exitCode = undefined;
|
|
38
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
39
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
40
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
41
|
+
vi.resetAllMocks();
|
|
42
|
+
store.findDeployment.mockReturnValue(null);
|
|
43
|
+
store.generateReceiptId.mockReturnValue('rcpt-rerun-001');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
process.exitCode = undefined;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('requires a deployment id', async () => {
|
|
52
|
+
await rerunCommand(config, [], chalk);
|
|
53
|
+
expect(api.rerunDeployment).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('calls rerunDeployment and prints the new deployment id, never removing the source', async () => {
|
|
57
|
+
api.rerunDeployment.mockResolvedValue({
|
|
58
|
+
deployment_id: 'dep-new-002',
|
|
59
|
+
workload_type: 'job',
|
|
60
|
+
gpu_type: 'A100',
|
|
61
|
+
gpu_count: 1,
|
|
62
|
+
status: 'provisioning',
|
|
63
|
+
rerun_of: 'dep-old-001',
|
|
64
|
+
cost_per_hour: 1.9,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const lines = [];
|
|
68
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
69
|
+
|
|
70
|
+
await rerunCommand(config, ['dep-old-001'], chalk);
|
|
71
|
+
|
|
72
|
+
expect(api.rerunDeployment).toHaveBeenCalledWith(config, 'dep-old-001');
|
|
73
|
+
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({ id: 'dep-new-002' }));
|
|
74
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({ rerunOf: 'dep-old-001' }));
|
|
75
|
+
expect(lines.some(l => l.includes('dep-new-002'))).toBe(true);
|
|
76
|
+
expect(process.exitCode).toBeFalsy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('resolves a local name/alias to its deployment id before calling the API', async () => {
|
|
80
|
+
store.findDeployment.mockReturnValue({ id: 'dep-resolved-999' });
|
|
81
|
+
api.rerunDeployment.mockResolvedValue({ deployment_id: 'dep-new-003', workload_type: 'job', status: 'provisioning' });
|
|
82
|
+
|
|
83
|
+
await rerunCommand(config, ['my-job-alias'], chalk);
|
|
84
|
+
|
|
85
|
+
expect(api.rerunDeployment).toHaveBeenCalledWith(config, 'dep-resolved-999');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports an error and sets exitCode on failure', async () => {
|
|
89
|
+
api.rerunDeployment.mockRejectedValue(new Error('deployment not found'));
|
|
90
|
+
|
|
91
|
+
await rerunCommand(config, ['dep-missing'], chalk);
|
|
92
|
+
|
|
93
|
+
expect(process.exitCode).toBe(1);
|
|
94
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr restart — relaunches an endpoint with the same config and API key
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { restartCommand } from '../src/commands/restart.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
restartDeployment: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../src/store.js', () => ({
|
|
12
|
+
findDeployment: vi.fn(() => null),
|
|
13
|
+
removeDeployment: vi.fn(),
|
|
14
|
+
addDeployment: vi.fn(),
|
|
15
|
+
addReceipt: vi.fn(),
|
|
16
|
+
generateReceiptId: vi.fn(() => 'rcpt-restart-001'),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../src/config.js', () => ({
|
|
20
|
+
requireApiKey: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import * as api from '../src/api.js';
|
|
24
|
+
import * as store from '../src/store.js';
|
|
25
|
+
|
|
26
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
27
|
+
|
|
28
|
+
const chalk = {
|
|
29
|
+
bold: s => s,
|
|
30
|
+
dim: s => s,
|
|
31
|
+
red: s => s,
|
|
32
|
+
yellow: s => s,
|
|
33
|
+
green: s => s,
|
|
34
|
+
cyan: s => s,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
process.exitCode = undefined;
|
|
39
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
40
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
41
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
42
|
+
vi.resetAllMocks();
|
|
43
|
+
store.findDeployment.mockReturnValue(null);
|
|
44
|
+
store.generateReceiptId.mockReturnValue('rcpt-restart-001');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('requires a deployment id', async () => {
|
|
53
|
+
await restartCommand(config, [], chalk);
|
|
54
|
+
expect(api.restartDeployment).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('calls restartDeployment and prints the new deployment id and URL', async () => {
|
|
58
|
+
api.restartDeployment.mockResolvedValue({
|
|
59
|
+
deployment_id: 'dep-new-002',
|
|
60
|
+
workload_type: 'endpoint',
|
|
61
|
+
model: 'Qwen/Qwen2.5-7B-Instruct',
|
|
62
|
+
gpu_type: 'L40S',
|
|
63
|
+
gpu_count: 1,
|
|
64
|
+
status: 'provisioning',
|
|
65
|
+
endpoint_url: 'https://dep-new-002.aibadgr.com/v1',
|
|
66
|
+
cost_per_hour: 1.2,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const lines = [];
|
|
70
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
71
|
+
|
|
72
|
+
await restartCommand(config, ['dep-old-001'], chalk);
|
|
73
|
+
|
|
74
|
+
expect(api.restartDeployment).toHaveBeenCalledWith(config, 'dep-old-001');
|
|
75
|
+
expect(store.removeDeployment).toHaveBeenCalledWith('dep-old-001');
|
|
76
|
+
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({ id: 'dep-new-002' }));
|
|
77
|
+
expect(lines.some(l => l.includes('dep-new-002'))).toBe(true);
|
|
78
|
+
expect(lines.some(l => l.includes('https://dep-new-002.aibadgr.com/v1'))).toBe(true);
|
|
79
|
+
expect(process.exitCode).toBeFalsy();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports an error and sets exitCode on failure', async () => {
|
|
83
|
+
api.restartDeployment.mockRejectedValue(new Error('deployment not found'));
|
|
84
|
+
|
|
85
|
+
await restartCommand(config, ['dep-missing'], chalk);
|
|
86
|
+
|
|
87
|
+
expect(process.exitCode).toBe(1);
|
|
88
|
+
});
|
|
@@ -425,6 +425,78 @@ describe('custom image health check', () => {
|
|
|
425
425
|
expect(process.exitCode).toBeFalsy();
|
|
426
426
|
});
|
|
427
427
|
|
|
428
|
+
it('sends --idle-timeout to the backend as idle_timeout_minutes', async () => {
|
|
429
|
+
api.callApi
|
|
430
|
+
.mockResolvedValueOnce(makeServeDep())
|
|
431
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
432
|
+
.mockResolvedValueOnce(readyStatus());
|
|
433
|
+
|
|
434
|
+
const p = serveCommand(
|
|
435
|
+
config,
|
|
436
|
+
['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '30', '--max-cost', '5'],
|
|
437
|
+
chalk,
|
|
438
|
+
);
|
|
439
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
440
|
+
await p;
|
|
441
|
+
|
|
442
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
443
|
+
expect(body.idle_timeout_minutes).toBe(30);
|
|
444
|
+
expect(process.exitCode).toBeFalsy();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('omits idle_timeout_minutes when --idle-timeout is not passed', async () => {
|
|
448
|
+
api.callApi
|
|
449
|
+
.mockResolvedValueOnce(makeServeDep())
|
|
450
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
451
|
+
.mockResolvedValueOnce(readyStatus());
|
|
452
|
+
|
|
453
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
454
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
455
|
+
await p;
|
|
456
|
+
|
|
457
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
458
|
+
expect(body.idle_timeout_minutes).toBeUndefined();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('prints the per-endpoint API key exactly once when the backend returns one', async () => {
|
|
462
|
+
api.callApi
|
|
463
|
+
.mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
|
|
464
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
465
|
+
.mockResolvedValueOnce(readyStatus());
|
|
466
|
+
|
|
467
|
+
const lines = [];
|
|
468
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
469
|
+
|
|
470
|
+
const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
|
|
471
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
472
|
+
await p;
|
|
473
|
+
|
|
474
|
+
const keyLine = lines.find(l => l.includes('API key:'));
|
|
475
|
+
expect(keyLine).toContain('bge_test_key_xyz');
|
|
476
|
+
const sdkLine = lines.find(l => l.includes('api_key='));
|
|
477
|
+
expect(sdkLine).toContain('bge_test_key_xyz');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('prints a heartbeat reminder when --idle-timeout is set', async () => {
|
|
481
|
+
api.callApi
|
|
482
|
+
.mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
|
|
483
|
+
.mockResolvedValueOnce({ status: 'running' })
|
|
484
|
+
.mockResolvedValueOnce(readyStatus());
|
|
485
|
+
|
|
486
|
+
const lines = [];
|
|
487
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
488
|
+
|
|
489
|
+
const p = serveCommand(
|
|
490
|
+
config,
|
|
491
|
+
['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '15', '--max-cost', '5'],
|
|
492
|
+
chalk,
|
|
493
|
+
);
|
|
494
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
495
|
+
await p;
|
|
496
|
+
|
|
497
|
+
expect(lines.some(l => l.includes('badgr heartbeat dep-serve-001'))).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
|
|
428
500
|
it('--health-path also works for model-based serve (overrides /models default)', async () => {
|
|
429
501
|
api.callApi
|
|
430
502
|
.mockResolvedValueOnce(makeServeDep()) // POST /serve
|