badgr-cli 1.0.44 → 1.0.46
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/api.js +18 -0
- package/src/badgr.js +18 -0
- package/src/catalog.js +31 -0
- package/src/commands/comfyui.js +70 -56
- package/src/commands/detect.js +58 -0
- package/src/commands/heartbeat.js +38 -0
- package/src/commands/receipts.js +39 -2
- package/src/commands/restart.js +74 -0
- package/src/commands/run.js +78 -3
- package/src/commands/serve.js +142 -9
- 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/heartbeat.test.js +70 -0
- package/tests/job-progress-poll.test.js +136 -0
- package/tests/productized-runners.test.js +7 -0
- package/tests/restart.test.js +88 -0
- package/tests/run-lifecycle.test.js +111 -1
- package/tests/serve-apps.test.js +189 -0
- package/tests/serve-lifecycle.test.js +93 -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/detect.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workload detection — infer how to run an arbitrary GPU project from its
|
|
3
|
+
* files, without asking the user to describe their infrastructure.
|
|
4
|
+
*
|
|
5
|
+
* Domain-agnostic by design: no code path here is specific to any one
|
|
6
|
+
* framework (ComfyUI, vLLM, RunPod, etc.) or provider. Detection works off
|
|
7
|
+
* generic, corroborating file/content signals — entry-point filenames,
|
|
8
|
+
* dependency manifests, port/health conventions, model-reference patterns —
|
|
9
|
+
* so the same engine covers custom scripts, endpoints, batch jobs, and
|
|
10
|
+
* training repos alike. Framework-specific signals are just additional
|
|
11
|
+
* evidence fed into the same generic scoring, never a special-cased branch.
|
|
12
|
+
*
|
|
13
|
+
* Pure and local: reads files under the target directory only, makes no
|
|
14
|
+
* network calls, and never reveals provider/routing details — that's the
|
|
15
|
+
* caller's job (see commands/detect.js and commands/run.js).
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
// Mirrors _ZIP_EXCLUDES in commands/run.js — directories we never walk into.
|
|
21
|
+
const SKIP_DIRS = new Set([
|
|
22
|
+
'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env',
|
|
23
|
+
'dist', 'build', '.next', '.nuxt', 'coverage', '.pytest_cache',
|
|
24
|
+
'.mypy_cache', '.ruff_cache', '.DS_Store', '.idea', '.vscode',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const MAX_READ_BYTES = 200_000; // don't slurp huge files for signal-scanning
|
|
28
|
+
const MAX_WALK_ENTRIES = 5000; // safety bound on very large trees
|
|
29
|
+
|
|
30
|
+
function safeReadText(absPath) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.statSync(absPath);
|
|
33
|
+
if (!stat.isFile() || stat.size > 5_000_000) return '';
|
|
34
|
+
const fd = fs.openSync(absPath, 'r');
|
|
35
|
+
const len = Math.min(stat.size, MAX_READ_BYTES);
|
|
36
|
+
const buf = Buffer.alloc(len);
|
|
37
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
38
|
+
fs.closeSync(fd);
|
|
39
|
+
return buf.toString('utf8');
|
|
40
|
+
} catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function walk(dir, files = [], depth = 0) {
|
|
46
|
+
if (files.length >= MAX_WALK_ENTRIES || depth > 8) return files;
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (files.length >= MAX_WALK_ENTRIES) break;
|
|
55
|
+
if (entry.name.startsWith('.') && !['.env.example'].includes(entry.name)) {
|
|
56
|
+
if (entry.isDirectory()) continue;
|
|
57
|
+
}
|
|
58
|
+
const abs = path.join(dir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
61
|
+
walk(abs, files, depth + 1);
|
|
62
|
+
} else {
|
|
63
|
+
files.push(abs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Signal catalogs (generic — ordered by how strongly they imply an entry point) ──
|
|
70
|
+
|
|
71
|
+
const ENTRYPOINT_CANDIDATES = [
|
|
72
|
+
'main.py', 'app.py', 'server.py', 'run.py', 'predict.py', 'inference.py',
|
|
73
|
+
'handler.py', 'rp_handler.py', 'worker.py', 'train.py', 'finetune.py',
|
|
74
|
+
'generate.py', 'transcribe.py', 'index.js', 'server.js', 'main.js',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const REQUIREMENTS_FILES = [
|
|
78
|
+
'requirements.txt', 'pyproject.toml', 'environment.yml', 'environment.yaml',
|
|
79
|
+
'Pipfile', 'package.json', 'setup.py', 'poetry.lock',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const MODEL_DIR_NAMES = ['models', 'checkpoints', 'ckpt', 'weights', 'model'];
|
|
83
|
+
const INPUT_DIR_NAMES = ['input', 'inputs', 'data', 'dataset', 'datasets'];
|
|
84
|
+
const OUTPUT_DIR_NAMES = ['output', 'outputs', 'out', 'results', 'generated'];
|
|
85
|
+
const CHECKPOINT_DIR_NAMES = ['checkpoints', 'checkpoint', 'ckpt', 'ckpts'];
|
|
86
|
+
|
|
87
|
+
const HF_REPO_RE = /\b[a-zA-Z0-9][\w.-]{1,60}\/[a-zA-Z0-9][\w.-]{1,80}\b/g;
|
|
88
|
+
const WEIGHT_FILE_RE = /[\w.-]+\.(safetensors|ckpt|gguf|bin|pt|pth|onnx)\b/gi;
|
|
89
|
+
const PARAM_SIZE_RE = /\b(\d{1,3})\s*[bB]\b/g;
|
|
90
|
+
|
|
91
|
+
// ── File-content sniffers ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function detectPorts(files) {
|
|
94
|
+
const ports = new Set();
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
const base = path.basename(f);
|
|
97
|
+
const text = base === 'Dockerfile' || /\.(py|js|ts|toml|ya?ml)$/.test(base)
|
|
98
|
+
? safeReadText(f) : '';
|
|
99
|
+
if (!text) continue;
|
|
100
|
+
for (const m of text.matchAll(/\bEXPOSE\s+(\d{2,5})/gi)) ports.add(Number(m[1]));
|
|
101
|
+
for (const m of text.matchAll(/\bport\s*[=:]\s*(\d{2,5})\b/gi)) ports.add(Number(m[1]));
|
|
102
|
+
for (const m of text.matchAll(/--port[= ](\d{2,5})/g)) ports.add(Number(m[1]));
|
|
103
|
+
}
|
|
104
|
+
return [...ports];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function detectModelRefs(files) {
|
|
108
|
+
const refs = new Set();
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
const base = path.basename(f);
|
|
111
|
+
if (!/\.(py|json|ya?ml|toml|txt|md)$/.test(base)) continue;
|
|
112
|
+
const text = safeReadText(f);
|
|
113
|
+
if (!text) continue;
|
|
114
|
+
for (const m of text.matchAll(WEIGHT_FILE_RE)) refs.add(m[0]);
|
|
115
|
+
for (const m of text.matchAll(/\b(base_model|model_name|model_id|MODEL_NAME|--model)\s*[:=]\s*["']?([\w./-]+)["']?/g)) {
|
|
116
|
+
refs.add(m[2]);
|
|
117
|
+
}
|
|
118
|
+
// Hugging Face "org/name" style refs — only keep ones that look plausible
|
|
119
|
+
// (avoid matching file paths like src/foo by requiring at least one dot-free segment
|
|
120
|
+
// and no leading './').
|
|
121
|
+
for (const m of text.matchAll(HF_REPO_RE)) {
|
|
122
|
+
const val = m[0];
|
|
123
|
+
if (val.startsWith('.') || val.includes('://') || /\.(py|js|ts|json|ya?ml|txt|md|toml)$/.test(val)) continue;
|
|
124
|
+
if (/^(https?|import|from|src|lib|utils|tests?|scripts?|docs?)\//.test(val)) continue;
|
|
125
|
+
if (val.split('/').length !== 2) continue;
|
|
126
|
+
refs.add(val);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...refs].slice(0, 20);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function listDirsMatching(rootFiles, rootDir, names) {
|
|
133
|
+
const found = new Set();
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const abs = path.join(rootDir, name);
|
|
136
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) found.add(name + '/');
|
|
137
|
+
}
|
|
138
|
+
return [...found];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function estimateVramFromModelRefs(refs, workloadType) {
|
|
142
|
+
let maxB = 0;
|
|
143
|
+
for (const ref of refs) {
|
|
144
|
+
for (const m of ref.matchAll(PARAM_SIZE_RE)) {
|
|
145
|
+
const n = parseInt(m[1], 10);
|
|
146
|
+
if (n > maxB && n <= 700) maxB = n;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (maxB > 0) {
|
|
150
|
+
if (maxB <= 3) return '8+ GB';
|
|
151
|
+
if (maxB <= 8) return '16+ GB';
|
|
152
|
+
if (maxB <= 14) return '24+ GB';
|
|
153
|
+
if (maxB <= 34) return '40+ GB';
|
|
154
|
+
return '80+ GB';
|
|
155
|
+
}
|
|
156
|
+
const defaults = {
|
|
157
|
+
training: '40+ GB',
|
|
158
|
+
image_gen: '16+ GB',
|
|
159
|
+
endpoint: '24+ GB',
|
|
160
|
+
batch: '16+ GB',
|
|
161
|
+
custom: '16+ GB',
|
|
162
|
+
};
|
|
163
|
+
return defaults[workloadType] || '16+ GB';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function estimateRuntimeMinutes(workloadType) {
|
|
167
|
+
if (workloadType === 'endpoint') return null; // runs until stopped, not a fixed job
|
|
168
|
+
const defaults = {
|
|
169
|
+
training: 120,
|
|
170
|
+
image_gen: 20,
|
|
171
|
+
batch: 30,
|
|
172
|
+
custom: 30,
|
|
173
|
+
};
|
|
174
|
+
return defaults[workloadType] ?? 30;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Entry-point + workload-type inference ────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function pickEntrypoint(rootDir, allFiles) {
|
|
180
|
+
const relTop = allFiles
|
|
181
|
+
.filter(f => path.dirname(f) === rootDir)
|
|
182
|
+
.map(f => path.basename(f));
|
|
183
|
+
|
|
184
|
+
for (const candidate of ENTRYPOINT_CANDIDATES) {
|
|
185
|
+
if (relTop.includes(candidate)) {
|
|
186
|
+
return { file: candidate, confidenceBoost: 2 };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Fall back to *any* single top-level .py/.js file if there's exactly one —
|
|
190
|
+
// common for "plain script" projects with no conventional entry-point name.
|
|
191
|
+
const topScripts = relTop.filter(f => /\.(py|js)$/.test(f));
|
|
192
|
+
if (topScripts.length === 1) {
|
|
193
|
+
return { file: topScripts[0], confidenceBoost: 1 };
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function classifyWorkload({ entrypointText, hasWorkflowJson, hasDockerfile, hasTrainSignal, hasServeSignal, hasHandlerSignal, ports }) {
|
|
199
|
+
const signals = [];
|
|
200
|
+
let type = 'custom';
|
|
201
|
+
let score = 0;
|
|
202
|
+
|
|
203
|
+
if (hasHandlerSignal) {
|
|
204
|
+
signals.push('serverless-style handler function detected');
|
|
205
|
+
type = 'endpoint';
|
|
206
|
+
score += 2;
|
|
207
|
+
}
|
|
208
|
+
if (hasServeSignal || ports.length > 0) {
|
|
209
|
+
signals.push(ports.length > 0 ? `listens on port ${ports[0]}` : 'starts an HTTP server');
|
|
210
|
+
type = 'endpoint';
|
|
211
|
+
score += 2;
|
|
212
|
+
}
|
|
213
|
+
if (hasTrainSignal) {
|
|
214
|
+
signals.push('training/fine-tuning loop detected');
|
|
215
|
+
type = 'training';
|
|
216
|
+
score += 2;
|
|
217
|
+
}
|
|
218
|
+
if (hasWorkflowJson) {
|
|
219
|
+
signals.push('image-generation workflow file detected');
|
|
220
|
+
if (type === 'custom') type = 'image_gen';
|
|
221
|
+
score += 2;
|
|
222
|
+
}
|
|
223
|
+
if (hasDockerfile) {
|
|
224
|
+
signals.push('Dockerfile present');
|
|
225
|
+
score += 1;
|
|
226
|
+
}
|
|
227
|
+
if (/for\s+\w+\s+in\s+.*(os\.listdir|glob\(|Path\(.*\)\.iterdir)/.test(entrypointText || '')) {
|
|
228
|
+
signals.push('iterates over an input directory');
|
|
229
|
+
if (type === 'custom') type = 'batch';
|
|
230
|
+
score += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { type, score, signals };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Detect the shape of a GPU workload from a local project directory.
|
|
238
|
+
* Returns a plain object describing what was found — never throws for a
|
|
239
|
+
* missing/empty directory, so callers can always render a report.
|
|
240
|
+
*/
|
|
241
|
+
export function detectWorkload(dirPath) {
|
|
242
|
+
const rootDir = path.resolve(dirPath);
|
|
243
|
+
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
|
|
244
|
+
return {
|
|
245
|
+
rootDir,
|
|
246
|
+
workloadType: 'custom',
|
|
247
|
+
confidence: 'low',
|
|
248
|
+
command: null,
|
|
249
|
+
image: null,
|
|
250
|
+
dockerfile: null,
|
|
251
|
+
requirements: [],
|
|
252
|
+
models: [],
|
|
253
|
+
inputs: [],
|
|
254
|
+
outputs: [],
|
|
255
|
+
checkpoints: [],
|
|
256
|
+
ports: [],
|
|
257
|
+
healthPath: null,
|
|
258
|
+
vram: '16+ GB',
|
|
259
|
+
runtimeEstimateMinutes: 30,
|
|
260
|
+
cacheableAssets: [],
|
|
261
|
+
signals: [],
|
|
262
|
+
error: 'directory not found',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const allFiles = walk(rootDir);
|
|
267
|
+
|
|
268
|
+
const dockerfilePath = allFiles.find(f => path.basename(f) === 'Dockerfile' && path.dirname(f) === rootDir);
|
|
269
|
+
const dockerfileText = dockerfilePath ? safeReadText(dockerfilePath) : '';
|
|
270
|
+
|
|
271
|
+
const requirements = REQUIREMENTS_FILES.filter(name =>
|
|
272
|
+
allFiles.some(f => path.basename(f) === name && path.dirname(f) === rootDir));
|
|
273
|
+
|
|
274
|
+
const workflowJson = allFiles.find(f =>
|
|
275
|
+
/workflow.*\.json$/i.test(path.basename(f)) && path.dirname(f) === rootDir);
|
|
276
|
+
|
|
277
|
+
const entry = pickEntrypoint(rootDir, allFiles);
|
|
278
|
+
const entrypointText = entry ? safeReadText(path.join(rootDir, entry.file)) : '';
|
|
279
|
+
const combinedText = entrypointText + '\n' + dockerfileText;
|
|
280
|
+
|
|
281
|
+
const hasServeSignal = /(uvicorn|fastapi|flask|app\.run\(|vllm\s+serve|VLLM|litserve|gradio|\.listen\()/i.test(combinedText);
|
|
282
|
+
const hasHandlerSignal = /(runpod\.serverless\.start|def\s+handler\s*\(|exports\.handler\s*=)/i.test(combinedText);
|
|
283
|
+
const hasTrainSignal = /(\.backward\(\)|optimizer\.step\(|Trainer\(|axolotl|lora|qlora|peft|accelerate|kohya)/i.test(combinedText) ||
|
|
284
|
+
(entry && /train|finetune/i.test(entry.file));
|
|
285
|
+
const ports = detectPorts([dockerfilePath, entry ? path.join(rootDir, entry.file) : null].filter(Boolean));
|
|
286
|
+
|
|
287
|
+
const { type: workloadType, score, signals } = classifyWorkload({
|
|
288
|
+
entrypointText: combinedText,
|
|
289
|
+
hasWorkflowJson: Boolean(workflowJson),
|
|
290
|
+
hasDockerfile: Boolean(dockerfilePath),
|
|
291
|
+
hasTrainSignal,
|
|
292
|
+
hasServeSignal,
|
|
293
|
+
hasHandlerSignal,
|
|
294
|
+
ports,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const models = detectModelRefs([
|
|
298
|
+
...(entry ? [path.join(rootDir, entry.file)] : []),
|
|
299
|
+
...(workflowJson ? [workflowJson] : []),
|
|
300
|
+
...allFiles.filter(f => /\.ya?ml$/i.test(f) && path.dirname(f) === rootDir),
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
const inputs = listDirsMatching(allFiles, rootDir, INPUT_DIR_NAMES);
|
|
304
|
+
const outputs = listDirsMatching(allFiles, rootDir, OUTPUT_DIR_NAMES);
|
|
305
|
+
const checkpoints = listDirsMatching(allFiles, rootDir, CHECKPOINT_DIR_NAMES);
|
|
306
|
+
const modelDirs = listDirsMatching(allFiles, rootDir, MODEL_DIR_NAMES);
|
|
307
|
+
|
|
308
|
+
let command = null;
|
|
309
|
+
if (entry) {
|
|
310
|
+
if (/\.py$/.test(entry.file)) command = `python ${entry.file}`;
|
|
311
|
+
else if (/\.js$/.test(entry.file)) command = `node ${entry.file}`;
|
|
312
|
+
} else if (workflowJson) {
|
|
313
|
+
command = `python main.py --workflow ${path.basename(workflowJson)}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let confidenceScore = score + (entry ? entry.confidenceBoost : 0) + (requirements.length > 0 ? 1 : 0);
|
|
317
|
+
let confidence = 'low';
|
|
318
|
+
if (confidenceScore >= 4) confidence = 'high';
|
|
319
|
+
else if (confidenceScore >= 2) confidence = 'medium';
|
|
320
|
+
|
|
321
|
+
if (entry) signals.push(`entry point: ${entry.file}`);
|
|
322
|
+
if (requirements.length) signals.push(`dependency manifest: ${requirements.join(', ')}`);
|
|
323
|
+
if (!entry && !workflowJson) signals.push('no recognizable entry point — command must be supplied');
|
|
324
|
+
|
|
325
|
+
const healthPath = hasHandlerSignal ? null : (hasServeSignal ? '/health' : null);
|
|
326
|
+
|
|
327
|
+
const cacheableAssets = [
|
|
328
|
+
...modelDirs,
|
|
329
|
+
...(requirements.length ? requirements : []),
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
rootDir,
|
|
334
|
+
workloadType,
|
|
335
|
+
confidence,
|
|
336
|
+
command,
|
|
337
|
+
image: dockerfilePath ? null : undefined, // Dockerfile present → image is built from it, not picked
|
|
338
|
+
dockerfile: dockerfilePath ? path.relative(rootDir, dockerfilePath) : null,
|
|
339
|
+
requirements,
|
|
340
|
+
models,
|
|
341
|
+
inputs,
|
|
342
|
+
outputs,
|
|
343
|
+
checkpoints,
|
|
344
|
+
ports,
|
|
345
|
+
healthPath,
|
|
346
|
+
vram: estimateVramFromModelRefs(models, workloadType),
|
|
347
|
+
runtimeEstimateMinutes: estimateRuntimeMinutes(workloadType),
|
|
348
|
+
cacheableAssets,
|
|
349
|
+
signals,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Human label for a workload type — used by both `detect` and `run` reports. */
|
|
354
|
+
export function workloadTypeLabel(type) {
|
|
355
|
+
return {
|
|
356
|
+
endpoint: 'endpoint',
|
|
357
|
+
training: 'training',
|
|
358
|
+
image_gen: 'batch (image generation)',
|
|
359
|
+
batch: 'batch',
|
|
360
|
+
custom: 'custom GPU job',
|
|
361
|
+
}[type] || 'custom GPU job';
|
|
362
|
+
}
|
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
|
+
}
|