bloby-bot 0.53.3 → 0.53.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.53.3",
3
+ "version": "0.53.5",
4
4
  "releaseNotes": [
5
5
  "1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
6
6
  "2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
@@ -8,6 +8,10 @@ let child: ChildProcess | null = null;
8
8
  let restarts = 0;
9
9
  let lastSpawnTime = 0;
10
10
  let intentionallyStopped = false;
11
+ // True once the backend has crash-looped past MAX_RESTARTS and given up — i.e. it's down and
12
+ // will NOT come back without the user fixing the code. The supervisor shows the "backend down"
13
+ // interstitial in this state. Cleared on every spawn attempt (a deliberate restart is "trying again").
14
+ let gaveUp = false;
11
15
  const MAX_RESTARTS = 3;
12
16
  const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
13
17
 
@@ -39,6 +43,7 @@ export function spawnBackend(port: number): ChildProcess {
39
43
  const backendPath = path.join(WORKSPACE_DIR, 'backend', 'index.ts');
40
44
  lastSpawnTime = Date.now();
41
45
  intentionallyStopped = false;
46
+ gaveUp = false;
42
47
 
43
48
  // Clear log file on each restart — only keeps current run
44
49
  try { fs.writeFileSync(LOG_FILE, ''); } catch {}
@@ -106,6 +111,7 @@ export function spawnBackend(port: number): ChildProcess {
106
111
  log.info(`Restarting backend (${restarts}/${MAX_RESTARTS}, delay ${delay}ms)...`);
107
112
  setTimeout(() => spawnBackend(port), delay);
108
113
  } else {
114
+ gaveUp = true;
109
115
  log.error('Backend failed too many times. Use Bloby chat to debug.');
110
116
  }
111
117
  });
@@ -183,6 +189,23 @@ export function isBackendAlive(): boolean {
183
189
  return child !== null && child.exitCode === null;
184
190
  }
185
191
 
192
+ /** True when the backend has crash-looped past MAX_RESTARTS and given up — down and not
193
+ * coming back without a code fix. Drives the supervisor's "backend down" interstitial. */
194
+ export function isBackendDead(): boolean {
195
+ return gaveUp;
196
+ }
197
+
198
+ /** Read the tail of the backend log (default 100 lines) for the "copy logs" debug helper. */
199
+ export function readBackendLogTail(maxLines = 100): string {
200
+ try {
201
+ const text = fs.readFileSync(LOG_FILE, 'utf-8');
202
+ const lines = text.split('\n');
203
+ return lines.slice(-maxLines).join('\n').trim();
204
+ } catch {
205
+ return '';
206
+ }
207
+ }
208
+
186
209
  export function isBackendStopping(): boolean {
187
210
  return stopPromise !== null;
188
211
  }
@@ -11,7 +11,7 @@ import { log } from '../shared/logger.js';
11
11
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
- import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, setBackendEnv } from './backend.js';
14
+ import { spawnBackend, stopBackend, restartBackend, getBackendPort, isBackendAlive, isBackendStopping, isBackendDead, readBackendLogTail, setBackendEnv } from './backend.js';
15
15
  import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
16
16
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
17
17
  import {
@@ -69,6 +69,8 @@ const PLATFORM_ASSETS = new Set([
69
69
  '/pi-logo.svg',
70
70
  '/codex.svg',
71
71
  '/manifest.json',
72
+ '/what-happened.webm',
73
+ '/what-happened.mp4',
72
74
  ]);
73
75
 
74
76
  // Directory-prefix platform assets — anything under these is served from supervisor/public/.
@@ -250,6 +252,77 @@ const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><
250
252
  </div><script>setTimeout(function(){location.reload()},3000)</script>
251
253
  <script src="/bloby/widget.js"></script></body></html>`;
252
254
 
255
+ /** Interstitial shown (by the supervisor, not the workspace) when the workspace backend has
256
+ * crash-looped and given up. Replaces proxying the dashboard SPA to Vite — which would 503 on
257
+ * every /app/api call and, for the common workspace-lock template, misread "no backend" as
258
+ * "no password set" and show the lock-setup screen. Embeds the Bloby chat widget so the user
259
+ * can ask the agent to fix it inline, a "copy logs" button (last 100 backend log lines baked in
260
+ * at render time), and a poll that reloads into the real dashboard once the backend is back. */
261
+ function backendDownPage(logTail: string): string {
262
+ // Embed logs as a JS string literal; escape `<` so a stray `</script>` in the logs can't break out.
263
+ const logs = JSON.stringify(logTail && logTail.length ? logTail : '(no backend logs were captured)').replace(/</g, '\\u003c');
264
+ return `<!DOCTYPE html>
265
+ <html lang="en"><head>
266
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
267
+ <title>Backend down · Bloby</title>
268
+ <style>
269
+ *{margin:0;padding:0;box-sizing:border-box}
270
+ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:1.5rem;overflow:hidden}
271
+ .c{text-align:center;max-width:480px;width:100%;animation:fade-up .6s ease-out both}
272
+ .video-wrap{position:relative;width:200px;height:200px;margin:0 auto 1.4rem;display:flex;align-items:center;justify-content:center}
273
+ .video-wrap::before{content:'';position:absolute;inset:-20px;background:radial-gradient(circle,rgba(1,102,255,0.18) 0%,transparent 60%);filter:blur(20px);animation:glow 3s ease-in-out infinite}
274
+ .video-wrap video{position:relative;width:100%;height:100%;object-fit:contain;pointer-events:none;border-radius:50%}
275
+ h1{font-size:1.55rem;font-weight:700;margin-bottom:.6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
276
+ p{color:#a1a1aa;line-height:1.6;margin-bottom:.5rem;font-size:.95rem}
277
+ .lead{color:#e4e4e7;font-size:1rem}
278
+ .actions{margin-top:1.3rem}
279
+ button{font:inherit;cursor:pointer;border-radius:10px;padding:.65rem 1.2rem;font-size:.9rem;font-weight:600;border:none;background:linear-gradient(135deg,#0166FF,#0069FE);color:#fff;transition:filter .15s}
280
+ button:hover{filter:brightness(1.12)}
281
+ .sub{font-size:.82rem;color:#71717a;display:inline-flex;align-items:center;gap:.5rem;background:#18181b;border:1px solid #27272a;border-radius:9999px;padding:.35rem .9rem;margin-top:1.1rem}
282
+ .sub .dot{width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#0166FF,#009AFE);box-shadow:0 0 8px rgba(1,102,255,.6);animation:pulse 1.6s ease-in-out infinite}
283
+ .badge{display:block;font-size:.7rem;color:#52525b;margin-top:1.3rem}
284
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.85)}}
285
+ @keyframes glow{0%,100%{opacity:.55;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
286
+ @keyframes fade-up{0%{opacity:0;transform:translateY(12px)}100%{opacity:1;transform:translateY(0)}}
287
+ </style></head>
288
+ <body><div class="c">
289
+ <div class="video-wrap"><video autoplay loop muted playsinline>
290
+ <source src="/what-happened.webm" type="video/webm">
291
+ <source src="/what-happened.mp4" type="video/mp4">
292
+ </video></div>
293
+ <h1>Your app's backend is down</h1>
294
+ <p class="lead">The workspace server crashed and couldn't restart on its own.</p>
295
+ <p>Ask your agent to fix it — the chat is right here in the corner. Tap below to copy the logs so it can debug faster.</p>
296
+ <div class="actions"><button id="copyBtn">Copy logs for your agent</button></div>
297
+ <div class="sub"><span class="dot"></span><span id="statusText">Watching for recovery…</span></div>
298
+ <span class="badge">Powered by Bloby</span>
299
+ </div>
300
+ <script>
301
+ (function(){
302
+ var LOGS = ${logs};
303
+ var btn = document.getElementById('copyBtn'), statusEl = document.getElementById('statusText');
304
+ btn.addEventListener('click', function(){
305
+ var text = 'My workspace backend crashed and will not start. Find and fix the root cause. Last backend logs:\\n\\n' + LOGS;
306
+ function ok(){ btn.textContent = '✓ Copied — paste it to your agent'; setTimeout(function(){ btn.textContent = 'Copy logs for your agent'; }, 2600); }
307
+ function fallback(){ var ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); ok(); }catch(e){ btn.textContent='Copy failed — open the logs manually'; } document.body.removeChild(ta); }
308
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(ok).catch(fallback); } else { fallback(); }
309
+ });
310
+ var attempt = 0;
311
+ function retry(){
312
+ attempt++;
313
+ fetch('/__bloby/backend-status', { cache:'no-store' })
314
+ .then(function(r){ return r.json(); })
315
+ .then(function(s){ if (s && s.alive) { location.reload(); } else { schedule(); } })
316
+ .catch(schedule);
317
+ }
318
+ function schedule(){ statusEl.textContent = 'Watching for recovery… (checked ' + attempt + 'x)'; setTimeout(retry, Math.min(4000, 1500 + attempt*250)); }
319
+ setTimeout(retry, 2500);
320
+ })();
321
+ </script>
322
+ <script src="/bloby/widget.js"></script>
323
+ </body></html>`;
324
+ }
325
+
253
326
  /** Kill any stale process holding a port. Ensures clean startup after crashes/updates. */
254
327
  function killPort(port: number): void {
255
328
  try {
@@ -464,6 +537,15 @@ export async function startSupervisor() {
464
537
  return;
465
538
  }
466
539
 
540
+ // Backend liveness for the "backend down" interstitial's recovery poll. Supervisor-served
541
+ // (not proxied) so it answers even when the workspace backend is dead, and independent of
542
+ // whatever routes the user's backend happens to define.
543
+ if (req.url === '/__bloby/backend-status') {
544
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
545
+ res.end(JSON.stringify({ alive: isBackendAlive(), dead: isBackendDead() }));
546
+ return;
547
+ }
548
+
467
549
  // App API routes → proxy to user's backend server
468
550
  if (req.url?.startsWith('/app/api')) {
469
551
  const backendPath = req.url.replace(/^\/app/, '');
@@ -1696,6 +1778,24 @@ mint();
1696
1778
  } catch { /* fall through to Vite */ }
1697
1779
  }
1698
1780
 
1781
+ // Workspace backend has crash-looped and given up → serve the "backend down" interstitial
1782
+ // for dashboard DOCUMENT navigations, instead of proxying to Vite (which serves the user's
1783
+ // SPA that then 503s on every /app/api call and, for the common workspace-lock template,
1784
+ // misreads the dead backend as "no password set" and shows the lock-setup screen). Scoped to
1785
+ // top-level navigations only (not assets/HMR/XHR) and only when the backend has truly given
1786
+ // up — never during a normal 1–2s restart. The chat PWA (/bloby/*) is served earlier and is
1787
+ // unaffected.
1788
+ const wantsHtml = req.method === 'GET' && (
1789
+ req.headers['sec-fetch-dest'] === 'document' ||
1790
+ req.headers['sec-fetch-mode'] === 'navigate' ||
1791
+ (!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
1792
+ );
1793
+ if (wantsHtml && isBackendDead()) {
1794
+ res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
1795
+ res.end(backendDownPage(readBackendLogTail(100)));
1796
+ return;
1797
+ }
1798
+
1699
1799
  // Everything else → proxy to dashboard Vite dev server
1700
1800
  console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
1701
1801
  const proxy = http.request(