badgr-cli 1.0.17 → 1.0.19
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 +1 -1
- package/src/commands/run.js +150 -9
package/package.json
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -46,18 +46,111 @@ export function classifyFailure(finalStatus, exitCode) {
|
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Lines the log stream never needs to print — we surface them in the status bar instead.
|
|
50
|
+
const LOG_META_RE = /^\[dep-[^\]]+\] (status|gpu|region|endpoint|cost|receipt|provider_status|uptime)=/;
|
|
51
|
+
|
|
52
|
+
// Extract structured values from provider status lines so we can show them nicely.
|
|
53
|
+
function parseProviderLine(line) {
|
|
54
|
+
const gpuUtil = line.match(/\bgpu_util=([\d.]+)%/);
|
|
55
|
+
const cpuUtil = line.match(/\bcpu_util=([\d.]+)%/);
|
|
56
|
+
const ssh = line.match(/\bssh=(\S+)/);
|
|
57
|
+
const provSt = line.match(/\bprovider_status=(\S+)/);
|
|
58
|
+
return {
|
|
59
|
+
gpuUtil: gpuUtil ? parseFloat(gpuUtil[1]) : null,
|
|
60
|
+
cpuUtil: cpuUtil ? parseFloat(cpuUtil[1]) : null,
|
|
61
|
+
ssh: ssh ? ssh[1] : null,
|
|
62
|
+
providerStatus: provSt ? provSt[1] : null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderStatusBar(chalk, { elapsedMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost }) {
|
|
67
|
+
const spent = ratePerHour * (elapsedMs / 3_600_000);
|
|
68
|
+
const parts = [`⏱ ${fmtRuntime(elapsedMs)}`];
|
|
69
|
+
if (ratePerHour > 0) parts.push(`$${spent.toFixed(4)} spent`);
|
|
70
|
+
if (gpuUtil !== null) parts.push(`GPU ${gpuUtil.toFixed(0)}%`);
|
|
71
|
+
if (cpuUtil !== null) parts.push(`CPU ${cpuUtil.toFixed(0)}%`);
|
|
72
|
+
if (maxRuntimeMs) {
|
|
73
|
+
const left = Math.max(0, maxRuntimeMs - elapsedMs);
|
|
74
|
+
parts.push(`${fmtRuntime(left)} left`);
|
|
75
|
+
}
|
|
76
|
+
if (maxCost && ratePerHour > 0) {
|
|
77
|
+
const budgetLeft = Math.max(0, maxCost - spent);
|
|
78
|
+
parts.push(`$${budgetLeft.toFixed(4)} budget left`);
|
|
79
|
+
}
|
|
80
|
+
parts.push('Ctrl+C to stop');
|
|
81
|
+
return chalk.dim(' ' + parts.join(' • '));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Wait for status to leave 'starting' (smoke check running on backend).
|
|
85
|
+
// Returns the final dep object with status 'running' or 'failed'.
|
|
86
|
+
async function waitForRunning(config, depId, chalk) {
|
|
87
|
+
const POLL_MS = 3000;
|
|
88
|
+
const TIMEOUT_MS = 120_000; // 2 min max for smoke check
|
|
89
|
+
const startMs = Date.now();
|
|
90
|
+
let dots = 0;
|
|
91
|
+
|
|
92
|
+
process.stdout.write(chalk.dim(' Waiting for container to start'));
|
|
93
|
+
const ticker = setInterval(() => {
|
|
94
|
+
process.stdout.write('.');
|
|
95
|
+
dots++;
|
|
96
|
+
}, 1000);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
while (Date.now() - startMs < TIMEOUT_MS) {
|
|
100
|
+
await new Promise(r => setTimeout(r, POLL_MS));
|
|
101
|
+
const dep = await callApi(`/deployments/${depId}`, {
|
|
102
|
+
apiKey: config.apiKey,
|
|
103
|
+
baseUrl: config.baseUrl,
|
|
104
|
+
});
|
|
105
|
+
if (dep.status !== 'starting') {
|
|
106
|
+
clearInterval(ticker);
|
|
107
|
+
process.stdout.write('\n');
|
|
108
|
+
return dep;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
clearInterval(ticker);
|
|
113
|
+
if (dots > 0) process.stdout.write('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Timed out waiting — return whatever we have
|
|
117
|
+
return await callApi(`/deployments/${depId}`, { apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
118
|
+
}
|
|
119
|
+
|
|
49
120
|
async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost = null, ratePerHour = 0, onTeardown }) {
|
|
50
121
|
const TERMINAL = new Set(['stopped', 'failed', 'completed']);
|
|
51
122
|
const POLL_MS = 4000;
|
|
52
|
-
let
|
|
123
|
+
let seenContent = new Set(); // track by content, not index, to avoid reprinting stale lines
|
|
53
124
|
let lastStatus = '';
|
|
54
125
|
let consecutiveErrs = 0;
|
|
126
|
+
let gpuUtil = null;
|
|
127
|
+
let cpuUtil = null;
|
|
128
|
+
let sshShown = false;
|
|
129
|
+
let statusBarActive = false;
|
|
55
130
|
const startMs = Date.now();
|
|
56
131
|
|
|
132
|
+
// Ticker: update status bar in place every second between polls.
|
|
133
|
+
let tickerInterval = null;
|
|
134
|
+
const startTicker = () => {
|
|
135
|
+
if (tickerInterval) return;
|
|
136
|
+
statusBarActive = true;
|
|
137
|
+
tickerInterval = setInterval(() => {
|
|
138
|
+
const bar = renderStatusBar(chalk, {
|
|
139
|
+
elapsedMs: Date.now() - startMs, ratePerHour, gpuUtil, cpuUtil, maxRuntimeMs, maxCost,
|
|
140
|
+
});
|
|
141
|
+
process.stdout.write(`\r${bar} `);
|
|
142
|
+
}, 1000);
|
|
143
|
+
};
|
|
144
|
+
const stopTicker = () => {
|
|
145
|
+
if (tickerInterval) { clearInterval(tickerInterval); tickerInterval = null; }
|
|
146
|
+
if (statusBarActive) { process.stdout.write('\r\x1b[2K'); statusBarActive = false; } // clear line
|
|
147
|
+
};
|
|
148
|
+
|
|
57
149
|
let tearing = false;
|
|
58
150
|
const sigintHandler = () => {
|
|
59
151
|
if (tearing) return;
|
|
60
152
|
tearing = true;
|
|
153
|
+
stopTicker();
|
|
61
154
|
onTeardown('interrupted');
|
|
62
155
|
};
|
|
63
156
|
process.once('SIGINT', sigintHandler);
|
|
@@ -71,13 +164,15 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
71
164
|
|
|
72
165
|
if (maxCost !== null && spentSoFar >= maxCost) {
|
|
73
166
|
tearing = true;
|
|
74
|
-
|
|
167
|
+
stopTicker();
|
|
168
|
+
await onTeardown('max-cost');
|
|
75
169
|
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
76
170
|
}
|
|
77
171
|
|
|
78
172
|
if (maxRuntimeMs !== null && elapsedMs >= maxRuntimeMs) {
|
|
79
173
|
tearing = true;
|
|
80
|
-
|
|
174
|
+
stopTicker();
|
|
175
|
+
await onTeardown('max-runtime');
|
|
81
176
|
return { status: 'timeout', exitCode: null, runtimeMs: elapsedMs, failureType: null };
|
|
82
177
|
}
|
|
83
178
|
|
|
@@ -93,10 +188,13 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
93
188
|
if (lastStatus === 'running') {
|
|
94
189
|
const lostSec = Math.round((consecutiveErrs * POLL_MS) / 1000);
|
|
95
190
|
if (consecutiveErrs === HEARTBEAT_WARN_POLLS) {
|
|
191
|
+
stopTicker();
|
|
96
192
|
console.log(chalk.yellow(`\n ⚠ No heartbeat for ${lostSec}s — cloud machine may be unresponsive`));
|
|
193
|
+
startTicker();
|
|
97
194
|
} else if (consecutiveErrs >= HEARTBEAT_KILL_POLLS) {
|
|
98
195
|
tearing = true;
|
|
99
|
-
|
|
196
|
+
stopTicker();
|
|
197
|
+
await onTeardown('heartbeat-lost');
|
|
100
198
|
return { status: 'failed', exitCode: null, runtimeMs: elapsedMs, failureType: 'infrastructure' };
|
|
101
199
|
}
|
|
102
200
|
}
|
|
@@ -106,6 +204,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
106
204
|
const status = dep.status;
|
|
107
205
|
if (status !== lastStatus) {
|
|
108
206
|
if (status === 'running' && lastStatus === 'provisioning') {
|
|
207
|
+
stopTicker();
|
|
109
208
|
console.log(chalk.dim(' [running]'));
|
|
110
209
|
}
|
|
111
210
|
lastStatus = status;
|
|
@@ -117,15 +216,42 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
117
216
|
baseUrl: config.baseUrl,
|
|
118
217
|
});
|
|
119
218
|
const lines = logData?.logs ?? [];
|
|
120
|
-
|
|
121
|
-
|
|
219
|
+
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
// Extract structured provider values (gpu_util, ssh, etc.) from any line.
|
|
222
|
+
const parsed = parseProviderLine(line);
|
|
223
|
+
if (parsed.gpuUtil !== null) gpuUtil = parsed.gpuUtil;
|
|
224
|
+
if (parsed.cpuUtil !== null) cpuUtil = parsed.cpuUtil;
|
|
225
|
+
|
|
226
|
+
// Show SSH address once, prominently.
|
|
227
|
+
if (parsed.ssh && !sshShown) {
|
|
228
|
+
stopTicker();
|
|
229
|
+
console.log(` ${chalk.bold('SSH:')} ${chalk.cyan(parsed.ssh)}`);
|
|
230
|
+
sshShown = true;
|
|
231
|
+
startTicker();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Skip lines we've already printed and pure metadata lines.
|
|
235
|
+
if (seenContent.has(line)) continue;
|
|
236
|
+
seenContent.add(line);
|
|
237
|
+
if (LOG_META_RE.test(line)) continue;
|
|
238
|
+
// Skip provider util lines — they're shown in the status bar instead.
|
|
239
|
+
if (/\b(gpu_util|cpu_util|provider_status|uptime)=/.test(line)) continue;
|
|
240
|
+
|
|
241
|
+
const isErrorLine = /^error\b/i.test(line) || /Error response from daemon/i.test(line);
|
|
242
|
+
stopTicker();
|
|
243
|
+
console.log(` ${isErrorLine ? chalk.red(line) : chalk.dim(line)}`);
|
|
244
|
+
startTicker();
|
|
122
245
|
}
|
|
123
|
-
seenLines = lines.length;
|
|
124
246
|
} catch {
|
|
125
247
|
// logs not ready yet
|
|
126
248
|
}
|
|
127
249
|
|
|
250
|
+
// Start the ticker once the job is confirmed running.
|
|
251
|
+
if (status === 'running') startTicker();
|
|
252
|
+
|
|
128
253
|
if (TERMINAL.has(status)) {
|
|
254
|
+
stopTicker();
|
|
129
255
|
process.removeListener('SIGINT', sigintHandler);
|
|
130
256
|
const exitCode = dep.exit_code ?? null;
|
|
131
257
|
return {
|
|
@@ -137,6 +263,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
137
263
|
}
|
|
138
264
|
}
|
|
139
265
|
} finally {
|
|
266
|
+
stopTicker();
|
|
140
267
|
process.removeListener('SIGINT', sigintHandler);
|
|
141
268
|
}
|
|
142
269
|
}
|
|
@@ -370,8 +497,22 @@ export async function runCommand(config, args, chalk) {
|
|
|
370
497
|
return;
|
|
371
498
|
}
|
|
372
499
|
|
|
500
|
+
// If the backend is still running the smoke check, wait for it to finish.
|
|
501
|
+
if (dep.status === 'starting') {
|
|
502
|
+
console.log();
|
|
503
|
+
dep = await waitForRunning(config, dep.deployment_id, chalk);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (dep.status === 'failed') {
|
|
507
|
+
console.error(chalk.red('\n ✗ Container failed to start (infrastructure error).\n'));
|
|
508
|
+
console.error(chalk.dim(' The backend retried automatically. All attempts failed.'));
|
|
509
|
+
console.error(chalk.dim(` Contact support with receipt ID: ${rcptId}`));
|
|
510
|
+
console.log();
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
|
|
373
514
|
const ratePerHour = dep.cost_per_hour || 0;
|
|
374
|
-
console.log(chalk.dim('\n ──
|
|
515
|
+
console.log(chalk.dim('\n ── Live status (Ctrl+C to stop) ─────────────────────────────────\n'));
|
|
375
516
|
|
|
376
517
|
async function teardown(reason) {
|
|
377
518
|
const labels = {
|
|
@@ -433,7 +574,7 @@ export async function runCommand(config, args, chalk) {
|
|
|
433
574
|
}
|
|
434
575
|
console.log();
|
|
435
576
|
process.exit(exitCode ?? 1);
|
|
436
|
-
} else {
|
|
577
|
+
} else if (finalStatus === 'completed' && (exitCode === 0 || exitCode === null)) {
|
|
437
578
|
console.log(chalk.green(`\n ✓ Complete\n`));
|
|
438
579
|
}
|
|
439
580
|
}
|