- commit
- c9e1441
- parent
- 37f4a25
- author
- im_wower
- date
- 2026-03-22 16:41:48 +0800 CST
feat(firefox): auto-retry control api sync
5 files changed,
+348,
-53
+13,
-3
1@@ -57,6 +57,8 @@
2 - 插件负责显示状态和发起人工控制动作,不负责创建、分配、恢复 task。
3 - `pause`、`resume`、`drain` 都是全局动作,不是单标签页动作。
4 - Firefox 插件默认模式不依赖 websocket;如果保留兼容能力,也只能作为手动启用的可选通道。
5+- Firefox 启动时由 `background.js` 确保 controller 页存在;controller 启动后立即同步 Control API。
6+- Control API 临时失败时,插件必须持续自动重试;服务恢复后自动回到正常同步周期。
7 - 插件里的“跟踪标签页”只统计当前真实打开且仍匹配平台 host 的页面,不把历史 storage 残留当成当前状态。
8 - 插件里的“凭证”只统计绑定到当前 tracked tab、最近重新捕获且仍通过平台登录规则校验的快照;启动时不自动补开全部平台页。
9
10@@ -81,6 +83,7 @@
11
12 - `CONTROL_API_AUTH_REQUIRED=false`
13 - Firefox 插件默认直接请求 `https://control-api.makefile.so`
14+- 如果用户没有手动保存配置,插件会继续默认使用 `https://control-api.makefile.so`
15 - Firefox 插件默认不配置 websocket,也不会主动连接 `ws://127.0.0.1:9800`
16 - 当前不要求本地 `baa-server`
17 - 当前不要求在插件里填写 token
18@@ -144,8 +147,12 @@ Firefox 插件至少要读取这些字段:
19
20 插件行为:
21
22-- popup 或侧边栏打开时立即请求一次。
23-- 建议每 `5` 到 `10` 秒轮询一次;面板关闭后停止轮询。
24+- Firefox 启动时,background 会自动确保 `controller.html` 标签页存在。
25+- `controller.html` 启动后立即请求一次 `GET /v1/system/state`。
26+- 当前实现成功后按 `15` 秒周期继续同步。
27+- 当前实现失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再切到每 `30` 秒一次的慢速重试。
28+- 服务恢复后自动回到 `15` 秒正常同步周期,不需要手动刷新页面或重新打开 controller。
29+- `刷新控制面` 按钮仍然保留,但只是额外的手动触发入口。
30 - 每次成功执行 `pause`、`resume`、`drain` 后,优先使用写接口返回的新状态更新 UI;如果后端暂未回传完整状态,则立即补一次 `GET /v1/system/state`。
31
32 ## 4.1 能力发现和辅助只读接口
33@@ -282,11 +289,14 @@ Firefox 插件至少要读取这些字段:
34
35 推荐展示字段:
36
37+- Control API 连接状态
38 - 当前 `mode`
39 - 当前 leader host
40 - `active_runs`
41 - `queued_tasks`
42-- 可选 WS 状态;未配置时展示 `未启用`,不要展示成 `WS 未连接`
43+- 最近一次成功同步时间
44+- 自动重试中的失败信息和下一次重试时间
45+- 可选 WS 状态;未配置时展示 `未启用`,不要展示成主连接故障
46
47 ## 7. `control` 与 `dispatch` 边界
48
+54,
-3
1@@ -7,6 +7,12 @@ const MODE_BADGES = {
2 draining: { text: "排空", color: "#8b5e34" },
3 unknown: { text: "--", color: "#7f7a70" }
4 };
5+const CONNECTION_BADGES = {
6+ connecting: { text: "连中", color: "#8b5e34" },
7+ connected: MODE_BADGES.unknown,
8+ retrying: { text: "重试", color: "#a6512f" },
9+ disconnected: { text: "断开", color: "#7f7a70" }
10+};
11
12 async function setControllerTabId(tabId) {
13 await browser.storage.local.set({ [STORAGE_KEY]: tabId });
14@@ -64,16 +70,60 @@ function normalizeMode(value) {
15 return "unknown";
16 }
17
18+function normalizeControlConnection(value) {
19+ const lower = String(value || "").trim().toLowerCase();
20+ if (lower === "connecting" || lower === "connected" || lower === "retrying" || lower === "disconnected") {
21+ return lower;
22+ }
23+ return "disconnected";
24+}
25+
26+function formatModeLabel(mode) {
27+ switch (normalizeMode(mode)) {
28+ case "running":
29+ return "运行中";
30+ case "paused":
31+ return "已暂停";
32+ case "draining":
33+ return "排空中";
34+ default:
35+ return "未知";
36+ }
37+}
38+
39+function formatControlConnectionLabel(connection) {
40+ switch (normalizeControlConnection(connection)) {
41+ case "connecting":
42+ return "连接中";
43+ case "connected":
44+ return "已连接";
45+ case "retrying":
46+ return "正在重试";
47+ default:
48+ return "已断开";
49+ }
50+}
51+
52+function formatSyncTime(timestamp) {
53+ if (!Number.isFinite(timestamp) || timestamp <= 0) return "未同步";
54+ return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
55+}
56+
57 async function updateActionBadge(snapshot) {
58 const mode = normalizeMode(snapshot?.mode);
59- const badge = snapshot?.error ? { text: "ERR", color: "#a6512f" } : MODE_BADGES[mode];
60- const modeLabel = mode === "running" ? "运行中" : mode === "paused" ? "已暂停" : mode === "draining" ? "排空中" : "未知";
61+ const connection = normalizeControlConnection(snapshot?.controlConnection);
62+ const badge = connection === "connected" ? MODE_BADGES[mode] : CONNECTION_BADGES[connection];
63 const leader = snapshot?.leader ? `\n主控: ${snapshot.leader}` : "";
64+ const lastSuccess = snapshot?.lastSuccessAt ? `\n最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}` : "";
65+ const nextRetry = connection === "retrying" && snapshot?.nextRetryAt
66+ ? `\n下次重试: ${formatSyncTime(snapshot.nextRetryAt)}`
67+ : "";
68+ const error = snapshot?.error && connection !== "connected" ? `\n错误: ${snapshot.error}` : "";
69
70 await browser.action.setBadgeText({ text: badge.text });
71 await browser.action.setBadgeBackgroundColor({ color: badge.color });
72 await browser.action.setTitle({
73- title: `BAA Firefox 管理页\n模式: ${modeLabel}${leader}`
74+ title: `BAA Firefox 管理页\n连接: ${formatControlConnectionLabel(connection)}\n自动化: ${formatModeLabel(mode)}${leader}${lastSuccess}${nextRetry}${error}`
75 });
76 }
77
78@@ -127,4 +177,5 @@ browser.tabs.onRemoved.addListener((tabId) => {
79 }).catch(() => {});
80 });
81
82+ensureControllerTab({ activate: false }).catch(() => {});
83 refreshActionBadgeFromStorage().catch(() => {});
+2,
-2
1@@ -29,7 +29,7 @@
2 <label for="control-base-url">控制 API</label>
3 <input id="control-base-url" type="text" spellcheck="false" placeholder="https://control-api.makefile.so">
4 <button id="save-control-btn" type="button">保存控制面配置</button>
5- <button id="refresh-control-btn" type="button">刷新状态</button>
6+ <button id="refresh-control-btn" type="button">刷新控制面</button>
7 <button id="pause-btn" type="button">暂停</button>
8 <button id="resume-btn" type="button">恢复</button>
9 <button id="drain-btn" type="button">排空</button>
10@@ -37,7 +37,7 @@
11
12 <section class="grid">
13 <article class="card">
14- <p class="label">自动化模式</p>
15+ <p class="label">控制面连接</p>
16 <p id="control-mode" class="value off">未知</p>
17 <p id="control-meta" class="meta">未同步</p>
18 </article>
+255,
-40
1@@ -34,6 +34,9 @@ const LOG_LIMIT = 160;
2 const PROXY_MESSAGE_RETRY = 10;
3 const PROXY_MESSAGE_RETRY_DELAY = 400;
4 const CONTROL_REFRESH_INTERVAL = 15_000;
5+const CONTROL_RETRY_DELAYS = [1_000, 3_000, 5_000];
6+const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
7+const CONTROL_RETRY_LOG_INTERVAL = 60_000;
8 const TRACKED_TAB_REFRESH_DELAY = 150;
9 const CONTROL_STATUS_BODY_LIMIT = 12_000;
10 const CHATGPT_SESSION_COOKIE_PATTERNS = [
11@@ -157,6 +160,9 @@ const state = {
12 wsConnected: false,
13 reconnectTimer: null,
14 controlRefreshTimer: null,
15+ controlRefreshInFlight: null,
16+ lastControlFailureLogAt: 0,
17+ lastControlFailureKey: "",
18 trackedTabRefreshTimer: null,
19 trackedTabRefreshRunning: false,
20 trackedTabRefreshQueued: false,
21@@ -247,6 +253,11 @@ function createDefaultControlState(overrides = {}) {
22 message: null,
23 statusCode: null,
24 syncedAt: 0,
25+ controlConnection: "disconnected",
26+ retryCount: 0,
27+ lastSuccessAt: 0,
28+ lastFailureAt: 0,
29+ nextRetryAt: 0,
30 source: "bootstrap",
31 raw: null,
32 ...overrides
33@@ -464,14 +475,80 @@ function formatSyncTime(timestamp) {
34 return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
35 }
36
37+function formatRetryDelay(targetTime) {
38+ if (!Number.isFinite(targetTime) || targetTime <= 0) return "";
39+ const remaining = Math.max(0, targetTime - Date.now());
40+ if (remaining < 1_000) return "不到 1 秒";
41+ if (remaining < 60_000) return `${Math.ceil(remaining / 1_000)} 秒`;
42+ return `${Math.ceil(remaining / 60_000)} 分钟`;
43+}
44+
45 function isWsEnabled() {
46 return !!state.wsUrl;
47 }
48
49-function controlModeClass(snapshot) {
50- if (!snapshot || snapshot.mode === "unknown") return "off";
51- if (snapshot.error) return "warn";
52- return snapshot.mode === "running" ? "on" : "warn";
53+function normalizeControlConnection(value) {
54+ switch (String(value || "").trim().toLowerCase()) {
55+ case "connecting":
56+ case "connected":
57+ case "retrying":
58+ case "disconnected":
59+ return String(value || "").trim().toLowerCase();
60+ default:
61+ return "disconnected";
62+ }
63+}
64+
65+function formatControlConnectionLabel(snapshot) {
66+ switch (normalizeControlConnection(snapshot?.controlConnection)) {
67+ case "connecting":
68+ return "连接中";
69+ case "connected":
70+ return "已连接";
71+ case "retrying":
72+ return "正在重试";
73+ default:
74+ return "已断开";
75+ }
76+}
77+
78+function controlConnectionClass(snapshot) {
79+ switch (normalizeControlConnection(snapshot?.controlConnection)) {
80+ case "connected":
81+ return "on";
82+ case "connecting":
83+ case "retrying":
84+ return "warn";
85+ default:
86+ return "off";
87+ }
88+}
89+
90+function formatControlMeta(snapshot) {
91+ const parts = [`自动化模式: ${formatModeLabel(snapshot.mode)}`];
92+ const connection = normalizeControlConnection(snapshot.controlConnection);
93+
94+ if (snapshot.lastSuccessAt > 0) {
95+ parts.push(`最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}`);
96+ } else if (connection === "connecting") {
97+ parts.push("浏览器启动后自动连接中");
98+ }
99+
100+ if (connection === "retrying") {
101+ parts.push("最近同步失败");
102+ if (snapshot.lastFailureAt > 0) {
103+ parts.push(`失败时间: ${formatSyncTime(snapshot.lastFailureAt)}`);
104+ }
105+ if (snapshot.nextRetryAt > 0) {
106+ parts.push(`下次重试: ${formatRetryDelay(snapshot.nextRetryAt)}`);
107+ }
108+ }
109+
110+ if (snapshot.error && connection !== "connected") {
111+ parts.push(`错误: ${snapshot.error}`);
112+ }
113+
114+ return parts.join(" · ");
115 }
116
117 function normalizeControlStatePayload(payload, meta = {}) {
118@@ -1122,6 +1199,8 @@ function renderControlSnapshot() {
119
120 return JSON.stringify({
121 ok: snapshot.ok,
122+ controlConnection: snapshot.controlConnection,
123+ retryCount: snapshot.retryCount,
124 mode: snapshot.mode,
125 leader: snapshot.leader,
126 leaseHolder: snapshot.leaseHolder,
127@@ -1129,6 +1208,9 @@ function renderControlSnapshot() {
128 activeRuns: snapshot.activeRuns,
129 statusCode: snapshot.statusCode,
130 syncedAt: snapshot.syncedAt ? new Date(snapshot.syncedAt).toISOString() : null,
131+ lastSuccessAt: snapshot.lastSuccessAt ? new Date(snapshot.lastSuccessAt).toISOString() : null,
132+ lastFailureAt: snapshot.lastFailureAt ? new Date(snapshot.lastFailureAt).toISOString() : null,
133+ nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
134 source: snapshot.source,
135 error: snapshot.error,
136 message: snapshot.message,
137@@ -1167,9 +1249,9 @@ function render() {
138 ui.clientId.textContent = !wsEnabled
139 ? "可选能力;当前默认仅使用 Control API"
140 : `客户端: ${state.clientId || "-"}${state.wsConnected ? "" : " · 等待连接"}`;
141- ui.controlMode.textContent = formatModeLabel(controlSnapshot.mode);
142- ui.controlMode.className = `value ${controlModeClass(controlSnapshot)}`;
143- ui.controlMeta.textContent = `${formatSyncTime(controlSnapshot.syncedAt)}${controlSnapshot.error ? ` · ${controlSnapshot.error}` : ""}`;
144+ ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
145+ ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
146+ ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
147 ui.leaderValue.textContent = controlSnapshot.leader || "-";
148 ui.leaderValue.className = `value ${controlSnapshot.leader ? "on" : "off"}`;
149 ui.leaderMeta.textContent = `租约: ${controlSnapshot.leaseHolder || "-"}`;
150@@ -1178,7 +1260,7 @@ function render() {
151 ui.queueMeta.textContent = "排队任务";
152 ui.runsValue.textContent = controlSnapshot.activeRuns == null ? "-" : String(controlSnapshot.activeRuns);
153 ui.runsValue.className = `value ${controlSnapshot.activeRuns > 0 ? "warn" : controlSnapshot.activeRuns === 0 ? "on" : "off"}`;
154- ui.runsMeta.textContent = controlSnapshot.message || "控制面";
155+ ui.runsMeta.textContent = `自动化模式: ${formatModeLabel(controlSnapshot.mode)}`;
156 ui.platformsView.textContent = renderPlatformStatus();
157 ui.controlView.textContent = renderControlSnapshot();
158 ui.headersView.textContent = renderHeaderSnapshot();
159@@ -1245,35 +1327,146 @@ async function requestControlPlane(path, options = {}) {
160 };
161 }
162
163+function getControlRetryDelay(retryCount) {
164+ const index = Math.max(0, Number(retryCount) - 1);
165+ return CONTROL_RETRY_DELAYS[index] || CONTROL_RETRY_SLOW_INTERVAL;
166+}
167+
168+function resetControlFailureLog() {
169+ state.lastControlFailureKey = "";
170+ state.lastControlFailureLogAt = 0;
171+}
172+
173+function createControlSuccessState(payload, meta = {}, previousSnapshot = null) {
174+ const normalized = normalizeControlStatePayload(payload, meta);
175+ const previous = cloneControlState(previousSnapshot || state.controlState);
176+
177+ return createDefaultControlState({
178+ ...previous,
179+ ...normalized,
180+ ok: true,
181+ error: null,
182+ controlConnection: "connected",
183+ retryCount: 0,
184+ lastSuccessAt: normalized.syncedAt,
185+ lastFailureAt: 0,
186+ nextRetryAt: 0
187+ });
188+}
189+
190+function createControlFailureState(error, meta = {}, previousSnapshot = null) {
191+ const previous = cloneControlState(previousSnapshot || state.controlState);
192+ const normalized = normalizeControlStatePayload(error.payload || error.message, {
193+ ok: false,
194+ statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
195+ source: meta.source || "http",
196+ error: error.message
197+ });
198+ const next = createDefaultControlState({
199+ ...previous,
200+ ok: false,
201+ error: error.message,
202+ message: previous.lastSuccessAt > 0
203+ ? "最近同步失败,正在自动重试"
204+ : "控制面暂不可用,正在自动重试",
205+ statusCode: normalized.statusCode,
206+ syncedAt: normalized.syncedAt,
207+ controlConnection: "retrying",
208+ retryCount: meta.retryCount || Math.max(1, previous.retryCount || 0),
209+ lastFailureAt: normalized.syncedAt,
210+ nextRetryAt: meta.nextRetryAt || 0,
211+ source: normalized.source,
212+ raw: normalized.raw
213+ });
214+
215+ if (normalized.mode !== "unknown") next.mode = normalized.mode;
216+ if (normalized.leader) next.leader = normalized.leader;
217+ if (normalized.leaseHolder) next.leaseHolder = normalized.leaseHolder;
218+ if (normalized.queueDepth != null) next.queueDepth = normalized.queueDepth;
219+ if (normalized.activeRuns != null) next.activeRuns = normalized.activeRuns;
220+
221+ return next;
222+}
223+
224+function logControlFailure(snapshot, options = {}) {
225+ const key = `${snapshot.statusCode || "-"}:${snapshot.error || "unknown"}`;
226+ const now = Date.now();
227+ const shouldLog = !options.silent
228+ || snapshot.retryCount <= 1
229+ || key !== state.lastControlFailureKey
230+ || now - state.lastControlFailureLogAt >= CONTROL_RETRY_LOG_INTERVAL;
231+
232+ if (!shouldLog) return;
233+
234+ state.lastControlFailureKey = key;
235+ state.lastControlFailureLogAt = now;
236+
237+ const retryDelay = formatRetryDelay(snapshot.nextRetryAt);
238+ addLog(
239+ "warn",
240+ `控制面同步失败:${snapshot.error}${retryDelay ? `;${retryDelay}后继续重试` : ""}`,
241+ false
242+ );
243+}
244+
245 async function refreshControlPlaneState(options = {}) {
246- try {
247- const response = await requestControlPlane("/v1/system/state");
248- const snapshot = normalizeControlStatePayload(response.payload, {
249- ok: true,
250- statusCode: response.statusCode,
251- source: options.source || "http"
252- });
253- await setControlState(snapshot);
254+ if (state.controlRefreshInFlight) {
255+ return state.controlRefreshInFlight;
256+ }
257
258- if (!options.silent) {
259- addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`);
260- }
261+ const task = (async () => {
262+ const source = options.source || "http";
263+ const previousSnapshot = cloneControlState(state.controlState);
264+ const previousConnection = normalizeControlConnection(previousSnapshot.controlConnection);
265
266- return snapshot;
267- } catch (error) {
268- const snapshot = normalizeControlStatePayload(error.payload || error.message, {
269- ok: false,
270- statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
271- source: options.source || "http",
272- error: error.message
273- });
274- await setControlState(snapshot);
275+ try {
276+ const response = await requestControlPlane("/v1/system/state");
277+ const snapshot = createControlSuccessState(response.payload, {
278+ ok: true,
279+ statusCode: response.statusCode,
280+ source
281+ }, previousSnapshot);
282+ await setControlState(snapshot);
283+ resetControlFailureLog();
284+ restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
285+ reason: "poll"
286+ });
287
288- if (!options.silent) {
289- addLog("error", `控制面状态同步失败:${error.message}`);
290+ if (!options.silent) {
291+ addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
292+ } else if (previousConnection !== "connected") {
293+ addLog("info", `控制面已恢复:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
294+ }
295+
296+ return snapshot;
297+ } catch (error) {
298+ const retryCount = previousConnection === "retrying"
299+ ? (Number(previousSnapshot.retryCount) || 0) + 1
300+ : 1;
301+ const retryDelay = getControlRetryDelay(retryCount);
302+ const nextRetryAt = Date.now() + retryDelay;
303+ const snapshot = createControlFailureState(error, {
304+ source,
305+ retryCount,
306+ nextRetryAt
307+ }, previousSnapshot);
308+ await setControlState(snapshot);
309+ restartControlPlaneRefreshTimer(retryDelay, {
310+ reason: "retry"
311+ });
312+ logControlFailure(snapshot, options);
313+ throw error;
314 }
315+ })();
316
317- throw error;
318+ state.controlRefreshInFlight = task;
319+
320+ try {
321+ return await task;
322+ } finally {
323+ if (state.controlRefreshInFlight === task) {
324+ state.controlRefreshInFlight = null;
325+ }
326 }
327 }
328
329@@ -1305,12 +1498,34 @@ async function runControlPlaneAction(action, options = {}) {
330 };
331 }
332
333-function restartControlPlaneRefreshTimer() {
334- clearInterval(state.controlRefreshTimer);
335- state.controlRefreshTimer = setInterval(() => {
336- refreshTrackedTabsFromBrowser("poll").catch(() => {});
337- refreshControlPlaneState({ source: "poll", silent: true }).catch(() => {});
338- }, CONTROL_REFRESH_INTERVAL);
339+async function runScheduledControlPlaneRefresh(reason = "poll") {
340+ await refreshTrackedTabsFromBrowser(reason).catch(() => {});
341+ await refreshControlPlaneState({
342+ source: reason,
343+ silent: true
344+ }).catch(() => {});
345+}
346+
347+function restartControlPlaneRefreshTimer(delay = CONTROL_REFRESH_INTERVAL, options = {}) {
348+ clearTimeout(state.controlRefreshTimer);
349+ state.controlRefreshTimer = setTimeout(() => {
350+ runScheduledControlPlaneRefresh(options.reason || "poll").catch(() => {});
351+ }, Math.max(0, Number(delay) || 0));
352+}
353+
354+async function prepareStartupControlState() {
355+ const previous = cloneControlState(state.controlState);
356+ await setControlState(createDefaultControlState({
357+ ...previous,
358+ ok: previous.lastSuccessAt > 0,
359+ controlConnection: "connecting",
360+ retryCount: 0,
361+ nextRetryAt: 0,
362+ lastFailureAt: 0,
363+ error: null,
364+ message: "浏览器启动后自动连接中",
365+ source: "startup"
366+ }));
367 }
368
369 function wsSend(payload) {
370@@ -2129,7 +2344,7 @@ function bindUi() {
371 });
372
373 qs("save-control-btn").addEventListener("click", () => {
374- state.controlBaseUrl = trimTrailingSlash(ui.controlBaseUrl.value) || DEFAULT_CONTROL_BASE_URL;
375+ state.controlBaseUrl = normalizeSavedControlBaseUrl(ui.controlBaseUrl.value);
376 persistState().catch(() => {});
377 addLog("info", `已保存控制 API:${state.controlBaseUrl}`, false);
378 render();
379@@ -2225,12 +2440,12 @@ async function init() {
380 } else {
381 addLog("info", "当前默认模式只使用 Control API;可选 WS 保持未启用", false);
382 }
383- restartControlPlaneRefreshTimer();
384+ await prepareStartupControlState();
385 refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
386 }
387
388 window.addEventListener("beforeunload", () => {
389- clearInterval(state.controlRefreshTimer);
390+ clearTimeout(state.controlRefreshTimer);
391 clearTimeout(state.trackedTabRefreshTimer);
392 closeWsConnection();
393 });
1@@ -1,16 +1,18 @@
2 # Firefox Conductor Control
3
4-`baa-firefox` 现在包含一条最小可用的 conductor control-plane 接线。
5+`baa-firefox` 现在默认通过 Control API 自动连接 conductor control-plane,并在 Firefox 启动后自动恢复同步。
6
7 ## 范围
8
9 - `controller.html` / `controller.js`
10 - 配置 Control API base URL
11- - 读取 `GET /v1/system/state`
12+ - 启动后立即读取 `GET /v1/system/state`
13+ - 失败时自动退避重试,成功后恢复常规轮询
14 - 调用 `POST /v1/system/pause`
15 - 调用 `POST /v1/system/resume`
16 - 调用 `POST /v1/system/drain`
17 - `background.js`
18+ - Firefox 启动时确保 `controller.html` 存在
19 - 根据最新 control snapshot 更新扩展 badge
20 - `content-script.js`
21 - 在 Claude 页面右下角提供最小浮层入口
22@@ -28,6 +30,7 @@
23
24 当前临时单节点模式默认不开启 Control API 鉴权,因此插件不会再要求填写 bearer token。
25 当前默认模式也不再要求本地 `ws://127.0.0.1:9800` 或 `ws://localhost:9800`。
26+如果 storage 中没有显式配置,插件仍然会默认回落到 `https://control-api.makefile.so`。
27
28 状态快照会持久化到 `browser.storage.local` 的 `baaFirefox.controlState`,供:
29
30@@ -44,15 +47,31 @@
31 - `lease_holder`
32 - `queue_depth` / `queued_tasks`
33 - `active_runs`
34+- `controlConnection`
35+- `retryCount`
36+- `lastSuccessAt`
37+- `lastFailureAt`
38+- `nextRetryAt`
39
40 如果服务端实际字段名略有不同,`controller.js` 已做多路径兼容解析。
41
42+## 默认连接行为
43+
44+- Firefox 启动时,`background.js` 会确保 `controller.html` 标签页存在。
45+- `controller.html` 启动后会立刻请求 `GET /v1/system/state`,不需要用户先点“刷新控制面”。
46+- 正常同步成功后,插件按 `15` 秒周期继续拉取 Control API。
47+- 如果 Control API 临时失败、服务端重启或网络抖动,插件会按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试,不会停在一次性报错状态。
48+- 服务恢复后,插件会自动回到正常已连接状态,不需要手动刷新页面或重新打开 controller。
49+
50 ## 可见入口
51
52 - 扩展工具栏 badge
53- - `RUN` / `PAU` / `DRN` / `ERR`
54+ - 已连接时显示自动化模式
55+ - 自动重试时显示重试状态
56 - `controller.html`
57- - 优先展示 control-plane 卡片、原始快照和动作按钮
58+ - 第一张卡片主状态表示 Control API 连接状态
59+ - 会显示最近成功时间、最近失败和下一次重试
60+ - 保留“刷新控制面”按钮作为手动触发入口
61 - 如果未配置 WS,则显示 `未启用`,不再把它当成主故障
62 - Claude 页面
63 - 右下角 `BAA` 浮层,可直接 `Pause` / `Resume` / `Drain` 或打开 controller
64@@ -62,4 +81,4 @@
65 - 本次没有改 `manifest.json`
66 - 因此 Control API 需要落在当前扩展 CSP 允许的地址范围内
67 - 当前默认目标就是 `https://control-api.makefile.so`
68-- 可选 WS 能力仍然保留,但默认不启用
69+- 可选 WS 能力仍然保留,但默认不启用,也不是主连接状态来源