badgr-cli 1.0.44 → 1.0.45
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 +178 -240
- package/package.json +1 -1
- package/src/badgr.js +12 -0
- package/src/catalog.js +31 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/run.js +78 -3
- package/src/commands/serve.js +116 -5
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +160 -0
- package/src/store.js +11 -0
- package/tests/detect.test.js +191 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +21 -0
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
package/src/progress.js
CHANGED
|
@@ -40,3 +40,163 @@ export function renderLiveBlock(chalk, { stageLine, elapsedSec, statusWord, spen
|
|
|
40
40
|
chalk.dim(` Stop billing: badgr down ${id}`),
|
|
41
41
|
];
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// GET /v1/jobs/{id} polling — shared by every command that submits through
|
|
46
|
+
// the productized job API (comfy.batch, train.lora, custom.run, model.serve,
|
|
47
|
+
// image.generate) instead of the raw /deployments API. Surfaces the shared
|
|
48
|
+
// progress contract (stage/health/progress_current/progress_total/...) and
|
|
49
|
+
// makes backend failures and poll failures visible instead of swallowing
|
|
50
|
+
// them, per the job-reliability spec.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'canceled']);
|
|
54
|
+
|
|
55
|
+
// After this many consecutive poll failures, warn the user we've lost contact.
|
|
56
|
+
const POLL_FAIL_WARN_THRESHOLD = 3;
|
|
57
|
+
// After this many, stop polling and report clearly instead of hanging forever.
|
|
58
|
+
const POLL_FAIL_GIVEUP_THRESHOLD = 8;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Poll GET /jobs/{id} until it reaches a terminal status (or maxMs elapses).
|
|
62
|
+
* `callApi` is injected (rather than imported) to avoid a circular import
|
|
63
|
+
* with api.js and to keep this module easy to unit test.
|
|
64
|
+
*/
|
|
65
|
+
export async function pollJobUntilTerminal(callApi, config, jobId, { chalk, maxMs, pollMs = 15000 } = {}) {
|
|
66
|
+
const startMs = Date.now();
|
|
67
|
+
let consecutiveFailures = 0;
|
|
68
|
+
let lastDetail = null;
|
|
69
|
+
let warned = false;
|
|
70
|
+
let announcedRetry = false;
|
|
71
|
+
|
|
72
|
+
while (Date.now() - startMs < maxMs) {
|
|
73
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
74
|
+
|
|
75
|
+
let detail;
|
|
76
|
+
try {
|
|
77
|
+
detail = await callApi(`/jobs/${jobId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
consecutiveFailures++;
|
|
80
|
+
if (consecutiveFailures >= POLL_FAIL_WARN_THRESHOLD && !warned) {
|
|
81
|
+
const lostSec = Math.round((consecutiveFailures * pollMs) / 1000);
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
`\n${chalk.yellow(` ⚠ Lost contact with Badgr for ~${lostSec}s (${err.message}). ` +
|
|
84
|
+
`Last known status: ${lastDetail?.status || 'unknown'}`)}\n`
|
|
85
|
+
);
|
|
86
|
+
warned = true;
|
|
87
|
+
}
|
|
88
|
+
if (consecutiveFailures >= POLL_FAIL_GIVEUP_THRESHOLD) {
|
|
89
|
+
return { outcome: 'polling_failed', detail: lastDetail, jobId };
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
consecutiveFailures = 0;
|
|
94
|
+
warned = false;
|
|
95
|
+
lastDetail = detail;
|
|
96
|
+
|
|
97
|
+
// One-time announcement of the automatic retry-on-a-different-route —
|
|
98
|
+
// printed once per retry (not every poll tick) so it reads as an event,
|
|
99
|
+
// not a repeated status line. See job_progress.py's retrying_different_route
|
|
100
|
+
// stage — this is what "one safe retry" looks like from the CLI.
|
|
101
|
+
if (detail.stage === 'retrying_different_route' && !announcedRetry) {
|
|
102
|
+
announcedRetry = true;
|
|
103
|
+
const teardown = detail.teardown_status === 'ok' ? 'succeeded'
|
|
104
|
+
: detail.teardown_status === 'failed' ? 'failed — run `badgr status` to check'
|
|
105
|
+
: 'pending';
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
`\n${chalk.yellow(` ${detail.progress_message || 'Retrying on a different route...'}`)}\n` +
|
|
108
|
+
` ${chalk.dim(`Previous attempt teardown: ${teardown}`)}\n` +
|
|
109
|
+
` ${chalk.dim('Billing: stopped')}\n`
|
|
110
|
+
);
|
|
111
|
+
} else if (detail.stage !== 'retrying_different_route') {
|
|
112
|
+
announcedRetry = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const elapsedSec = detail.elapsed_seconds ?? Math.floor((Date.now() - startMs) / 1000);
|
|
116
|
+
const label = detail.stage_label || detail.status;
|
|
117
|
+
const counter = (detail.progress_current != null && detail.progress_total != null)
|
|
118
|
+
? ` ${detail.progress_current}/${detail.progress_total}${detail.progress_unit ? ' ' + detail.progress_unit : ''}`
|
|
119
|
+
: '';
|
|
120
|
+
const healthWord = detail.health || detail.status;
|
|
121
|
+
const latest = detail.progress_message ? ` Latest: ${detail.progress_message}` : '';
|
|
122
|
+
process.stdout.write(
|
|
123
|
+
`\r\x1b[2K [${label}]${counter} ${elapsedSec}s elapsed Status: ${healthWord}${latest}`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (TERMINAL_JOB_STATUSES.has(detail.status)) {
|
|
127
|
+
process.stdout.write('\n');
|
|
128
|
+
return { outcome: detail.status, detail, jobId };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { outcome: 'timed_out', detail: lastDetail, jobId };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _formatElapsed(seconds) {
|
|
136
|
+
if (seconds == null) return 'unknown';
|
|
137
|
+
const m = Math.floor(seconds / 60);
|
|
138
|
+
const s = Math.floor(seconds % 60);
|
|
139
|
+
return `${m}m ${s}s`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Prints the `Class:`/`Next:` lines for a failed GpuDeployment (the raw
|
|
144
|
+
* /deployments/{id} path used by `badgr run`/`badgr serve`/`badgr comfyui run`)
|
|
145
|
+
* using the exact same failure_class/next_action fields — computed by the
|
|
146
|
+
* same backend/job_progress.py classify_failure()/next_step_for() functions —
|
|
147
|
+
* that renderJobClosingBlock below already prints for the productized
|
|
148
|
+
* /v1/jobs path (comfy.batch, train.lora, custom.run, model.serve). One
|
|
149
|
+
* failure-class vocabulary shown the same way regardless of which API path
|
|
150
|
+
* a command happens to use.
|
|
151
|
+
*/
|
|
152
|
+
export function printFailureClass(chalk, dep) {
|
|
153
|
+
if (!dep?.failure_class) return;
|
|
154
|
+
console.error(chalk.dim(` Class: ${dep.failure_class}`));
|
|
155
|
+
if (dep.next_action) console.error(chalk.dim(` Next: ${dep.next_action}`));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The standard closing block shown by every job-family CLI command
|
|
160
|
+
* (comfy.batch, train.lora, custom.run, model.serve) once GET /jobs/{id}
|
|
161
|
+
* reaches a terminal status — same shape regardless of job type, so a user
|
|
162
|
+
* always sees runtime/teardown/billing/logs/receipt in the same place.
|
|
163
|
+
*/
|
|
164
|
+
export function renderJobClosingBlock(chalk, detail, rcptId) {
|
|
165
|
+
const elapsed = _formatElapsed(detail.elapsed_seconds);
|
|
166
|
+
const teardown = detail.teardown_status === 'ok' ? 'succeeded'
|
|
167
|
+
: detail.teardown_status === 'failed' ? 'failed — run `badgr status` to check'
|
|
168
|
+
: detail.status === 'completed' || detail.status === 'failed' || detail.status === 'canceled' ? 'not required'
|
|
169
|
+
: 'pending';
|
|
170
|
+
const billing = detail.billing_status === 'stopped' ? 'stopped' : 'running';
|
|
171
|
+
|
|
172
|
+
const lines = [];
|
|
173
|
+
if (detail.status === 'completed') {
|
|
174
|
+
lines.push(chalk.green('\n Complete\n'));
|
|
175
|
+
lines.push(` Runtime: ${elapsed}`);
|
|
176
|
+
if (detail.charged_usd != null) lines.push(` Estimated cost: ~$${detail.charged_usd.toFixed(2)}`);
|
|
177
|
+
} else if (detail.status === 'failed') {
|
|
178
|
+
const capped = detail.failure_class === 'runtime_cap_reached' || detail.failure_class === 'spend_cap_reached';
|
|
179
|
+
const headline = detail.error?.message || detail.progress_message || 'Job failed';
|
|
180
|
+
lines.push(chalk[capped ? 'yellow' : 'red'](`\n ${capped ? 'Stopped — limit reached' : 'Failed'} — ${headline}\n`));
|
|
181
|
+
if (detail.failure_class) lines.push(` Class: ${detail.failure_class}`);
|
|
182
|
+
if (detail.stage) lines.push(` Stage: ${detail.stage}`);
|
|
183
|
+
lines.push(` Runtime: ${elapsed}`);
|
|
184
|
+
if (detail.progress_current != null && detail.progress_total != null) {
|
|
185
|
+
lines.push(` Progress: ${detail.progress_current}/${detail.progress_total}${detail.progress_unit ? ' ' + detail.progress_unit : ''}`);
|
|
186
|
+
}
|
|
187
|
+
} else if (detail.status === 'canceled') {
|
|
188
|
+
lines.push(chalk.yellow('\n Cancelled by user\n'));
|
|
189
|
+
lines.push(` Runtime: ${elapsed}`);
|
|
190
|
+
} else {
|
|
191
|
+
lines.push(chalk.yellow(`\n Still running — detached (status: ${detail.status})\n`));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
lines.push(` Teardown: ${teardown}`);
|
|
195
|
+
lines.push(` Billing: ${billing}`);
|
|
196
|
+
lines.push(` Logs: badgr logs ${detail.job_id}`);
|
|
197
|
+
lines.push(` Job ID: ${detail.job_id}`);
|
|
198
|
+
if (rcptId) lines.push(` Receipt: badgr receipts ${rcptId}`);
|
|
199
|
+
if (detail.status === 'failed' && detail.next_action) lines.push(` Next: ${detail.next_action}`);
|
|
200
|
+
|
|
201
|
+
return lines.join('\n') + '\n';
|
|
202
|
+
}
|
package/src/store.js
CHANGED
|
@@ -95,3 +95,14 @@ export function updateReceipt(receiptId, updates, storeFile = STORE_FILE) {
|
|
|
95
95
|
export function listReceipts(limit = 20, storeFile = STORE_FILE) {
|
|
96
96
|
return loadStore(storeFile).receipts.slice(0, limit);
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
// Local receipt IDs (rcpt-...) are minted client-side by job-family commands
|
|
100
|
+
// (badgr comfyui batch, badgr train lora, ...) and are never registered with
|
|
101
|
+
// the backend — they exist only to point back at the job_id that made them.
|
|
102
|
+
// `badgr receipts <id>` needs this lookup so it can fetch the *job's* status
|
|
103
|
+
// instead of hitting the backend's unrelated /v1/receipts (LLM inference
|
|
104
|
+
// receipt ledger), which has never heard of a locally-minted rcpt- id.
|
|
105
|
+
export function findReceipt(idOrJobId, storeFile = STORE_FILE) {
|
|
106
|
+
const { receipts } = loadStore(storeFile);
|
|
107
|
+
return receipts.find(r => r.receiptId === idOrJobId || r.job_id === idOrJobId) ?? null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { detectWorkload, workloadTypeLabel } from '../src/detect.js';
|
|
6
|
+
|
|
7
|
+
let tmpRoot;
|
|
8
|
+
|
|
9
|
+
function makeProject(files) {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-detect-'));
|
|
11
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
12
|
+
const abs = path.join(dir, rel);
|
|
13
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
14
|
+
fs.writeFileSync(abs, content);
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => { tmpRoot = []; });
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const dir of tmpRoot) {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function project(files) {
|
|
27
|
+
const dir = makeProject(files);
|
|
28
|
+
tmpRoot.push(dir);
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('detectWorkload — missing / empty directory', () => {
|
|
33
|
+
it('reports an error without throwing for a missing directory', () => {
|
|
34
|
+
const report = detectWorkload('/nonexistent/badgr-detect-fixture');
|
|
35
|
+
expect(report.error).toBeTruthy();
|
|
36
|
+
expect(report.confidence).toBe('low');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is low confidence with no command for an empty directory', () => {
|
|
40
|
+
const dir = project({});
|
|
41
|
+
const report = detectWorkload(dir);
|
|
42
|
+
expect(report.command).toBeNull();
|
|
43
|
+
expect(report.confidence).toBe('low');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('detectWorkload — plain Python CUDA script', () => {
|
|
48
|
+
it('finds the entry point and requirements, classifies as custom', () => {
|
|
49
|
+
const dir = project({
|
|
50
|
+
'train.py': `
|
|
51
|
+
import torch
|
|
52
|
+
model = torch.nn.Linear(10, 10).cuda()
|
|
53
|
+
for step in range(100):
|
|
54
|
+
optimizer.step()
|
|
55
|
+
`,
|
|
56
|
+
'requirements.txt': 'torch\nnumpy\n',
|
|
57
|
+
'output/.gitkeep': '',
|
|
58
|
+
});
|
|
59
|
+
const report = detectWorkload(dir);
|
|
60
|
+
expect(report.command).toBe('python train.py');
|
|
61
|
+
expect(report.requirements).toContain('requirements.txt');
|
|
62
|
+
expect(report.outputs).toContain('output/');
|
|
63
|
+
expect(report.workloadType).toBe('training');
|
|
64
|
+
expect(['medium', 'high']).toContain(report.confidence);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('detectWorkload — vLLM endpoint', () => {
|
|
69
|
+
it('classifies as endpoint and detects the port', () => {
|
|
70
|
+
const dir = project({
|
|
71
|
+
'server.py': `
|
|
72
|
+
from vllm import LLM
|
|
73
|
+
import uvicorn
|
|
74
|
+
app = None
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
uvicorn.run(app, port=8000)
|
|
78
|
+
`,
|
|
79
|
+
'requirements.txt': 'vllm\nfastapi\nuvicorn\n',
|
|
80
|
+
});
|
|
81
|
+
const report = detectWorkload(dir);
|
|
82
|
+
expect(report.command).toBe('python server.py');
|
|
83
|
+
expect(report.workloadType).toBe('endpoint');
|
|
84
|
+
expect(report.ports).toContain(8000);
|
|
85
|
+
expect(report.healthPath).toBe('/health');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('detectWorkload — LoRA training repo', () => {
|
|
90
|
+
it('classifies as training and estimates high VRAM for a large model', () => {
|
|
91
|
+
const dir = project({
|
|
92
|
+
'train.py': `
|
|
93
|
+
from peft import LoraConfig
|
|
94
|
+
base_model = "meta-llama/Llama-3.1-70B-Instruct"
|
|
95
|
+
trainer = Trainer(model=model)
|
|
96
|
+
trainer.train()
|
|
97
|
+
`,
|
|
98
|
+
'config.yaml': 'base_model: meta-llama/Llama-3.1-70B-Instruct\nlora_r: 16\n',
|
|
99
|
+
'requirements.txt': 'peft\ntransformers\naccelerate\n',
|
|
100
|
+
'checkpoints/.gitkeep': '',
|
|
101
|
+
});
|
|
102
|
+
const report = detectWorkload(dir);
|
|
103
|
+
expect(report.workloadType).toBe('training');
|
|
104
|
+
expect(report.checkpoints).toContain('checkpoints/');
|
|
105
|
+
expect(report.models.some(m => m.includes('Llama-3.1-70B'))).toBe(true);
|
|
106
|
+
expect(report.vram).toBe('80+ GB');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('detectWorkload — ComfyUI-style project', () => {
|
|
111
|
+
it('classifies as image_gen from a workflow json without special-casing ComfyUI by name', () => {
|
|
112
|
+
const dir = project({
|
|
113
|
+
'workflow_api.json': JSON.stringify({ '1': { class_type: 'CheckpointLoaderSimple' } }),
|
|
114
|
+
'requirements.txt': 'torch\npillow\n',
|
|
115
|
+
});
|
|
116
|
+
const report = detectWorkload(dir);
|
|
117
|
+
expect(report.workloadType).toBe('image_gen');
|
|
118
|
+
expect(workloadTypeLabel(report.workloadType)).toMatch(/image generation/);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('detectWorkload — custom Docker image', () => {
|
|
123
|
+
it('detects the Dockerfile and exposed port instead of guessing a base image', () => {
|
|
124
|
+
const dir = project({
|
|
125
|
+
Dockerfile: `
|
|
126
|
+
FROM python:3.11-slim
|
|
127
|
+
COPY . /app
|
|
128
|
+
EXPOSE 7860
|
|
129
|
+
CMD ["python", "app.py"]
|
|
130
|
+
`,
|
|
131
|
+
'app.py': `
|
|
132
|
+
import gradio as gr
|
|
133
|
+
demo = gr.Interface()
|
|
134
|
+
demo.launch(server_port=7860)
|
|
135
|
+
`,
|
|
136
|
+
});
|
|
137
|
+
const report = detectWorkload(dir);
|
|
138
|
+
expect(report.dockerfile).toBe('Dockerfile');
|
|
139
|
+
expect(report.image).toBeNull();
|
|
140
|
+
expect(report.ports).toContain(7860);
|
|
141
|
+
expect(report.workloadType).toBe('endpoint');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('detectWorkload — RunPod-style serverless handler job', () => {
|
|
146
|
+
it('classifies a handler-based job as an endpoint without naming any provider', () => {
|
|
147
|
+
const dir = project({
|
|
148
|
+
'rp_handler.py': `
|
|
149
|
+
import runpod
|
|
150
|
+
|
|
151
|
+
def handler(event):
|
|
152
|
+
return {"output": "ok"}
|
|
153
|
+
|
|
154
|
+
runpod.serverless.start({"handler": handler})
|
|
155
|
+
`,
|
|
156
|
+
'requirements.txt': 'runpod\n',
|
|
157
|
+
});
|
|
158
|
+
const report = detectWorkload(dir);
|
|
159
|
+
expect(report.command).toBe('python rp_handler.py');
|
|
160
|
+
expect(report.workloadType).toBe('endpoint');
|
|
161
|
+
// No provider name should leak into any user-visible field.
|
|
162
|
+
const serialized = JSON.stringify(report).toLowerCase();
|
|
163
|
+
expect(serialized).not.toContain('runpod.com');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('detectWorkload — batch image generation over an input directory', () => {
|
|
168
|
+
it('classifies as batch when the script iterates an input directory', () => {
|
|
169
|
+
const dir = project({
|
|
170
|
+
'generate.py': `
|
|
171
|
+
import os
|
|
172
|
+
for fname in os.listdir("input"):
|
|
173
|
+
process(fname)
|
|
174
|
+
`,
|
|
175
|
+
'input/.gitkeep': '',
|
|
176
|
+
'output/.gitkeep': '',
|
|
177
|
+
});
|
|
178
|
+
const report = detectWorkload(dir);
|
|
179
|
+
expect(report.workloadType).toBe('batch');
|
|
180
|
+
expect(report.inputs).toContain('input/');
|
|
181
|
+
expect(report.outputs).toContain('output/');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('workloadTypeLabel', () => {
|
|
186
|
+
it('never returns a provider-specific label', () => {
|
|
187
|
+
for (const t of ['endpoint', 'training', 'image_gen', 'batch', 'custom', 'unknown']) {
|
|
188
|
+
expect(workloadTypeLabel(t)).not.toMatch(/runpod|vast|hyperstack/i);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { pollJobUntilTerminal, renderJobClosingBlock } from '../src/progress.js';
|
|
3
|
+
|
|
4
|
+
const chalk = new Proxy({}, { get: () => (s) => s });
|
|
5
|
+
const config = { apiKey: 'k', baseUrl: 'https://api.test' };
|
|
6
|
+
|
|
7
|
+
function silenceStdout() {
|
|
8
|
+
return vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('pollJobUntilTerminal', () => {
|
|
12
|
+
it('returns immediately once the job reaches a terminal status', async () => {
|
|
13
|
+
const writeSpy = silenceStdout();
|
|
14
|
+
const detail = { job_id: 'job_1', status: 'completed', stage_label: 'Complete', health: 'completed' };
|
|
15
|
+
const callApi = vi.fn().mockResolvedValue(detail);
|
|
16
|
+
|
|
17
|
+
const result = await pollJobUntilTerminal(callApi, config, 'job_1', { chalk, maxMs: 60_000, pollMs: 1 });
|
|
18
|
+
|
|
19
|
+
expect(result.outcome).toBe('completed');
|
|
20
|
+
expect(result.detail).toBe(detail);
|
|
21
|
+
writeSpy.mockRestore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('surfaces progress fields (stage/current/total) while polling', async () => {
|
|
25
|
+
const writeSpy = silenceStdout();
|
|
26
|
+
const callApi = vi.fn()
|
|
27
|
+
.mockResolvedValueOnce({ job_id: 'job_2', status: 'running', stage_label: 'Generating images', progress_current: 3, progress_total: 10, progress_unit: 'images', health: 'progressing' })
|
|
28
|
+
.mockResolvedValueOnce({ job_id: 'job_2', status: 'completed', stage_label: 'Complete', health: 'completed' });
|
|
29
|
+
|
|
30
|
+
const result = await pollJobUntilTerminal(callApi, config, 'job_2', { chalk, maxMs: 60_000, pollMs: 1 });
|
|
31
|
+
|
|
32
|
+
expect(result.outcome).toBe('completed');
|
|
33
|
+
const output = writeSpy.mock.calls.map(c => c[0]).join('');
|
|
34
|
+
expect(output).toContain('3/10 images');
|
|
35
|
+
writeSpy.mockRestore();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not swallow repeated poll failures silently — gives up and reports polling_failed', async () => {
|
|
39
|
+
const writeSpy = silenceStdout();
|
|
40
|
+
const callApi = vi.fn().mockRejectedValue(new Error('network down'));
|
|
41
|
+
|
|
42
|
+
const result = await pollJobUntilTerminal(callApi, config, 'job_3', { chalk, maxMs: 60_000, pollMs: 1 });
|
|
43
|
+
|
|
44
|
+
expect(result.outcome).toBe('polling_failed');
|
|
45
|
+
// Must have warned the user at least once instead of looping silently.
|
|
46
|
+
const output = writeSpy.mock.calls.map(c => c[0]).join('');
|
|
47
|
+
expect(output).toMatch(/Lost contact/i);
|
|
48
|
+
writeSpy.mockRestore();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('announces a retry-on-a-different-route once, with teardown/billing status', async () => {
|
|
52
|
+
const writeSpy = silenceStdout();
|
|
53
|
+
const callApi = vi.fn()
|
|
54
|
+
.mockResolvedValueOnce({
|
|
55
|
+
job_id: 'job_retry', status: 'running', stage: 'retrying_different_route',
|
|
56
|
+
stage_label: 'Retrying on a different route', health: 'progressing',
|
|
57
|
+
progress_message: 'Retrying on a different route... attempt 2/2',
|
|
58
|
+
teardown_status: 'ok',
|
|
59
|
+
})
|
|
60
|
+
.mockResolvedValueOnce({
|
|
61
|
+
job_id: 'job_retry', status: 'completed', stage_label: 'Complete', health: 'completed',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await pollJobUntilTerminal(callApi, config, 'job_retry', { chalk, maxMs: 60_000, pollMs: 1 });
|
|
65
|
+
|
|
66
|
+
expect(result.outcome).toBe('completed');
|
|
67
|
+
const output = writeSpy.mock.calls.map(c => c[0]).join('');
|
|
68
|
+
expect(output).toContain('Retrying on a different route... attempt 2/2');
|
|
69
|
+
expect(output).toContain('Previous attempt teardown: succeeded');
|
|
70
|
+
expect(output).toContain('Billing: stopped');
|
|
71
|
+
writeSpy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('recovers from transient poll failures once the API responds again', async () => {
|
|
75
|
+
const writeSpy = silenceStdout();
|
|
76
|
+
const callApi = vi.fn()
|
|
77
|
+
.mockRejectedValueOnce(new Error('timeout'))
|
|
78
|
+
.mockRejectedValueOnce(new Error('timeout'))
|
|
79
|
+
.mockResolvedValueOnce({ job_id: 'job_4', status: 'completed', stage_label: 'Complete', health: 'completed' });
|
|
80
|
+
|
|
81
|
+
const result = await pollJobUntilTerminal(callApi, config, 'job_4', { chalk, maxMs: 60_000, pollMs: 1 });
|
|
82
|
+
|
|
83
|
+
expect(result.outcome).toBe('completed');
|
|
84
|
+
writeSpy.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('renderJobClosingBlock', () => {
|
|
89
|
+
it('shows teardown/billing/logs/receipt for a completed job', () => {
|
|
90
|
+
const block = renderJobClosingBlock(chalk, {
|
|
91
|
+
job_id: 'job_5', status: 'completed', elapsed_seconds: 252,
|
|
92
|
+
charged_usd: 0.08, teardown_status: 'ok', billing_status: 'stopped',
|
|
93
|
+
}, 'rcpt-abc');
|
|
94
|
+
|
|
95
|
+
expect(block).toContain('Complete');
|
|
96
|
+
expect(block).toContain('Teardown: succeeded');
|
|
97
|
+
expect(block).toContain('Billing: stopped');
|
|
98
|
+
expect(block).toContain('badgr logs job_5');
|
|
99
|
+
expect(block).toContain('badgr receipts rcpt-abc');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('shows failure class, stage, and next action for a failed job', () => {
|
|
103
|
+
const block = renderJobClosingBlock(chalk, {
|
|
104
|
+
job_id: 'job_6', status: 'failed', elapsed_seconds: 1112,
|
|
105
|
+
error: { code: 'comfy_health_failed', message: 'ComfyUI did not start within 10 minutes.' },
|
|
106
|
+
failure_class: 'health_check_failed', stage: 'comfy_waiting_for_health',
|
|
107
|
+
teardown_status: 'ok', billing_status: 'stopped',
|
|
108
|
+
next_action: 'Run `badgr logs <job_id>` or retry with a longer max-runtime.',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(block).toContain('Failed');
|
|
112
|
+
expect(block).toContain('Class: health_check_failed');
|
|
113
|
+
expect(block).toContain('Stage: comfy_waiting_for_health');
|
|
114
|
+
expect(block).toContain('Next:');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('labels a runtime/spend cap failure as "Stopped — limit reached", not a crash', () => {
|
|
118
|
+
const block = renderJobClosingBlock(chalk, {
|
|
119
|
+
job_id: 'job_7', status: 'failed', elapsed_seconds: 545,
|
|
120
|
+
error: { code: 'max_runtime_reached', message: 'Job reached max runtime and was stopped. Billing ended.' },
|
|
121
|
+
failure_class: 'runtime_cap_reached',
|
|
122
|
+
teardown_status: 'ok', billing_status: 'stopped',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(block).toContain('Stopped — limit reached');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('shows cancelled block distinctly from failed', () => {
|
|
129
|
+
const block = renderJobClosingBlock(chalk, {
|
|
130
|
+
job_id: 'job_8', status: 'canceled', elapsed_seconds: 164,
|
|
131
|
+
teardown_status: 'ok', billing_status: 'stopped',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(block).toContain('Cancelled by user');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -131,6 +131,13 @@ describe('BLESSED_COMFY_WORKFLOWS catalog', () => {
|
|
|
131
131
|
expect(wf.gpu_type).toBe('RTX_4090');
|
|
132
132
|
expect(wf.output_type).toBe('images');
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
it('has flux-basic with gpu_type and output_type', () => {
|
|
136
|
+
const wf = BLESSED_COMFY_WORKFLOWS['flux-basic'];
|
|
137
|
+
expect(wf).toBeDefined();
|
|
138
|
+
expect(wf.gpu_type).toBe('RTX_4090');
|
|
139
|
+
expect(wf.output_type).toBe('images');
|
|
140
|
+
});
|
|
134
141
|
});
|
|
135
142
|
|
|
136
143
|
// ── parseServeArgs alias recognition ─────────────────────────────────────────
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - Receipt explains state → status, exitCode, failureType, runtimeSeconds, finalCost
|
|
16
16
|
*/
|
|
17
17
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
18
|
-
import { runCommand } from '../src/commands/run.js';
|
|
18
|
+
import { runCommand, parseRunArgs } from '../src/commands/run.js';
|
|
19
19
|
|
|
20
20
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
|
21
21
|
|
|
@@ -222,6 +222,26 @@ describe('container fails to start', () => {
|
|
|
222
222
|
|
|
223
223
|
expect(process.exitCode).toBe(1);
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
it('prints the backend failure_class/next_action using the same vocabulary as the job API', async () => {
|
|
227
|
+
api.callApi
|
|
228
|
+
.mockResolvedValueOnce(makeDep({ status: 'provisioning' }))
|
|
229
|
+
.mockResolvedValueOnce({
|
|
230
|
+
status: 'failed',
|
|
231
|
+
failure_class: 'container_start_failed',
|
|
232
|
+
next_action: 'Check the image and command, then retry.',
|
|
233
|
+
});
|
|
234
|
+
const logs = [];
|
|
235
|
+
const origError = console.error;
|
|
236
|
+
console.error = (...args) => { logs.push(args.join(' ')); origError(...args); };
|
|
237
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
238
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
239
|
+
await p;
|
|
240
|
+
console.error = origError;
|
|
241
|
+
|
|
242
|
+
expect(logs.some(l => l.includes('Class: container_start_failed'))).toBe(true);
|
|
243
|
+
expect(logs.some(l => l.includes('Next: Check the image and command, then retry.'))).toBe(true);
|
|
244
|
+
});
|
|
225
245
|
});
|
|
226
246
|
|
|
227
247
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -543,3 +563,93 @@ describe('--dry-run', () => {
|
|
|
543
563
|
expect(api.callApi).not.toHaveBeenCalled();
|
|
544
564
|
});
|
|
545
565
|
});
|
|
566
|
+
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
568
|
+
// 13. Progress-loss handling — --output / --checkpoint / --retry-safe / --resume-cmd
|
|
569
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
describe('--output / --checkpoint / --retry-safe / --resume-cmd', () => {
|
|
572
|
+
it('parseRunArgs parses all four flags', () => {
|
|
573
|
+
const { flags } = parseRunArgs([
|
|
574
|
+
'--output', './outputs', '--checkpoint', './checkpoints', '--retry-safe',
|
|
575
|
+
'--resume-cmd', 'python train.py --resume ./checkpoints/latest',
|
|
576
|
+
'--max-cost', '5', '--', 'python', 'train.py',
|
|
577
|
+
]);
|
|
578
|
+
expect(flags.output).toBe('./outputs');
|
|
579
|
+
expect(flags.checkpoint).toBe('./checkpoints');
|
|
580
|
+
expect(flags.retrySafe).toBe(true);
|
|
581
|
+
expect(flags.resumeCmd).toBe('python train.py --resume ./checkpoints/latest');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('wires --output/--checkpoint/--retry-safe into the job env as BADGR_* vars', async () => {
|
|
585
|
+
api.callApi
|
|
586
|
+
.mockResolvedValueOnce(makeDep())
|
|
587
|
+
.mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
|
|
588
|
+
.mockResolvedValueOnce({ logs: [] });
|
|
589
|
+
const p = runCommand(config, [
|
|
590
|
+
'--output', './outputs', '--checkpoint', './checkpoints', '--retry-safe',
|
|
591
|
+
'--max-cost', '5', '--', 'python', 'train.py',
|
|
592
|
+
], chalk);
|
|
593
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
594
|
+
await p;
|
|
595
|
+
|
|
596
|
+
const postRunBody = api.callApi.mock.calls[0][1].body;
|
|
597
|
+
expect(postRunBody.env).toEqual(expect.objectContaining({
|
|
598
|
+
BADGR_OUTPUT_DIR: './outputs',
|
|
599
|
+
BADGR_CHECKPOINT_DIR: './checkpoints',
|
|
600
|
+
BADGR_RETRY_SAFE: '1',
|
|
601
|
+
}));
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('explicit --env overrides the BADGR_* convention var for the same key', async () => {
|
|
605
|
+
api.callApi
|
|
606
|
+
.mockResolvedValueOnce(makeDep())
|
|
607
|
+
.mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
|
|
608
|
+
.mockResolvedValueOnce({ logs: [] });
|
|
609
|
+
const p = runCommand(config, [
|
|
610
|
+
'--output', './outputs', '--env', 'BADGR_OUTPUT_DIR=./custom-outputs',
|
|
611
|
+
'--max-cost', '5', '--', 'python', 'train.py',
|
|
612
|
+
], chalk);
|
|
613
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
614
|
+
await p;
|
|
615
|
+
|
|
616
|
+
const postRunBody = api.callApi.mock.calls[0][1].body;
|
|
617
|
+
expect(postRunBody.env.BADGR_OUTPUT_DIR).toBe('./custom-outputs');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('prints the --resume-cmd hint when the job fails', async () => {
|
|
621
|
+
api.callApi
|
|
622
|
+
.mockResolvedValueOnce(makeDep())
|
|
623
|
+
.mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
|
|
624
|
+
.mockResolvedValueOnce({ logs: [] });
|
|
625
|
+
const logs = [];
|
|
626
|
+
const captureChalk = { bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s };
|
|
627
|
+
const origLog = console.log;
|
|
628
|
+
console.log = (...args) => { logs.push(args.join(' ')); origLog(...args); };
|
|
629
|
+
const p = runCommand(config, [
|
|
630
|
+
'--resume-cmd', 'python train.py --resume ./checkpoints/latest',
|
|
631
|
+
'--max-cost', '5', '--', 'python', 'train.py',
|
|
632
|
+
], captureChalk);
|
|
633
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
634
|
+
await p;
|
|
635
|
+
console.log = origLog;
|
|
636
|
+
|
|
637
|
+
expect(logs.some(l => l.includes('python train.py --resume ./checkpoints/latest'))).toBe(true);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('does not print a Resume line when --resume-cmd was not given', async () => {
|
|
641
|
+
api.callApi
|
|
642
|
+
.mockResolvedValueOnce(makeDep())
|
|
643
|
+
.mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
|
|
644
|
+
.mockResolvedValueOnce({ logs: [] });
|
|
645
|
+
const logs = [];
|
|
646
|
+
const origLog = console.log;
|
|
647
|
+
console.log = (...args) => { logs.push(args.join(' ')); origLog(...args); };
|
|
648
|
+
const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
|
|
649
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
650
|
+
await p;
|
|
651
|
+
console.log = origLog;
|
|
652
|
+
|
|
653
|
+
expect(logs.some(l => l.includes('Resume:'))).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
});
|