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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/adapters",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -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})`);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/agent",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/contracts",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+25
View File
@@ -2167,6 +2167,31 @@ export const Api = {
query: browser.BrowserProvidersQuery,
response: z.object({ items: z.array(browser.BrowserProviderView) }),
}),
createProvider: ep({
method: "post",
path: "/browser/providers",
scope: "admin",
summary: "登记浏览器服务(camofox 地址、访问凭据存凭证库、启用)",
body: browser.BrowserProviderCreate,
response: browser.BrowserProviderView,
}),
updateProvider: ep({
method: "patch",
path: "/browser/providers/{id}",
scope: "admin",
summary: "修改浏览器服务地址、访问凭据或启用(环境变量登记的只能改启用)",
params: browser.BrowserProviderParams,
body: browser.BrowserProviderUpdate,
response: browser.BrowserProviderView,
}),
probeProvider: ep({
method: "post",
path: "/browser/providers/{id}/probe",
scope: "admin",
summary: "测试连通:探测该浏览器服务的 /health 与 /openapi.json 并写回能力",
params: browser.BrowserProviderParams,
response: browser.BrowserProviderView,
}),
sessions: ep({
method: "get",
path: "/browser/sessions",
+29 -1
View File
@@ -240,8 +240,12 @@ export const BrowserProviderView = z.object({
baseUrl: z.string().nullable(),
/** 访问凭据的 secret 引用(明文永不出 API) */
secretRef: Id.nullable(),
/** env = 由 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 登记(地址与凭据只能改部署配置);null = 设置页登记 */
managedBy: z.enum(["env"]).nullable(),
/** 是否配置了访问凭据(secret 或环境变量) */
hasAccessKey: z.boolean(),
enabled: z.boolean(),
/** 最近一次声明/探测到的能力;verified 只由人工核验写入 config.verified */
/** 最近一次声明/探测到的能力;verified 只由人工核验写入(config.verified 或适配器内置的已核验版本表) */
capabilities: BrowserCapabilities.nullable(),
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
@@ -251,6 +255,30 @@ export const BrowserProviderView = z.object({
});
export type BrowserProviderView = z.infer<typeof BrowserProviderView>;
const HttpUrl = z
.string()
.url()
.refine((u) => /^https?:\/\//i.test(u), "只支持 http(s) 地址");
/** 设置页登记浏览器服务(camofox);accessKey 明文只在请求里出现一次,落库为 secret */
export const BrowserProviderCreate = z.object({
name: z.string().min(1).max(64),
baseUrl: HttpUrl,
accessKey: z.string().min(1).max(512).optional(),
enabled: z.boolean().optional(),
});
export type BrowserProviderCreate = z.infer<typeof BrowserProviderCreate>;
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据 */
export const BrowserProviderUpdate = z.object({
baseUrl: HttpUrl.optional(),
accessKey: z.string().min(1).max(512).nullable().optional(),
enabled: z.boolean().optional(),
});
export type BrowserProviderUpdate = z.infer<typeof BrowserProviderUpdate>;
export const BrowserProviderParams = z.object({ id: Id });
export const BrowserProvidersQuery = z.object({
/** true = 现场探测 /health 与 /openapi.json 并写回 */
probe: QueryBool,
+6 -1
View File
@@ -381,8 +381,10 @@ export const SystemProcess = z.object({
lastBeatAt: Timestamp,
/** 距上次心跳秒数 */
beatAgeSec: z.number().nonnegative(),
/** 超过 45s 没心跳 */
/** 超过 45s 没心跳(只对当前实例告警) */
stale: z.boolean(),
/** 进程正常退出时写入;非 null 即已下线 */
stoppedAt: Timestamp.nullable(),
/** 最近一次采样的事件循环延迟 */
eventLoopLagMs: NonNegInt,
/** 最近一个心跳周期(15s)内的最大延迟 */
@@ -434,7 +436,10 @@ export const SystemHealth = z.object({
checkedAt: Timestamp,
/** 处理本请求的 api 进程 */
api: z.object({ version: z.string(), startedAt: Timestamp, uptimeSec: z.number().nonnegative() }),
/** 当前实例:最近 10 分钟内有心跳的 host 上、同 host 同 kind 最新启动且未退出的进程;stale 与告警只看这里 */
processes: z.array(SystemProcess),
/** 已下线实例:已正常退出、所在 host 10 分钟没有心跳(容器已重建)、或被同 host 新进程替换;不计入 level */
retiredProcesses: z.array(SystemProcess),
sse: z.object({ connections: NonNegInt }),
db: z.object({
level: HealthLevel,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/db",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -0,0 +1,16 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// 心跳残留修复:进程正常退出(SIGTERM / SIGINT)时把自己的行标 stopped_at,读取方立即把它当已下线实例,
// 不再等 45s 判 stale。重启后同一 instance_id 的 upsert 把 stopped_at 清回 null。
// 超过 24 小时没心跳的行由定时任务 system.heartbeat_cleanup 删除。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table process_heartbeat add column stopped_at timestamptz;`);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table process_heartbeat drop column if exists stopped_at;`);
}
+1 -1
View File
@@ -1,5 +1,5 @@
// 统一从本包取 Kysely 与 sql,避免各包各装一份 kysely
export { type Kysely, sql, type Transaction } from "kysely";
export { type Kysely, type Selectable, sql, type Transaction } from "kysely";
export {
type CachedOptions,
cachedCall,
+1
View File
@@ -1152,6 +1152,7 @@ export interface ProcessHeartbeatTable {
concurrency: Generated<Json>;
running_runs: number | null;
extra: Generated<Json>;
stopped_at: Timestamptz | null;
}
export interface ProcessHeartbeatSampleTable {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/domain",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/mapping",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/tasks",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/testkit",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+43 -11
View File
@@ -4,14 +4,15 @@ import { deflateSync } from "node:zlib";
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
/**
* mock camofox-browser(形状按 jo-inc/camofox-browser openapi.json 1.17.0,取值虚构,未对真实实例核验)。
* mock camofox-browser(形状按 jo-inc/camofox-browser v1.17.0,2026-09-28 与真实容器对照过请求/响应字段;取值虚构)。
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、
* POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、
* POST /tabs/{id}/{click,type,select,scroll,press,extract}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
* POST /tabs/{id}/{click,type,select,scroll,press,extract,evaluate}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
* 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。
* 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工),
* 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。
* 坐标点击:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H)。
* 坐标:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H);与真实上游一样
* click 不接受 {x, y},适配器先 evaluate(elementFromPoint)打 data-umt-pt 标记再按选择器点击。
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
* 故障注入(401/超时/断连)用 MockHttp.fault。
*/
@@ -58,6 +59,8 @@ export interface MockCamofoxTab {
/** 元素引用 → 已输入的值 */
fields: Map<string, string>;
focus: string | null;
/** evaluate(elementFromPoint)打的 data-umt-pt 标记 → 元素引用 */
marks: Map<string, string>;
downloads: MockCamofoxDownload[];
error: string | null;
}
@@ -121,6 +124,7 @@ const OPENAPI_PATHS: Record<string, string[]> = {
"/tabs/{tabId}/click": ["post"],
"/tabs/{tabId}/type": ["post"],
"/tabs/{tabId}/select": ["post"],
"/tabs/{tabId}/evaluate": ["post"],
"/tabs/{tabId}/extract": ["post"],
"/tabs/{tabId}/downloads": ["get"],
"/sessions/{userId}": ["delete"],
@@ -403,8 +407,11 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => {
const lines = pageLines(t);
if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref);
if (typeof b.y === "number") return lines[Math.floor((b.y - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (typeof b.selector === "string") return lines.find((l) => l.role.startsWith("button"));
if (typeof b.selector === "string") {
const mark = /^\[data-umt-pt="([^"]+)"\]$/.exec(b.selector)?.[1];
if (mark) return lines.find((l) => l.ref === t.marks.get(mark));
return lines.find((l) => l.role.startsWith("button"));
}
return undefined;
};
@@ -473,6 +480,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
cursor: -1,
fields: new Map(),
focus: null,
marks: new Map(),
downloads: [],
error: null,
};
@@ -565,6 +573,9 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
[
/^\/tabs\/([^/]+)\/click$/,
action("click", (t, b) => {
// 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记
if (typeof b.ref !== "string" && typeof b.selector !== "string")
return err(400, "ref or selector required");
const line = findLine(t, b);
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
if (line.role.startsWith("textbox")) t.focus = line.ref;
@@ -576,6 +587,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
/^\/tabs\/([^/]+)\/type$/,
action("type", (t, b) => {
if (typeof b.text !== "string") return err(400, "text required");
if (typeof b.ref !== "string" && typeof b.selector !== "string" && b.mode !== "keyboard")
return err(400, "ref or selector required for mode=fill");
const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus);
if (!ref) return err(422, "No focused element");
t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text);
@@ -584,6 +597,21 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
return undefined;
}),
],
[
/^\/tabs\/([^/]+)\/evaluate$/,
action("evaluate", (t, b) => {
// 只模拟适配器的坐标定位表达式:elementFromPoint(x, y) + data-umt-pt 标记
const expr = typeof b.expression === "string" ? b.expression : "";
const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
if (!pt || !nonce) return { json: { ok: true, result: null } };
const line = pageLines(t)[Math.floor((Number(pt[2]) - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (!line) return { json: { ok: true, result: false } };
t.marks.clear();
t.marks.set(nonce, line.ref);
return { json: { ok: true, result: true } };
}),
],
[/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)],
[/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)],
[
@@ -617,16 +645,20 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY;
// 字段形状按 1.17.0 实测(lib/downloads.js getDownloadsList)
const list = t.downloads.map((d) => ({
id: d.id,
filename: d.filename,
url: d.url,
suggestedFilename: d.filename,
mimeType: d.mimeType,
size: d.bytes.byteLength,
status: "completed",
...(q.includeData === "true" && d.bytes.byteLength <= max
? { data: Buffer.from(d.bytes).toString("base64") }
: { truncated: d.bytes.byteLength > max }),
bytes: d.bytes.byteLength,
createdAt: Date.now(),
failure: null,
...(q.includeData === "true"
? d.bytes.byteLength <= max
? { dataBase64: Buffer.from(d.bytes).toString("base64") }
: { dataSkipped: "max_bytes_exceeded" }
: {}),
}));
if (q.consume === "true") t.downloads = [];
return { json: { downloads: list } };