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
package/supervisor/backend.ts
CHANGED
|
@@ -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
|
}
|
package/supervisor/index.ts
CHANGED
|
@@ -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(
|
|
Binary file
|
|
Binary file
|