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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/run.js +150 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "Badgr, run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 seenLines = 0;
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
- onTeardown('max-cost');
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
- onTeardown('max-runtime');
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
- onTeardown('heartbeat-lost');
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
- for (let i = seenLines; i < lines.length; i++) {
121
- console.log(` ${chalk.dim(lines[i])}`);
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 ── Streaming logs (Ctrl+C to stop job) ─────────────────────────\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
  }