feat(browser): self-heal sessions after camofox restarts (BR-3)
- Any session operation that hits 404/410 (or finds no open tab) reconciles
tabs; if none survive it re-injects the account cookies (BR-2), opens a new
tab under the same userId/sessionKey, returns to the recorded URL (site home
otherwise), marks old tabs lost and retries the operation once. Failure
returns 503 browser_reconnect_failed with the reason, with a 10s backoff.
- Restart detection: umt plugin GET /umt/instance (fallback /health fields);
a changed instance id marks every open session needsReconnect.
- POST /browser/sessions/{id}/reconnect (idempotent); session view exposes
needsReconnect and reconnect {status, trigger, at, reason, humanNotice}.
- Controller and lease are kept; reconnecting during takeover asks the human
to double-check the reloaded page.
- Web: screenshot and desktop modes show "reconnecting", auto-reconnect once,
pause polling, offer a reconnect button with the reason on failure; desktop
mode rebuilds the Selkies connection after a reconnect.
- Mock camofox restart(), tests, smoke step and runbook section 10.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
da24380870
commit
838aea3ebd
19 files changed
+977
-45
No files matched your search
@@ -128,6 +128,16 @@ export function useCloseBrowserSession(siteId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** BR-3:浏览器服务重启后重新连接(幂等;上游上下文还在时服务端什么都不做) */
|
||||
export function useBrowserReconnect(siteId: string) {
|
||||
const refresh = useRefresh(siteId);
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiData<BrowserSessionView>("/browser/sessions/{id}/reconnect", { method: "POST", pathParams: { id }, body: {} }),
|
||||
onSuccess: (s) => refresh(s),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBrowserControl(siteId: string) {
|
||||
const refresh = useRefresh(siteId);
|
||||
const acquire = useMutation({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { apiData, errorMessage } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, waitingText } from "./coords.ts";
|
||||
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||
import { SiteOriginView } from "./site-origin-view.tsx";
|
||||
|
||||
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
|
||||
@@ -99,6 +100,25 @@ function DesktopView({
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const reloadDesktop = () => {
|
||||
setLoaded(false);
|
||||
setReloadKey((k) => k + 1);
|
||||
};
|
||||
// BR-3:浏览器服务(同一容器里的 Selkies)重启过 → 自动重连会话一次,成功后重建桌面连接
|
||||
const rc = useAutoReconnect(site.id, s, false, reloadDesktop);
|
||||
// 别处(Agent 操作、截图)触发的重连成功后也重建桌面连接
|
||||
const reconnectedAt = s?.reconnect?.status === "ok" ? s.reconnect.at : null;
|
||||
const prevReconnect = useRef(reconnectedAt);
|
||||
useEffect(() => {
|
||||
if (prevReconnect.current !== reconnectedAt) {
|
||||
prevReconnect.current = reconnectedAt;
|
||||
if (reconnectedAt) {
|
||||
setLoaded(false);
|
||||
setReloadKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
}, [reconnectedAt]);
|
||||
|
||||
// 控制权变化(接管 / 交还 / 过期)后重连,Selkies 按新口令认证;服务端也会主动断开旧连接
|
||||
const role = status.role;
|
||||
const prevRole = useRef(role);
|
||||
@@ -117,11 +137,16 @@ function DesktopView({
|
||||
const waiting = waitingText(s.waitingReason);
|
||||
const cred = credentialBadge(s.credential);
|
||||
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
||||
const conn: { tone: StatusTone; text: string } = loaded
|
||||
? mine
|
||||
? { tone: "success", text: "已连接,可操作" }
|
||||
: { tone: "success", text: "已连接,只读" }
|
||||
: { tone: "neutral", text: "连接中" };
|
||||
const conn: { tone: StatusTone; text: string } =
|
||||
rc.state.phase === "reconnecting"
|
||||
? { tone: "warning", text: "正在重新连接" }
|
||||
: rc.state.phase === "failed"
|
||||
? { tone: "destructive", text: "连接中断" }
|
||||
: loaded
|
||||
? mine
|
||||
? { tone: "success", text: "已连接,可操作" }
|
||||
: { tone: "success", text: "已连接,只读" }
|
||||
: { tone: "neutral", text: "连接中" };
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -167,14 +192,7 @@ function DesktopView({
|
||||
接管
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLoaded(false);
|
||||
setReloadKey((k) => k + 1);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={rc.busy} onClick={reloadDesktop}>
|
||||
<RotateCw />
|
||||
重新连接
|
||||
</Button>
|
||||
@@ -194,6 +212,8 @@ function DesktopView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReloadNotice session={s} />
|
||||
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{mine
|
||||
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
|
||||
@@ -201,6 +221,11 @@ function DesktopView({
|
||||
</p>
|
||||
|
||||
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
||||
{rc.state.phase !== "idle" ? (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-background/85">
|
||||
<ReconnectPanel state={rc.state} onRetry={rc.retry} />
|
||||
</div>
|
||||
) : null}
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
title="浏览器桌面"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { BrowserSessionView } from "@umt/contracts";
|
||||
import { LoaderCircle, RotateCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBrowserReconnect } from "~/api/browser.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// BR-3 浏览器服务(camofox)重启后的自动重连:截图模式与桌面模式共用。
|
||||
// 收到"标签页 / 会话不在了"(404)、"已被回收"(409)或自动重连失败(503),或会话详情标了 needsReconnect 时,
|
||||
// 自动调用一次 POST /browser/sessions/{id}/reconnect;成功后继续轮询,失败显示原因与"重新连接"按钮,不再反复刷错误。
|
||||
|
||||
/** 这个错误是否说明浏览器服务重启过(上游标签页 / 会话不在了、自动重连失败) */
|
||||
export function isBrowserGone(err: unknown): boolean {
|
||||
if (!(err instanceof ApiRequestError)) return false;
|
||||
if (err.code === "not_found" || err.code === "browser_reconnect_failed") return true;
|
||||
return err.code === "conflict" && /回收/.test(err.message);
|
||||
}
|
||||
|
||||
export type ReconnectPhase = { phase: "idle" } | { phase: "reconnecting" } | { phase: "failed"; reason: string };
|
||||
|
||||
/**
|
||||
* gone = 当前视图收到了"浏览器不在了"类错误。每次出问题只自动重连一次(问题消失后复位);
|
||||
* 失败后等人点"重新连接"(retry)。onReconnected:重连成功(桌面模式据此重建 Selkies 连接)。
|
||||
*/
|
||||
export function useAutoReconnect(
|
||||
siteId: string,
|
||||
session: BrowserSessionView | null,
|
||||
gone: boolean,
|
||||
onReconnected?: () => void,
|
||||
) {
|
||||
const mutation = useBrowserReconnect(siteId);
|
||||
const [state, setState] = useState<ReconnectPhase>({ phase: "idle" });
|
||||
const tried = useRef<string | null>(null);
|
||||
const logged = useRef(false);
|
||||
const done = useRef(onReconnected);
|
||||
done.current = onReconnected;
|
||||
const id = session && session.controller !== "closed" ? session.id : null;
|
||||
const trigger = !!id && (gone || session?.needsReconnect === true);
|
||||
const { mutate } = mutation;
|
||||
|
||||
const run = useCallback(() => {
|
||||
if (!id) return;
|
||||
setState({ phase: "reconnecting" });
|
||||
mutate(id, {
|
||||
onSuccess: () => {
|
||||
setState({ phase: "idle" });
|
||||
done.current?.();
|
||||
},
|
||||
onError: (e) => setState({ phase: "failed", reason: errorMessage(e, "浏览器服务不可用") }),
|
||||
});
|
||||
}, [id, mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger) {
|
||||
// 问题消失(截图恢复 / 会话不再需要重连):下次再出问题时还能自动重连一次
|
||||
if (tried.current === id) tried.current = null;
|
||||
return;
|
||||
}
|
||||
if (tried.current === id) return;
|
||||
tried.current = id;
|
||||
if (!logged.current) {
|
||||
logged.current = true;
|
||||
console.debug("[browser] 浏览器服务可能重启过,自动重新连接一次", id);
|
||||
}
|
||||
run();
|
||||
}, [trigger, id, run]);
|
||||
|
||||
return { state, retry: run, busy: state.phase === "reconnecting" };
|
||||
}
|
||||
|
||||
/** 画面区域:重连中 / 重连失败(原因 + 重新连接按钮) */
|
||||
export function ReconnectPanel({
|
||||
state,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
state: ReconnectPhase;
|
||||
/** idle 但仍拿到"浏览器不在了"类错误时显示它的消息 */
|
||||
error?: unknown;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (state.phase === "reconnecting")
|
||||
return (
|
||||
<div role="status" className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-lead font-medium text-foreground">浏览器已重启,正在重新连接…</p>
|
||||
<p className="text-caption text-muted-foreground">重新注入账号登录态,并回到原来的页面。</p>
|
||||
</div>
|
||||
);
|
||||
const reason = state.phase === "failed" ? state.reason : errorMessage(error, "浏览器服务不可用");
|
||||
return (
|
||||
<div role="alert" className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<p className="text-lead font-medium text-foreground">浏览器连接已断开</p>
|
||||
<p className="max-w-md text-caption break-words text-muted-foreground">{reason}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw />
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 人工接管中浏览器重连过:页面已重新加载(表单输入、滚动位置都没了),提示核对;点"知道了"收起 */
|
||||
export function ReloadNotice({ session }: { session: BrowserSessionView }) {
|
||||
const r = session.reconnect;
|
||||
const at = r?.status === "ok" && r.humanNotice && session.controller === "human" ? r.at : null;
|
||||
const [dismissed, setDismissed] = useState<string | null>(null);
|
||||
if (!at || dismissed === at) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone="warning">浏览器重启过,页面已重新加载,请核对</StatusBadge>
|
||||
<Button variant="ghost" size="xs" onClick={() => setDismissed(at)}>
|
||||
知道了
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { CONTROLLER_LABEL, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
||||
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||
|
||||
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
|
||||
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
|
||||
@@ -51,7 +52,13 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
const session = useSiteBrowserSession(site.id);
|
||||
const s = session.data ?? null;
|
||||
const [paused, setPaused] = useState(false);
|
||||
const shot = useBrowserScreenshot(s && s.controller !== "closed" ? s.id : null, { paused });
|
||||
const [holdShots, setHoldShots] = useState(false);
|
||||
const shot = useBrowserScreenshot(s && s.controller !== "closed" ? s.id : null, { paused: paused || holdShots });
|
||||
// BR-3:浏览器服务重启 → 自动重连一次;重连中 / 失败时停掉截图轮询,不反复刷错误
|
||||
const gone = shot.isError && isBrowserGone(shot.error);
|
||||
const rc = useAutoReconnect(site.id, s, gone);
|
||||
const reconnectView = rc.state.phase !== "idle" || gone;
|
||||
useEffect(() => setHoldShots(rc.state.phase !== "idle"), [rc.state.phase]);
|
||||
const open = useOpenBrowserSession(site.id);
|
||||
const close = useCloseBrowserSession(site.id);
|
||||
const control = useBrowserControl(site.id);
|
||||
@@ -108,11 +115,13 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
const unverified = !unsupported && (!caps?.takeover.verified || !caps?.screenshot.verified);
|
||||
const conn: { tone: StatusTone; text: string } = paused
|
||||
? { tone: "neutral", text: "已暂停刷新" }
|
||||
: shot.isError
|
||||
? { tone: "destructive", text: "连接中断" }
|
||||
: shot.data
|
||||
? { tone: "success", text: "已连接" }
|
||||
: { tone: "neutral", text: "连接中" };
|
||||
: rc.state.phase === "reconnecting"
|
||||
? { tone: "warning", text: "正在重新连接" }
|
||||
: shot.isError
|
||||
? { tone: "destructive", text: "连接中断" }
|
||||
: shot.data
|
||||
? { tone: "success", text: "已连接" }
|
||||
: { tone: "neutral", text: "连接中" };
|
||||
const waiting = waitingText(s.waitingReason);
|
||||
const cred = credentialBadge(s.credential);
|
||||
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
||||
@@ -142,6 +151,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
|
||||
<ReloadNotice session={s} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||
@@ -244,7 +254,9 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
</form>
|
||||
|
||||
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
|
||||
{shot.data ? (
|
||||
{reconnectView ? (
|
||||
<ReconnectPanel state={rc.state} error={shot.error} onRetry={rc.retry} />
|
||||
) : shot.data ? (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: 画面点击映射到浏览器;键盘输入走下方输入栏
|
||||
<img
|
||||
src={`data:${shot.data.mimeType};base64,${shot.data.dataBase64}`}
|
||||
@@ -258,7 +270,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
||||
) : (
|
||||
<LoadingState rows={6} className="p-4" />
|
||||
)}
|
||||
{shot.data && !human ? (
|
||||
{shot.data && !human && !reconnectView ? (
|
||||
<p className="absolute right-2 bottom-2 rounded-md bg-background/85 px-2 py-1 text-meta text-muted-foreground">
|
||||
接管后可点击和输入
|
||||
</p>
|
||||
|
||||
@@ -280,6 +280,145 @@ async function main() {
|
||||
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(problems)] });
|
||||
}
|
||||
|
||||
// ── 交互:原站视图在浏览器服务重启后自动重连(BR-3;浏览器 API 全部 mock,不连真实 camofox)──
|
||||
// 截图先 404(标签页不在了)→ 显示"正在重新连接" → 自动调一次 POST .../reconnect → 截图恢复
|
||||
{
|
||||
const label = "原站视图 → 浏览器重启后自动重连(mock)";
|
||||
const sitesRes = await page.request.get(`${BASE}/api/v1/sites?limit=50`);
|
||||
const site = sitesRes.ok()
|
||||
? ((await sitesRes.json().catch(() => null))?.data?.items ?? []).find((x) => x.enabled)
|
||||
: null;
|
||||
if (!site) skipped.push(`${label}(没有启用的站点)`);
|
||||
else {
|
||||
problems = [];
|
||||
const t0 = Date.now();
|
||||
const sid = "00000000-0000-4000-8000-00000000b3b3";
|
||||
const at = new Date().toISOString();
|
||||
const session = {
|
||||
id: sid,
|
||||
providerId: "00000000-0000-4000-8000-00000000b3b4",
|
||||
siteId: site.id,
|
||||
accountId: null,
|
||||
profileKey: `umt-site-${site.id}-acct-anon`,
|
||||
controller: "automation",
|
||||
currentUrl: "https://example.invalid/torrents.php",
|
||||
lastActivityAt: at,
|
||||
upstreamRef: {},
|
||||
recoveryState: {},
|
||||
closedAt: null,
|
||||
closeReason: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
tabs: [],
|
||||
currentTabId: "tab-mock",
|
||||
lease: null,
|
||||
needsSnapshot: false,
|
||||
waitingReason: null,
|
||||
credential: null,
|
||||
needsReconnect: false,
|
||||
reconnect: null,
|
||||
};
|
||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||
const ok = (data) => ({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, data }) });
|
||||
let reconnects = 0;
|
||||
let shots = 0;
|
||||
await page.route("**/api/v1/browser/**", async (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
const p = u.pathname.replace(/^\/api\/v1/, "");
|
||||
if (p === "/browser/providers")
|
||||
return route.fulfill(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
id: session.providerId,
|
||||
name: "camofox-mock",
|
||||
kind: "camofox",
|
||||
baseUrl: "http://camofox.invalid:9377",
|
||||
secretRef: null,
|
||||
managedBy: null,
|
||||
hasAccessKey: true,
|
||||
enabled: true,
|
||||
capabilities: null,
|
||||
verifiedAt: null,
|
||||
healthStatus: "healthy",
|
||||
lastHealthAt: at,
|
||||
health: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (p === "/browser/sessions" && route.request().method() === "GET")
|
||||
return route.fulfill(ok({ items: [session] }));
|
||||
if (p === `/browser/desktop/${sid}/_status`)
|
||||
return route.fulfill(
|
||||
ok({
|
||||
available: false,
|
||||
reason: "冒烟 mock",
|
||||
role: "viewer",
|
||||
leaseExpiresAt: null,
|
||||
path: `/api/v1/browser/desktop/${sid}/`,
|
||||
requiresSecureContext: true,
|
||||
}),
|
||||
);
|
||||
if (p === `/browser/sessions/${sid}/screenshot`) {
|
||||
shots++;
|
||||
if (reconnects === 0)
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, error: { code: "not_found", message: "标签页不存在或已关闭" } }),
|
||||
});
|
||||
return route.fulfill(
|
||||
ok({
|
||||
mimeType: "image/png",
|
||||
dataBase64: png,
|
||||
width: 1,
|
||||
height: 1,
|
||||
url: session.currentUrl,
|
||||
modelSafe: true,
|
||||
capturedAt: new Date().toISOString(),
|
||||
controller: "automation",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (p === `/browser/sessions/${sid}/reconnect`) {
|
||||
reconnects++;
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return route.fulfill(
|
||||
ok({ ...session, reconnect: { status: "ok", trigger: "tab_gone", at, reason: null, humanNotice: false } }),
|
||||
);
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, error: { code: "not_found", message: `冒烟 mock 没有 ${p}` } }),
|
||||
});
|
||||
});
|
||||
try {
|
||||
await page.goto("about:blank");
|
||||
await page.goto(`${BASE}/console/sites/${encodeURIComponent(site.id)}?tab=origin`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByText("浏览器已重启,正在重新连接…").waitFor({ timeout: 15_000 });
|
||||
await page.locator('img[alt="站点页面画面"]').waitFor({ timeout: 15_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
if (reconnects !== 1) problems.push(`自动重连应只调用 1 次,实际 ${reconnects} 次`);
|
||||
if (shots < 2) problems.push("重连后没有继续轮询截图");
|
||||
if (await page.getByText("读取画面失败").count()) problems.push("仍显示读取画面失败");
|
||||
} catch (err) {
|
||||
problems.push(`交互失败:${String(err?.message ?? err).split("\n")[0]}`);
|
||||
}
|
||||
if (SHOTS) await page.screenshot({ path: join(OUT, "site_origin_reconnect.png") }).catch(() => {});
|
||||
await page.unroute("**/api/v1/browser/**");
|
||||
// mock 的首个截图 404 是预期内的
|
||||
const kept = problems.filter(
|
||||
(x) => !(/HTTP 404/.test(x) && x.includes("/screenshot")) && !/status of 404/.test(x),
|
||||
);
|
||||
results.push({ path: label, ms: Date.now() - t0, problems: [...new Set(kept)] });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 旧链接重定向 ──
|
||||
for (const r of REDIRECT_ROUTES) {
|
||||
let from = r.from;
|
||||
|
||||
Reference in new issue
Block a user