badgr-cli 1.0.43 → 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 +180 -230
- package/package.json +1 -1
- package/src/badgr.js +12 -0
- package/src/catalog.js +46 -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 +150 -68
- package/src/commands/serve.js +278 -115
- package/src/commands/train.js +22 -27
- package/src/detect.js +362 -0
- package/src/progress.js +202 -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 +116 -2
- package/tests/store.test.js +22 -1
- package/tests/template.test.js +4 -4
- package/tests/workload-templates.test.js +22 -0
package/src/commands/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { addReceipt, updateReceipt, generateReceiptId } from '../store.js';
|
|
|
8
8
|
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
9
9
|
import { formatCliError } from '../errors.js';
|
|
10
10
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
11
|
+
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass } from '../progress.js';
|
|
12
|
+
import { detectWorkload, workloadTypeLabel } from '../detect.js';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Flow 1 — local project (primary):
|
|
@@ -55,6 +57,10 @@ export function parseRunArgs(args) {
|
|
|
55
57
|
if (flagArgs[i] === '--save') { flags.save = flagArgs[++i]; i++; continue; }
|
|
56
58
|
if (flagArgs[i] === '--workspace') { flags.workspace = flagArgs[++i]; i++; continue; }
|
|
57
59
|
if (flagArgs[i] === '--cmd') { flags.cmd = flagArgs[++i]; i++; continue; }
|
|
60
|
+
if (flagArgs[i] === '--output') { flags.output = flagArgs[++i]; i++; continue; }
|
|
61
|
+
if (flagArgs[i] === '--checkpoint') { flags.checkpoint = flagArgs[++i]; i++; continue; }
|
|
62
|
+
if (flagArgs[i] === '--retry-safe') { flags.retrySafe = true; i++; continue; }
|
|
63
|
+
if (flagArgs[i] === '--resume-cmd') { flags.resumeCmd = flagArgs[++i]; i++; continue; }
|
|
58
64
|
if (flagArgs[i] === '--env') {
|
|
59
65
|
const kv = flagArgs[++i]; i++;
|
|
60
66
|
if (!flags.env) flags.env = [];
|
|
@@ -105,6 +111,20 @@ const HEARTBEAT_WARN_POLLS = 3;
|
|
|
105
111
|
const HEARTBEAT_KILL_POLLS = 15;
|
|
106
112
|
const STARTUP_STATES = new Set(['queued', 'provisioning', 'starting']);
|
|
107
113
|
|
|
114
|
+
// Shared closing block for every terminal path (success, failure, cap, heartbeat loss) —
|
|
115
|
+
// always states exit code (when known), whether teardown/billing succeeded, and how to
|
|
116
|
+
// pull the receipt, so the user is never left guessing what happened.
|
|
117
|
+
function _printFinalInfo(chalk, { exitCode = undefined, teardownOk, jobId, rcptId, logsAvailable = true }) {
|
|
118
|
+
if (exitCode !== null && exitCode !== undefined) {
|
|
119
|
+
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
120
|
+
}
|
|
121
|
+
console.log(` ${chalk.bold('Teardown:')} ${teardownOk ? chalk.green('succeeded') : chalk.red(`failed — run: badgr down ${jobId}`)}`);
|
|
122
|
+
console.log(` ${chalk.bold('Billing:')} ${teardownOk ? 'stopped' : 'unconfirmed — check receipt'}`);
|
|
123
|
+
if (logsAvailable) console.log(` ${chalk.bold('Logs:')} badgr logs ${jobId}`);
|
|
124
|
+
console.log(` ${chalk.bold('Job ID:')} ${jobId}`);
|
|
125
|
+
console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
export function classifyFailure(finalStatus, exitCode) {
|
|
109
129
|
if (finalStatus === 'failed' && (exitCode === null || exitCode === undefined)) return 'infrastructure';
|
|
110
130
|
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'customer_code';
|
|
@@ -128,24 +148,6 @@ function parseProviderLine(line) {
|
|
|
128
148
|
};
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost }) {
|
|
132
|
-
const spent = ratePerHour * (elapsedMs / 3_600_000);
|
|
133
|
-
const parts = [`⏱ ${fmtRuntime(elapsedMs)}`];
|
|
134
|
-
if (ratePerHour > 0) parts.push(`$${spent.toFixed(4)} spent`);
|
|
135
|
-
if (gpuUtil !== null) parts.push(`GPU ${gpuUtil.toFixed(0)}%`);
|
|
136
|
-
if (cpuUtil !== null) parts.push(`CPU ${cpuUtil.toFixed(0)}%`);
|
|
137
|
-
if (maxRuntimeMs) {
|
|
138
|
-
const left = Math.max(0, maxRuntimeMs - elapsedMs);
|
|
139
|
-
parts.push(`${fmtRuntime(left)} left`);
|
|
140
|
-
}
|
|
141
|
-
if (maxCost && ratePerHour > 0) {
|
|
142
|
-
const budgetLeft = Math.max(0, maxCost - spent);
|
|
143
|
-
parts.push(`$${budgetLeft.toFixed(4)} budget left`);
|
|
144
|
-
}
|
|
145
|
-
parts.push('Ctrl+C to stop');
|
|
146
|
-
return chalk.dim(' ' + parts.join(' • '));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
151
|
// Wait for status to leave 'starting'/'queued'/'provisioning'.
|
|
150
152
|
// Returns the dep once it leaves startup states (or the last known state on timeout).
|
|
151
153
|
async function waitForRunning(config, depId, chalk) {
|
|
@@ -200,7 +202,7 @@ async function waitForRunning(config, depId, chalk) {
|
|
|
200
202
|
});
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown }) {
|
|
205
|
+
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown, isShuttingDown, stageLine }) {
|
|
204
206
|
const TERMINAL = new Set(['stopped', 'failed', 'completed', 'succeeded']);
|
|
205
207
|
const POLL_MS = 4000;
|
|
206
208
|
let seenContent = new Set();
|
|
@@ -209,7 +211,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
209
211
|
let gpuUtil = null;
|
|
210
212
|
let cpuUtil = null;
|
|
211
213
|
let sshShown = false;
|
|
212
|
-
let
|
|
214
|
+
let statusWord = 'running';
|
|
215
|
+
let blockLines = 0;
|
|
213
216
|
const startMs = Date.now();
|
|
214
217
|
|
|
215
218
|
// tearing: guards against double-teardown for cap/heartbeat paths within this function.
|
|
@@ -218,17 +221,17 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
218
221
|
let tickerInterval = null;
|
|
219
222
|
const startTicker = () => {
|
|
220
223
|
if (tickerInterval) return;
|
|
221
|
-
statusBarActive = true;
|
|
222
224
|
tickerInterval = setInterval(() => {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
const elapsedSec = Math.round((Date.now() - startMs) / 1000);
|
|
226
|
+
const spend = ratePerHour * (elapsedSec / 3600);
|
|
227
|
+
blockLines = _writeBlock(blockLines, _renderLiveBlock(chalk, {
|
|
228
|
+
stageLine, elapsedSec, statusWord, spend, id: depId,
|
|
229
|
+
}));
|
|
227
230
|
}, 1000);
|
|
228
231
|
};
|
|
229
232
|
const stopTicker = () => {
|
|
230
233
|
if (tickerInterval) { clearInterval(tickerInterval); tickerInterval = null; }
|
|
231
|
-
if (
|
|
234
|
+
if (blockLines > 0) { _clearBlock(blockLines); blockLines = 0; }
|
|
232
235
|
};
|
|
233
236
|
|
|
234
237
|
try {
|
|
@@ -271,6 +274,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
271
274
|
consecutiveErrs++;
|
|
272
275
|
if (lastStatus === 'running') {
|
|
273
276
|
const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
|
|
277
|
+
if (consecutiveErrs >= HEARTBEAT_WARN_POLLS) statusWord = 'stuck';
|
|
274
278
|
if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
|
|
275
279
|
stopTicker();
|
|
276
280
|
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
|
|
@@ -293,6 +297,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
293
297
|
}
|
|
294
298
|
lastStatus = status;
|
|
295
299
|
}
|
|
300
|
+
if (status === 'running') statusWord = seenContent.size === 0 ? 'no logs yet' : 'running';
|
|
296
301
|
|
|
297
302
|
try {
|
|
298
303
|
const logData = await callApi(`/deployments/${depId}/logs`, {
|
|
@@ -339,6 +344,8 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
339
344
|
exitCode,
|
|
340
345
|
runtimeMs: Date.now() - startMs,
|
|
341
346
|
failureType: classifyFailure(status, exitCode),
|
|
347
|
+
failureClass: dep.failure_class ?? null,
|
|
348
|
+
nextAction: dep.next_action ?? null,
|
|
342
349
|
};
|
|
343
350
|
}
|
|
344
351
|
}
|
|
@@ -357,6 +364,7 @@ const _KNOWN_RUN_FLAGS = new Set([
|
|
|
357
364
|
'--detach', '--fallback', '--no-fallback', '--strict-capacity',
|
|
358
365
|
'--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--env',
|
|
359
366
|
'--dry-run', '--cmd', '--save', '--workspace',
|
|
367
|
+
'--output', '--checkpoint', '--retry-safe', '--resume-cmd',
|
|
360
368
|
]);
|
|
361
369
|
|
|
362
370
|
// Directories and files always excluded from project zip uploads.
|
|
@@ -497,10 +505,43 @@ export async function runCommand(config, args, chalk) {
|
|
|
497
505
|
const isGitHubUrl = firstArg && _isGitHubUrl(firstArg);
|
|
498
506
|
const isCodeSource = isLocalPath || isGitHubUrl;
|
|
499
507
|
|
|
508
|
+
// Local paths can be inspected on disk, so a missing --cmd doesn't have to be
|
|
509
|
+
// an error — try to infer it (and output/checkpoint conventions) the same way
|
|
510
|
+
// `badgr detect .` would, before ever provisioning anything.
|
|
511
|
+
let detectionReport = null;
|
|
512
|
+
if (isLocalPath && !flags.cmd) {
|
|
513
|
+
detectionReport = detectWorkload(firstArg);
|
|
514
|
+
if (detectionReport.command && detectionReport.confidence !== 'low') {
|
|
515
|
+
flags.cmd = detectionReport.command;
|
|
516
|
+
console.log(chalk.dim(`\n Detected command (${detectionReport.confidence} confidence): ${detectionReport.command}`));
|
|
517
|
+
console.log(chalk.dim(` Workload: ${workloadTypeLabel(detectionReport.workloadType)} • badgr detect . for the full report • --cmd to override`));
|
|
518
|
+
}
|
|
519
|
+
if (!flags.output && detectionReport.outputs?.length) flags.output = detectionReport.outputs[0];
|
|
520
|
+
if (!flags.checkpoint && detectionReport.checkpoints?.length) flags.checkpoint = detectionReport.checkpoints[0];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Detection couldn't confidently name a command — ask for just that one
|
|
524
|
+
// missing essential (interactively, if we have a terminal to ask on).
|
|
525
|
+
if (isLocalPath && !flags.cmd && process.stdin.isTTY && process.stdout.isTTY) {
|
|
526
|
+
console.log(chalk.dim(`\n Badgr couldn't confidently detect a command to run in ${path.resolve(firstArg)}.`));
|
|
527
|
+
try {
|
|
528
|
+
const { input } = await import('@inquirer/prompts');
|
|
529
|
+
const answer = await input({
|
|
530
|
+
message: 'Which command should run?',
|
|
531
|
+
default: detectionReport?.command || undefined,
|
|
532
|
+
validate: v => v.trim() ? true : 'A command is required',
|
|
533
|
+
});
|
|
534
|
+
flags.cmd = answer.trim();
|
|
535
|
+
} catch {
|
|
536
|
+
// Ctrl+C or a non-interactive stdin that lied about isTTY — fall through to the hard error below.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
500
540
|
if (isCodeSource && !flags.cmd) {
|
|
501
541
|
console.error(chalk.red(`\n ✗ --cmd is required when running from a ${isLocalPath ? 'local path' : 'GitHub URL'}.\n`));
|
|
502
542
|
if (isLocalPath) {
|
|
503
543
|
console.error(chalk.dim(' Example: badgr run . --cmd "python train.py" --max-cost 5\n'));
|
|
544
|
+
console.error(chalk.dim(' Or check what Badgr detects first: badgr detect .\n'));
|
|
504
545
|
} else {
|
|
505
546
|
console.error(chalk.dim(' Example: badgr run https://github.com/user/repo --cmd "python train.py" --max-cost 5\n'));
|
|
506
547
|
}
|
|
@@ -550,6 +591,20 @@ export async function runCommand(config, args, chalk) {
|
|
|
550
591
|
return;
|
|
551
592
|
}
|
|
552
593
|
|
|
594
|
+
if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
|
|
595
|
+
try {
|
|
596
|
+
const { input } = await import('@inquirer/prompts');
|
|
597
|
+
const answer = await input({
|
|
598
|
+
message: 'What max cost ($) should cap this run?',
|
|
599
|
+
default: '5',
|
|
600
|
+
validate: v => (Number.isFinite(parseFloat(v)) && parseFloat(v) > 0) ? true : 'Enter a number greater than 0',
|
|
601
|
+
});
|
|
602
|
+
flags.maxCost = parseFloat(answer);
|
|
603
|
+
} catch {
|
|
604
|
+
// fall through to the hard error below
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
553
608
|
if (!flags.maxCost && !flags.dryRun) {
|
|
554
609
|
console.error(chalk.red('\n ✗ --max-cost is required for run workloads.\n'));
|
|
555
610
|
console.error(chalk.dim(' Example: badgr run --gpu RTX_4090 --image node:20 --max-cost 5 -- node script.js\n'));
|
|
@@ -578,7 +633,16 @@ export async function runCommand(config, args, chalk) {
|
|
|
578
633
|
const maxRuntimeMs = effectiveMaxRuntime * 60 * 1000;
|
|
579
634
|
|
|
580
635
|
const maxCost = flags.maxCost ?? null;
|
|
581
|
-
|
|
636
|
+
// BADGR_OUTPUT_DIR / BADGR_CHECKPOINT_DIR / BADGR_RETRY_SAFE are a
|
|
637
|
+
// convention for custom.run containers to write recoverable state outside
|
|
638
|
+
// the pod — Badgr doesn't enforce what the container does with them, it
|
|
639
|
+
// just wires the directories through and surfaces --resume-cmd on failure.
|
|
640
|
+
// Explicit --env always wins if the user passes the same key directly.
|
|
641
|
+
const conventionEnv = {};
|
|
642
|
+
if (flags.output) conventionEnv.BADGR_OUTPUT_DIR = flags.output;
|
|
643
|
+
if (flags.checkpoint) conventionEnv.BADGR_CHECKPOINT_DIR = flags.checkpoint;
|
|
644
|
+
if (flags.retrySafe) conventionEnv.BADGR_RETRY_SAFE = '1';
|
|
645
|
+
const envObj = { ...conventionEnv, ...parseEnvFlag(flags.env) };
|
|
582
646
|
const effectiveTier = normalizeTier(flags.tier);
|
|
583
647
|
|
|
584
648
|
const gpu = flags.gpu ? flags.gpu.toUpperCase().replace('-', '_') : undefined;
|
|
@@ -595,6 +659,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
595
659
|
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost}`);
|
|
596
660
|
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice}/hr`);
|
|
597
661
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
662
|
+
if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
|
|
663
|
+
if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
|
|
664
|
+
if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
|
|
665
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
|
|
598
666
|
console.log(chalk.dim('\n Remove --dry-run to provision.\n'));
|
|
599
667
|
return;
|
|
600
668
|
}
|
|
@@ -605,17 +673,20 @@ export async function runCommand(config, args, chalk) {
|
|
|
605
673
|
if (flags.cmd) console.log(` ${chalk.bold('Command:')} ${flags.cmd}`);
|
|
606
674
|
if (command) console.log(` ${chalk.bold('Command:')} ${command.join(' ')}`);
|
|
607
675
|
if (image) console.log(` ${chalk.bold('Image:')} ${image}`);
|
|
608
|
-
if (gpu)
|
|
609
|
-
else console.log(` ${chalk.bold('GPU:')} ${chalk.dim('auto')}`);
|
|
676
|
+
if (flags.gpu) console.log(` ${chalk.bold('GPU:')} ${gpu}`);
|
|
610
677
|
if (flags.minVram) console.log(` ${chalk.bold('Min VRAM:')} ${flags.minVram} GB`);
|
|
611
678
|
const runtimeLabel = isDefaultRuntime
|
|
612
|
-
? `${effectiveMaxRuntime}min ${chalk.dim('(default — use --max-runtime N to override)')}`
|
|
613
|
-
: `${effectiveMaxRuntime}min`;
|
|
679
|
+
? `${effectiveMaxRuntime} min ${chalk.dim('(default — use --max-runtime N to override)')}`
|
|
680
|
+
: `${effectiveMaxRuntime} min`;
|
|
681
|
+
console.log(` ${chalk.bold('Max cost:')} ${maxCost ? `$${maxCost.toFixed(2)}` : chalk.dim('none')}`);
|
|
614
682
|
console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
|
|
615
|
-
|
|
683
|
+
console.log(` ${chalk.bold('Auto-stop:')} ${maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
|
|
616
684
|
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
617
685
|
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
618
686
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${flags.env.join(', ')}`);
|
|
687
|
+
if (flags.output) console.log(` ${chalk.bold('Output:')} ${flags.output}`);
|
|
688
|
+
if (flags.checkpoint) console.log(` ${chalk.bold('Checkpoint:')} ${flags.checkpoint}`);
|
|
689
|
+
if (flags.retrySafe) console.log(` ${chalk.bold('Retry-safe:')} enabled`);
|
|
619
690
|
console.log();
|
|
620
691
|
|
|
621
692
|
|
|
@@ -636,6 +707,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
636
707
|
}
|
|
637
708
|
}
|
|
638
709
|
|
|
710
|
+
// Total stages: local-path runs get a "Preparing upload" stage the others don't.
|
|
711
|
+
const STAGE_TOTAL = isLocalPath ? 5 : 4;
|
|
712
|
+
let stageN = 1;
|
|
713
|
+
|
|
639
714
|
// ── Upload local project zip (Flow 1) ─────────────────────────────────────
|
|
640
715
|
let codeUri = null;
|
|
641
716
|
if (isLocalPath) {
|
|
@@ -646,9 +721,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
646
721
|
process.exitCode = 1;
|
|
647
722
|
return;
|
|
648
723
|
}
|
|
724
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Preparing upload...')));
|
|
725
|
+
stageN++;
|
|
649
726
|
}
|
|
650
727
|
|
|
651
|
-
console.log(chalk.dim(' Finding suitable capacity...'));
|
|
652
728
|
if (process.env.BADGR_DEBUG === '1' || process.env.BADGR_DEBUG === 'true') {
|
|
653
729
|
console.log(chalk.dim(` API: ${config.baseUrl}`));
|
|
654
730
|
}
|
|
@@ -717,12 +793,10 @@ export async function runCommand(config, args, chalk) {
|
|
|
717
793
|
|
|
718
794
|
const rate = dep.cost_per_hour || 0;
|
|
719
795
|
|
|
720
|
-
console.log(chalk.dim('
|
|
721
|
-
|
|
722
|
-
console.log(
|
|
723
|
-
|
|
724
|
-
if (maxCost) console.log(` ${chalk.bold('Max cost:')} $${maxCost.toFixed(2)}`);
|
|
725
|
-
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
796
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Finding a working route...')));
|
|
797
|
+
stageN++;
|
|
798
|
+
console.log(chalk.dim(_stageDone(stageN, STAGE_TOTAL, 'Starting runtime...')));
|
|
799
|
+
stageN++;
|
|
726
800
|
|
|
727
801
|
if (rate > HIGH_RATE_THRESHOLD && !maxCost) {
|
|
728
802
|
console.log(chalk.yellow(`\n Selected capacity rate: $${rate.toFixed(2)}/hr`));
|
|
@@ -745,30 +819,35 @@ export async function runCommand(config, args, chalk) {
|
|
|
745
819
|
if (teardownCalled) return;
|
|
746
820
|
teardownCalled = true;
|
|
747
821
|
|
|
748
|
-
const
|
|
749
|
-
'max-runtime':
|
|
750
|
-
'max-cost':
|
|
751
|
-
'heartbeat-lost':
|
|
752
|
-
'interrupted':
|
|
822
|
+
const stageLabels = {
|
|
823
|
+
'max-runtime': 'Stopped — runtime cap reached',
|
|
824
|
+
'max-cost': 'Stopped — spend cap reached',
|
|
825
|
+
'heartbeat-lost': 'Failed — lost connection to machine',
|
|
826
|
+
'interrupted': 'Stopped',
|
|
753
827
|
};
|
|
754
|
-
console.log(labels[reason] ?? chalk.yellow('\n Stopping job...'));
|
|
755
828
|
|
|
829
|
+
let teardownOk = true;
|
|
756
830
|
try {
|
|
757
831
|
await terminateDeployment(config, dep.deployment_id);
|
|
758
832
|
} catch {
|
|
759
833
|
// terminateDeployment retries 3×; best-effort if all fail
|
|
834
|
+
teardownOk = false;
|
|
760
835
|
}
|
|
761
836
|
|
|
762
837
|
const runtimeMs = Date.now() - attachStart;
|
|
763
838
|
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
764
839
|
updateReceipt(rcptId, {
|
|
765
840
|
status: reason,
|
|
766
|
-
teardownStatus: 'terminated',
|
|
841
|
+
teardownStatus: teardownOk ? 'terminated' : 'failed',
|
|
767
842
|
runtimeSeconds: Math.round(runtimeMs / 1000),
|
|
768
843
|
finalCost,
|
|
769
844
|
});
|
|
770
|
-
console.log(
|
|
771
|
-
console.log(chalk.dim(
|
|
845
|
+
console.log();
|
|
846
|
+
console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, stageLabels[reason] ?? 'Stopped')));
|
|
847
|
+
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
|
|
848
|
+
_printFinalInfo(chalk, { exitCode: null, teardownOk, jobId: dep.deployment_id, rcptId });
|
|
849
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
|
|
850
|
+
console.log();
|
|
772
851
|
}
|
|
773
852
|
|
|
774
853
|
// ── SIGINT handler — installed immediately after we have a deployment ID ───
|
|
@@ -798,20 +877,23 @@ export async function runCommand(config, args, chalk) {
|
|
|
798
877
|
if (dep.status === 'failed') {
|
|
799
878
|
process.removeListener('SIGINT', handleShutdown);
|
|
800
879
|
console.error(formatCliError('JOB_INFRASTRUCTURE_FAILURE', { receiptId: rcptId }, chalk));
|
|
880
|
+
_printFailureClass(chalk, dep);
|
|
801
881
|
process.exitCode = 1;
|
|
802
882
|
return;
|
|
803
883
|
}
|
|
804
884
|
|
|
805
|
-
|
|
885
|
+
const runStageLine = _stage(stageN, STAGE_TOTAL, 'Running command...');
|
|
886
|
+
console.log(chalk.dim(`\n [${stageN}/${STAGE_TOTAL}] Running command (Ctrl+C to stop)`));
|
|
806
887
|
|
|
807
888
|
attachStart = Date.now();
|
|
808
|
-
const { status: finalStatus, exitCode, runtimeMs, failureType } = await attachToJob(config, dep.deployment_id, {
|
|
889
|
+
const { status: finalStatus, exitCode, runtimeMs, failureType, failureClass, nextAction } = await attachToJob(config, dep.deployment_id, {
|
|
809
890
|
chalk,
|
|
810
891
|
maxRuntimeMs,
|
|
811
892
|
maxCost,
|
|
812
893
|
ratePerHour,
|
|
813
894
|
onTeardown: teardown,
|
|
814
895
|
isShuttingDown: () => shuttingDown,
|
|
896
|
+
stageLine: runStageLine,
|
|
815
897
|
});
|
|
816
898
|
|
|
817
899
|
// Remove SIGINT handler — job is done (or SIGINT was handled)
|
|
@@ -820,7 +902,11 @@ export async function runCommand(config, args, chalk) {
|
|
|
820
902
|
// 'interrupted' = SIGINT handler is managing teardown + exit — don't duplicate
|
|
821
903
|
if (finalStatus === 'interrupted') return;
|
|
822
904
|
|
|
823
|
-
|
|
905
|
+
// 'capped' = max-runtime or max-cost path; teardown() already printed the full summary
|
|
906
|
+
if (finalStatus === 'capped') {
|
|
907
|
+
process.exitCode = 1;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
824
910
|
|
|
825
911
|
const finalCost = ratePerHour * (runtimeMs / 3_600_000);
|
|
826
912
|
updateReceipt(rcptId, {
|
|
@@ -832,20 +918,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
832
918
|
teardownStatus: (finalStatus === 'completed' || finalStatus === 'succeeded') ? 'terminated' : 'failed',
|
|
833
919
|
});
|
|
834
920
|
|
|
835
|
-
console.log(
|
|
836
|
-
if (ratePerHour > 0) {
|
|
837
|
-
console.log(` ${chalk.bold('Cost:')} $${finalCost.toFixed(4)} (${chalk.dim(`$${ratePerHour.toFixed(2)}/hr`)})`);
|
|
838
|
-
}
|
|
839
|
-
if (exitCode !== null && exitCode !== undefined) {
|
|
840
|
-
console.log(` ${chalk.bold('Exit code:')} ${exitCode !== 0 ? chalk.red(exitCode) : chalk.green(exitCode)}`);
|
|
841
|
-
}
|
|
842
|
-
console.log(` ${chalk.bold('Receipt:')} ${chalk.dim(rcptId)}`);
|
|
843
|
-
|
|
844
|
-
// 'capped' = max-runtime or max-cost path; teardown message already printed
|
|
845
|
-
if (finalStatus === 'capped') {
|
|
846
|
-
process.exitCode = 1;
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
921
|
+
console.log();
|
|
849
922
|
|
|
850
923
|
if (finalStatus === 'failed' || (exitCode !== null && exitCode !== 0)) {
|
|
851
924
|
if (failureType === 'infrastructure') {
|
|
@@ -853,16 +926,25 @@ export async function runCommand(config, args, chalk) {
|
|
|
853
926
|
} else {
|
|
854
927
|
console.error(formatCliError('JOB_FAILED', { exitCode, deploymentId: dep.deployment_id }, chalk));
|
|
855
928
|
}
|
|
929
|
+
_printFailureClass(chalk, { failure_class: failureClass, next_action: nextAction });
|
|
930
|
+
console.log(chalk.dim(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Failed')));
|
|
931
|
+
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
|
|
932
|
+
// The container already reached a terminal state on the provider side by the
|
|
933
|
+
// time we observe it here, so billing is already stopped — no extra teardown call needed.
|
|
934
|
+
_printFinalInfo(chalk, { exitCode, teardownOk: true, jobId: dep.deployment_id, rcptId });
|
|
935
|
+
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume:')} ${flags.resumeCmd}`);
|
|
856
936
|
process.exitCode = exitCode ?? 1;
|
|
857
937
|
return;
|
|
858
938
|
}
|
|
859
939
|
|
|
860
940
|
if ((finalStatus === 'completed' || finalStatus === 'succeeded') && (exitCode === 0 || exitCode === null)) {
|
|
941
|
+
let teardownOk = true;
|
|
861
942
|
try {
|
|
862
943
|
await terminateDeployment(config, dep.deployment_id);
|
|
863
|
-
} catch { /* already stopped */ }
|
|
864
|
-
console.log(chalk.green(
|
|
865
|
-
console.log(chalk.dim(`
|
|
944
|
+
} catch { teardownOk = false; /* already stopped, or best-effort */ }
|
|
945
|
+
console.log(chalk.green(_stage(STAGE_TOTAL, STAGE_TOTAL, 'Complete')));
|
|
946
|
+
console.log(chalk.dim(` Runtime: ${fmtRuntime(runtimeMs)} • Estimated cost: ~$${finalCost.toFixed(2)}`));
|
|
947
|
+
_printFinalInfo(chalk, { exitCode, teardownOk, jobId: dep.deployment_id, rcptId });
|
|
866
948
|
|
|
867
949
|
if (flags.save && config.apiKey) {
|
|
868
950
|
try {
|