bloby-bot 0.68.2 → 0.68.3
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/supervisor/shell.ts +91 -0
- package/supervisor/workspace-guard.js +14 -1
package/package.json
CHANGED
package/supervisor/shell.ts
CHANGED
|
@@ -97,6 +97,97 @@ export const SHELL_HTML = `<!DOCTYPE html>
|
|
|
97
97
|
var s = document.getElementById('splash');
|
|
98
98
|
if (s && s.style.display !== 'none') s.style.display = 'none';
|
|
99
99
|
}, 10000);
|
|
100
|
+
|
|
101
|
+
/* ── Workspace watchdog ──────────────────────────────────────────────
|
|
102
|
+
The iframe can land on a document with NO Bloby code in it: cloudflared's raw
|
|
103
|
+
"502 Bad Gateway" while the supervisor restarts during a self-update, a relay
|
|
104
|
+
hiccup, or the browser's own network-error page. Nothing inside the frame can
|
|
105
|
+
recover from those — so the shell owns recovery: poll the supervisor, show a
|
|
106
|
+
branded "restarting" overlay while it's unreachable, and reload the frame once
|
|
107
|
+
it's back (then verify the frame really shows one of our documents again). */
|
|
108
|
+
var overlay = document.createElement('div');
|
|
109
|
+
overlay.id = 'bloby-ws-restarting';
|
|
110
|
+
// z-index 9997: above the workspace iframe, below the splash (9998), the bubble
|
|
111
|
+
// canvas (9999+) and the chat panel (99999) — the chat stays usable on top.
|
|
112
|
+
overlay.style.cssText = 'display:none;position:fixed;inset:0;z-index:9997;background:#0a0a0b;color:#e4e4e7;align-items:center;justify-content:center;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif';
|
|
113
|
+
overlay.innerHTML =
|
|
114
|
+
'<div style="text-align:center;max-width:460px;width:100%">' +
|
|
115
|
+
'<div style="position:relative;width:160px;height:160px;margin:0 auto 1.2rem">' +
|
|
116
|
+
'<div style="position:absolute;inset:-18px;background:radial-gradient(circle,rgba(1,102,255,.18) 0%,transparent 60%);filter:blur(18px)"></div>' +
|
|
117
|
+
// preload=auto so the clip is fetched (and SW-cached) while the supervisor is
|
|
118
|
+
// still up — by the time we show this, the origin is unreachable.
|
|
119
|
+
'<video autoplay loop muted playsinline preload="auto" style="position:relative;width:100%;height:100%;object-fit:contain;border-radius:50%">' +
|
|
120
|
+
'<source src="/what-happened.webm" type="video/webm"><source src="/what-happened.mp4" type="video/mp4">' +
|
|
121
|
+
'</video>' +
|
|
122
|
+
'</div>' +
|
|
123
|
+
'<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">Workspace is restarting…</h1>' +
|
|
124
|
+
'<p style="color:#e4e4e7;font-size:1rem;line-height:1.6;margin:0 0 .4rem">Your app is coming back online — this usually happens right after an update or a restart.</p>' +
|
|
125
|
+
'<p style="color:#a1a1aa;font-size:.93rem;line-height:1.6;margin:0">No action needed. Your chat stays online the whole time — ask your agent anything.</p>' +
|
|
126
|
+
'<div style="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"><span style="width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#0166FF,#009AFE);box-shadow:0 0 8px rgba(1,102,255,.6)"></span><span>Watching for recovery…</span></div>' +
|
|
127
|
+
'</div>';
|
|
128
|
+
document.body.appendChild(overlay);
|
|
129
|
+
|
|
130
|
+
function refreshFrame() {
|
|
131
|
+
var u = target.pathname + target.search + target.hash;
|
|
132
|
+
try { frame.contentWindow.location.replace(u); }
|
|
133
|
+
catch (e) { frame.src = u; } // error pages are opaque — reassigning src re-navigates
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function frameLooksBroken() {
|
|
137
|
+
try {
|
|
138
|
+
var d = frame.contentDocument;
|
|
139
|
+
if (!d) return true; // browser error pages are opaque to the same-origin parent
|
|
140
|
+
if (d.readyState === 'loading') return false; // still navigating — don't judge
|
|
141
|
+
// Every document WE can legitimately serve into the frame: the workspace app
|
|
142
|
+
// (#root / #splash), the supervisor interstitials (.video-wrap), and the relay's
|
|
143
|
+
// branded pages ("Powered by Bloby" — those self-recover, leave them alone).
|
|
144
|
+
if (d.getElementById('root') || d.getElementById('splash')) return false;
|
|
145
|
+
if (d.querySelector('.video-wrap')) return false;
|
|
146
|
+
var txt = String((d.body && d.body.textContent) || '');
|
|
147
|
+
if (txt.indexOf('Powered by Bloby') !== -1) return false;
|
|
148
|
+
// Only short, clearly error-shaped documents count as broken — NEVER judge a real
|
|
149
|
+
// app by the absence of our markers (an agent-rebuilt app may have none of them).
|
|
150
|
+
return txt.length < 600 && /bad gateway|gateway time|stream to the origin|cloudflare|connection refused|50[234]/i.test(txt);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return true; // cross-origin contentDocument → browser error page → broken
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
var downCount = 0, upCount = 0, isDown = false, brokenRetries = 0;
|
|
157
|
+
function watchTick() {
|
|
158
|
+
fetch('/__bloby/backend-status', { cache: 'no-store' })
|
|
159
|
+
.then(function (r) { if (!r.ok) throw new Error('status ' + r.status); return r.json(); })
|
|
160
|
+
.then(function () {
|
|
161
|
+
downCount = 0;
|
|
162
|
+
if (isDown) {
|
|
163
|
+
// Two consecutive healthy polls before reloading the frame — right after a
|
|
164
|
+
// restart the tunnel still drops the odd request, and reloading into that
|
|
165
|
+
// window is exactly how the frame lands on cloudflared's raw 502.
|
|
166
|
+
upCount++;
|
|
167
|
+
if (upCount >= 2) {
|
|
168
|
+
isDown = false; upCount = 0; brokenRetries = 0;
|
|
169
|
+
overlay.style.display = 'none';
|
|
170
|
+
refreshFrame();
|
|
171
|
+
}
|
|
172
|
+
} else if (frameLooksBroken()) {
|
|
173
|
+
// Supervisor healthy but the frame shows a foreign error page (e.g. it
|
|
174
|
+
// reloaded itself into a tunnel blip the watchdog never saw). Capped so an
|
|
175
|
+
// exotic-but-working document can never be refresh-looped forever.
|
|
176
|
+
if (brokenRetries < 5) { brokenRetries++; refreshFrame(); }
|
|
177
|
+
} else {
|
|
178
|
+
brokenRetries = 0;
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
.catch(function () {
|
|
182
|
+
upCount = 0;
|
|
183
|
+
downCount++;
|
|
184
|
+
if (downCount >= 2 && !isDown) { // ~8s of failures = real outage, not a blip
|
|
185
|
+
isDown = true;
|
|
186
|
+
overlay.style.display = 'flex';
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
setInterval(watchTick, 4000);
|
|
100
191
|
})();
|
|
101
192
|
</script>
|
|
102
193
|
|
|
@@ -155,20 +155,33 @@
|
|
|
155
155
|
// Single instance so rapid disconnects don't stack timers; paused while hidden
|
|
156
156
|
// (mirrors Vite's own visibility-aware ping) and kicked on visibility/online.
|
|
157
157
|
var recoveryTimer = null;
|
|
158
|
+
var stalePending = false;
|
|
158
159
|
function recoveryTick() {
|
|
159
160
|
if (document.visibilityState === 'hidden') return;
|
|
160
161
|
fetchStamp().then(function (s) {
|
|
161
162
|
if (s === null || recoveryTimer === null) return;
|
|
162
163
|
if (lastStamp === null) { lastStamp = s; return; } // no baseline — adopt and watch
|
|
163
164
|
if (s !== lastStamp) {
|
|
165
|
+
// Stamp moved, but require a SECOND consecutive healthy fetch before reloading:
|
|
166
|
+
// right after a supervisor restart (self-update) the tunnel still drops the odd
|
|
167
|
+
// request — reloading into that window is how the iframe used to land on
|
|
168
|
+
// cloudflared's raw 502 page, which has no Bloby code in it to recover.
|
|
169
|
+
if (!stalePending) {
|
|
170
|
+
stalePending = true;
|
|
171
|
+
flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s + ' (confirming next tick)');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
164
174
|
flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s);
|
|
165
175
|
stopRecoveryPoll();
|
|
166
176
|
location.reload(); // goes through the forensics wrapper: logged + splash
|
|
177
|
+
} else {
|
|
178
|
+
stalePending = false;
|
|
167
179
|
}
|
|
168
|
-
}).catch(function () {});
|
|
180
|
+
}).catch(function () { stalePending = false; }); // consecutive = no failures in between
|
|
169
181
|
}
|
|
170
182
|
function startRecoveryPoll() {
|
|
171
183
|
if (recoveryTimer !== null) return;
|
|
184
|
+
stalePending = false;
|
|
172
185
|
flog('recovery-poll', 'started');
|
|
173
186
|
recoveryTimer = setInterval(recoveryTick, 3000);
|
|
174
187
|
recoveryTick();
|