fix(system): retired heartbeat rows no longer alarm on status page

- GET /system/health splits processes into current instances (hosts with
  a beat in the last 10 min, newest per host+kind, not stopped) and
  retiredProcesses[]; 45s stale and level only consider current ones
- heartbeat writer marks stopped_at on graceful exit (migration 0043),
  upsert clears it; pure api role now stops its writer on SIGTERM
- scheduled job system.heartbeat_cleanup (maintain, 1h) deletes rows
  without a beat for 24h and samples older than 7 days
- status page: per-role alarms ("io worker 45 秒没有心跳"), collapsible
  retired instances; glossary/runbook updated
- bump all members to 0.2.2, CHANGELOG [0.2.2]

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 23:31:21 +08:00
1 parent fa261eb6cd
commit b4c9b1ead2
32 files changed
+747 -91

No files matched your search

@@ -1,6 +1,7 @@
/**
* camofox-browser 适配器(BrowserProvider 首个实现)。
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
* 对照:jo-inc/camofox-browser v1.17.0(commit 389c996)openapi.json、README 与 server.js;2026-09-28 对真实容器核验,
* 结果见 docs/runbook/browser.md「真机核验」。
*
* 上游模型与本项目合同的映射:
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
@@ -9,10 +10,10 @@
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
*
* 已实现(仅对 mock 与上游文档验证):health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
* 已实现:health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
* 未核验的上游行为(坐标点击、无目标输入、downloads 字段形状)见 docs/runbook/browser.md。
* 与文档不同的实测行为(截图直接返回 image/png、click 不接受坐标、焦点输入需 mode=keyboard、downloads 字段名)已按实测适配。
*/
import type {
BrowserCapabilities,
@@ -718,6 +719,13 @@ export class CamofoxBrowserProvider implements BrowserProvider {
await this.withProfile(s, async () => {
this.assertActor(s, ref.as);
const tab = this.tabOf(s, ref);
// 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作,
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
if (typeof payload.x === "number" && typeof payload.y === "number") {
payload.selector = await this.pointSelector(s, tab, payload.x, payload.y);
delete payload.x;
delete payload.y;
}
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action}`;
const res = await this.request("POST", path, {
body: { userId: s.session.profileKey, ...payload },
@@ -732,6 +740,22 @@ export class CamofoxBrowserProvider implements BrowserProvider {
});
}
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
const nonce = this.newId().replace(/[^a-z0-9]/gi, "").slice(0, 16);
const expression = `(() => { const e = document.elementFromPoint(${x}, ${y}); if (!e) return false;
for (const o of document.querySelectorAll("[data-umt-pt]")) o.removeAttribute("data-umt-pt");
e.setAttribute("data-umt-pt", "${nonce}"); return true; })()`;
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
const res = await this.request("POST", path, {
body: { userId: s.session.profileKey, expression },
timeoutMs: s.budget.timeoutMs,
});
const out = await this.json(res, path);
if (out.result !== true) throw new BrowserError("bad_request", "该坐标处没有可操作的元素");
return `[data-umt-pt="${nonce}"]`;
}
/** 点击可能跳转或改动页面:之后旧引用作废 */
async click(ref: TabRef, target: ElementTarget): Promise<void> {
await this.act(
@@ -765,7 +789,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
ref,
"type",
(s) => ({
...(target ? this.assertTarget(s, target) : {}),
// 无目标 = 输入到当前焦点:上游 mode=fill 必须带 ref/selector,焦点输入要 mode=keyboard(1.17.0 实测)
...(target ? this.assertTarget(s, target) : { mode: "keyboard" }),
text,
...(input.clear !== undefined ? { clear: input.clear } : {}),
...(input.submit !== undefined ? { submit: input.submit } : {}),
@@ -875,7 +900,13 @@ export class CamofoxBrowserProvider implements BrowserProvider {
if (s.seenDownloads.has(key)) continue;
const status = str(d.status) ?? str(d.state);
if (status && !["completed", "complete", "done", "finished"].includes(status)) continue;
const b64 = str(d.data) ?? str(d.base64) ?? str(d.content);
// 1.17.0 实测字段:{ id, url, suggestedFilename, mimeType, bytes, createdAt, failure, dataBase64 | dataSkipped | readError }
if (d.failure) {
s.seenDownloads.add(key);
throw new BrowserError("upstream", "浏览器下载失败");
}
if (d.dataSkipped) throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
const b64 = str(d.dataBase64) ?? str(d.data) ?? str(d.base64) ?? str(d.content);
const size = int(d.size) ?? int(d.bytes) ?? null;
if (size !== null && size > opts.maxBytes)
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);