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
+728 -72

No files matched your search

+10
View File
@@ -22,6 +22,16 @@
- 没有调查整个 qB 实例(含未纳管任务)的能力。
- AT18(恶意标题)、AT29(部分入库不清理)、AT42(未纳管占用)、AT73(多下载器同 hash)与流程版本升级没有测试。
## [0.2.2] - 2026-09-28
### 修复
- 编译产物启动即 `ERR_MODULE_NOT_FOUND`:web 的 `shadcn` 开发依赖带进第二份 `@modelcontextprotocol/[email protected]`(zod@3),`deno compile --exclude-unused-npm` 嵌入的目录与裁剪后快照解析的路径不一致。去掉 `shadcn` 依赖,只把它的 `tailwind.css` 拷成 `apps/web/app/shadcn-tailwind.css`,MCP SDK 只剩一份。
- 编译产物自检:`scripts/check-compiled-binary.sh` 用空环境跑 `--role preflight`,必须加载完整模块图并在配置校验处以退出码 2 结束;已接入 `deno task ci`(`check:binary`)与 Dockerfile 构建阶段。
- 运行状态页把容器重建前留下的旧心跳行当成"N 个进程超过 45 秒没有心跳"报警。`GET /system/health` 现在把进程分成当前实例(最近 10 分钟有心跳的 host 上、同 host 同 kind 最新启动且未退出的进程)与已下线实例,45 秒 stale 判定与告警只看当前实例;已下线实例放在新字段 `retiredProcesses[]`,不计入 `level`,页面折叠显示。横幅按角色报警,如「io worker 45 秒没有心跳」。
- 进程正常退出(SIGTERM / SIGINT)时把自己的心跳行标 `stopped_at`(迁移 0043),退出后立即不再当活进程;重启后 upsert 清回 null。纯 api 进程退出时也会停心跳写入器。
- 心跳清理改为定时任务 `system.heartbeat_cleanup`(维护组,1 小时):删 24 小时没有心跳的进程行和 7 天前的采样;写入器不再自己裁剪。
## [0.2.0] - 2026-09-28
Agent 上游全覆盖与订阅登录、控制台信息架构重排、定时任务看板、全站文案第二轮审校;W9 与前端波补上站点中心、订阅重做、统一规则、命名模板、全局搜索、文件管理 v2 与统一任务页。基线见 `docs/plan/08-v0.2-closeout.md`(W8-A 与"W10 集成记录")、`docs/plan/09-visual-qa.md`、`docs/design/{console-ia,site-center,subscriptions,naming-template,file-manager-v2,pi-providers,agent-ui-reference}.md`、`docs/runbook/{schedules,agent,sites,rules}.md`。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/server",
"version": "0.2.0",
"version": "0.2.2",
"exports": { ".": "./src/main.ts" },
"tasks": {
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
+19
View File
@@ -1,6 +1,9 @@
import { join, resolve } from "node:path";
import { z } from "zod";
/** compose 里 `${X:-}` 展开成空串:按未设置处理 */
const emptyToUndef = (v: unknown) => (v === "" ? undefined : v);
const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]);
export const ConfigSchema = z.object({
@@ -66,6 +69,22 @@ export const ConfigSchema = z.object({
ORGANIZE_ON_EXISTING: z.enum(["conflict", "skip", "replace_with_verify"]).default("conflict"),
/** 随行字幕中此语言加 .default 标记(对照 MP DEFAULT_SUB);不设 = 不加 */
ORGANIZE_DEFAULT_SUBTITLE_LANG: z.enum(["zh-cn", "zh-tw", "en", "ja", "ko"]).optional(),
// ── 浏览器服务(camofox-browser,docs/runbook/browser.md)──
/**
* 部署自带的浏览器服务地址(compose 缺省 http://camofox:9377;接自有实例时改成它的地址)。
* 设置后启动时登记/更新名为 camofox-env 的浏览器服务;不设则只用设置页登记的。
*/
BROWSER_PROVIDER_URL: z.preprocess(emptyToUndef, z.string().url().optional()),
/** 该服务的访问凭据(camofox CAMOFOX_ACCESS_KEY);只在进程内使用,不入库 */
BROWSER_ACCESS_KEY: z.preprocess(emptyToUndef, z.string().min(1).optional()),
/** false = 停用 camofox-env(容器可照常运行,界面标"浏览器不可用") */
BROWSER_ENABLED: z.preprocess(
emptyToUndef,
z
.enum(["true", "false", "1", "0"])
.default("true")
.transform((v) => v === "true" || v === "1"),
),
});
export type Config = z.infer<typeof ConfigSchema>;
+5
View File
@@ -237,6 +237,11 @@ if (role === "api" || role === "all") {
ac.abort();
await server.finished;
});
if (role === "api") {
// 纯 api 进程不走 worker 的模块 stop:这里停 system 模块的心跳写入器,把本进程的心跳行标 stopped_at
const sys = SERVER_MODULES.find((m) => m.name === "system");
if (sys?.stop) stops.push(async () => await sys.stop?.());
}
}
if (role === "worker" || role === "worker:agent" || role === "all") {
const worker = await startWorker({
@@ -85,6 +85,13 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
const B = Api.browser;
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
route(app, B.probeProvider, async ({ params }) => {
const [v] = await svc().listProviders(true, params.id);
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
return v;
});
route(app, B.sessions, async ({ query }) => ({
items: await svc().list({
+137 -4
View File
@@ -114,8 +114,13 @@ export interface RecoveryState {
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
}
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
export const ENV_PROVIDER_NAME = "camofox-env";
const ENV_MANAGED = "env";
export class BrowserService {
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
private envSync: Promise<void> | undefined;
readonly now: () => Date;
constructor(
@@ -163,7 +168,11 @@ export class BrowserService {
const { db, secrets } = this.ctx;
return {
resolve: async (req) => {
if (req.kind === "provider_access_key") return row.secret_ref ? secrets.resolve(row.secret_ref) : null;
if (req.kind === "provider_access_key") {
if (row.secret_ref) return secrets.resolve(row.secret_ref);
// 环境变量登记的服务:凭据只在进程内(BROWSER_ACCESS_KEY),不入库
return obj(row.config).managedBy === ENV_MANAGED ? (this.ctx.config?.BROWSER_ACCESS_KEY ?? null) : null;
}
let q = db
.selectFrom("site_account")
.selectAll()
@@ -210,7 +219,126 @@ export class BrowserService {
return provider;
}
/**
* BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → 名为 camofox-env 的浏览器服务(每个进程同步一次)。
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
*/
syncEnvProvider(): Promise<void> {
this.envSync ??= this.doSyncEnvProvider().catch((e) => {
this.envSync = undefined;
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
});
return this.envSync;
}
private async doSyncEnvProvider(): Promise<void> {
const c = this.ctx.config;
const url = c?.BROWSER_PROVIDER_URL;
const enabled = !!url && c.BROWSER_ENABLED !== false;
const db = this.ctx.db;
const row = await db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirst();
if (!url) {
if (row?.enabled)
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
return;
}
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
if (row)
await db
.updateTable("browser_provider")
.set({ base_url: url, enabled, kind: "camofox", secret_ref: null, config })
.where("id", "=", row.id)
.execute();
else
await db
.insertInto("browser_provider")
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
.onConflict((oc) => oc.column("name").doNothing())
.execute();
}
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
async createProvider(body: {
name: string;
baseUrl: string;
accessKey?: string | undefined;
enabled?: boolean | undefined;
}): Promise<BrowserProviderView> {
if (body.name === ENV_PROVIDER_NAME)
throw new BrowserServiceError("bad_request", `名称 ${ENV_PROVIDER_NAME} 保留给环境变量`);
const exists = await this.ctx.db
.selectFrom("browser_provider")
.select("id")
.where("name", "=", body.name)
.executeTakeFirst();
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
const secretRef = body.accessKey
? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
: null;
const row = await this.ctx.db
.insertInto("browser_provider")
.values({
name: body.name,
kind: "camofox",
base_url: body.baseUrl,
secret_ref: secretRef,
enabled: body.enabled ?? true,
})
.returning("id")
.executeTakeFirstOrThrow();
return this.providerView(row.id);
}
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
async updateProvider(
id: string,
body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
): Promise<BrowserProviderView> {
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
if (obj(row.config).managedBy === ENV_MANAGED && (body.baseUrl !== undefined || body.accessKey !== undefined))
throw new BrowserServiceError(
"conflict",
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
);
let secretRef = row.secret_ref;
if (body.accessKey === null) secretRef = null;
else if (body.accessKey) {
if (secretRef) await this.ctx.secrets.replace(secretRef, body.accessKey);
else
secretRef = await this.ctx.secrets.create({
purpose: "other",
plaintext: body.accessKey,
label: `browser:${row.name}`,
});
}
await this.ctx.db
.updateTable("browser_provider")
.set({
...(body.baseUrl !== undefined ? { base_url: body.baseUrl } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
secret_ref: secretRef,
})
.where("id", "=", id)
.execute();
if (row.secret_ref && secretRef === null) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
// 凭据换了但 secret_ref 不变:签名不变,显式丢弃缓存的适配器
this.providers.delete(id);
return this.providerView(id);
}
private async providerView(id: string): Promise<BrowserProviderView> {
const v = (await this.listProviders(false, id))[0];
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
return v;
}
async providerRow(id?: string): Promise<BrowserProviderRow> {
await this.syncEnvProvider();
let q = this.ctx.db
.selectFrom("browser_provider")
.selectAll()
@@ -223,12 +351,14 @@ export class BrowserService {
return row;
}
async listProviders(probe: boolean): Promise<BrowserProviderView[]> {
const rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
async listProviders(probe: boolean, onlyId?: string): Promise<BrowserProviderView[]> {
await this.syncEnvProvider();
let rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
if (onlyId) rows = rows.filter((r) => r.id === onlyId);
const out: BrowserProviderView[] = [];
for (let row of rows) {
let health: BrowserHealth | null = null;
if (probe && row.enabled && row.kind !== "none") {
if (probe && (row.enabled || onlyId) && row.kind !== "none") {
const p = this.providerFor(row);
health = await p.health();
let caps: BrowserCapabilities | null = null;
@@ -255,6 +385,9 @@ export class BrowserService {
kind: row.kind as "camofox" | "none",
baseUrl: row.base_url,
secretRef: row.secret_ref,
managedBy: obj(row.config).managedBy === ENV_MANAGED ? "env" : null,
hasAccessKey:
!!row.secret_ref || (obj(row.config).managedBy === ENV_MANAGED && !!this.ctx.config?.BROWSER_ACCESS_KEY),
enabled: row.enabled,
capabilities: Object.keys(caps).length ? (caps as unknown as BrowserCapabilities) : null,
verifiedAt: iso(row.verified_at),
+59 -15
View File
@@ -8,8 +8,8 @@ import type {
SystemMount,
SystemProcess,
} from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import { HEARTBEAT_STALE_MS } from "./heartbeat.ts";
import { type DB, type Kysely, type Selectable, sql } from "@umt/db";
import { CURRENT_HOST_WINDOW_MS, HEARTBEAT_ROW_RETENTION_MS, HEARTBEAT_STALE_MS } from "./heartbeat.ts";
// GET /system/health 各段的取数(W9-I)。每段独立 try/catch:一段失败只把该段标 unknown,不让整页 500。
// 慢 IO(statfs / 哨兵检查)都带超时:挂载卡死时只标 timeout,不拖住请求。
@@ -53,15 +53,9 @@ const num = (v: unknown) => (v == null ? null : Number(v));
// ── 进程 ──
export async function loadProcesses(db: Kysely<DB>, now: Date): Promise<SystemProcess[]> {
const rows = await db
.selectFrom("process_heartbeat")
.selectAll()
.where("last_beat_at", ">", new Date(now.getTime() - 24 * 3600_000))
.orderBy("process_kind")
.orderBy("instance_id")
.execute();
return rows.map((r) => {
type HeartbeatRow = Selectable<DB["process_heartbeat"]>;
function toProcess(r: HeartbeatRow, now: Date): SystemProcess {
const beat = toDate(r.last_beat_at);
const age = Math.max(0, now.getTime() - beat.getTime());
const extra = (r.extra ?? {}) as Record<string, unknown>;
@@ -77,6 +71,7 @@ export async function loadProcesses(db: Kysely<DB>, now: Date): Promise<SystemPr
lastBeatAt: beat.toISOString(),
beatAgeSec: Math.round(age / 100) / 10,
stale: age > HEARTBEAT_STALE_MS,
stoppedAt: r.stopped_at ? toDate(r.stopped_at).toISOString() : null,
eventLoopLagMs: r.event_loop_lag_ms,
eventLoopLagMaxMs: r.event_loop_lag_max_ms,
concurrency: {
@@ -87,10 +82,55 @@ export async function loadProcesses(db: Kysely<DB>, now: Date): Promise<SystemPr
sseConnections: typeof sse === "number" ? sse : null,
extra,
};
});
}
/** 进程段的档位:有 stale 或延迟 ≥ 5s 为 critical;延迟 ≥ 1s 为 warn;没有任何 worker 心跳为 warn */
/**
* 把心跳行分成当前实例与已下线实例:
* - 当前 host = 最近 10 分钟内有心跳且没标 stopped_at 的行所在 host(容器重建后 host 会变);
* - 当前实例 = 当前 host 上、没标 stopped_at、且是同 host 同 kind 里最新启动的那一行;
* - 其余(已正常退出、host 已不在、被同 host 新进程替换)都算已下线实例,不参与 stale 判定与告警。
*/
export function splitProcesses(
ps: SystemProcess[],
now: Date,
): { processes: SystemProcess[]; retiredProcesses: SystemProcess[] } {
const alive = ps.filter((p) => !p.stoppedAt);
const hosts = new Set(
alive.filter((p) => now.getTime() - Date.parse(p.lastBeatAt) <= CURRENT_HOST_WINDOW_MS).map((p) => p.host),
);
const newest = new Map<string, SystemProcess>();
for (const p of alive) {
if (!hosts.has(p.host)) continue;
const k = `${p.host}\u0000${p.processKind}`;
const cur = newest.get(k);
if (!cur || Date.parse(p.startedAt) > Date.parse(cur.startedAt)) newest.set(k, p);
}
const current = new Set([...newest.values()].map((p) => p.instanceId));
const processes = ps.filter((p) => current.has(p.instanceId));
const retiredProcesses = ps
.filter((p) => !current.has(p.instanceId))
.sort((a, b) => Date.parse(b.lastBeatAt) - Date.parse(a.lastBeatAt));
return { processes, retiredProcesses };
}
export async function loadProcesses(
db: Kysely<DB>,
now: Date,
): Promise<{ processes: SystemProcess[]; retiredProcesses: SystemProcess[] }> {
const rows = await db
.selectFrom("process_heartbeat")
.selectAll()
.where("last_beat_at", ">", new Date(now.getTime() - HEARTBEAT_ROW_RETENTION_MS))
.orderBy("process_kind")
.orderBy("instance_id")
.execute();
return splitProcesses(
rows.map((r) => toProcess(r, now)),
now,
);
}
/** 进程段的档位(只看当前实例):有 stale 或延迟 ≥ 1s 为 warn;延迟 ≥ 5s 为 critical;没有任何 worker 心跳为 warn */
export function processLevel(ps: SystemProcess[]): HealthLevel {
const live = ps.filter((p) => !p.stale);
const levels: HealthLevel[] = [];
@@ -344,8 +384,11 @@ export async function loadSchedules(db: Kysely<DB>, now: Date): Promise<SystemHe
export async function buildHealth(d: HealthDeps): Promise<SystemHealth> {
const now = d.now?.() ?? new Date();
const [processes, db, paths, engine, schedules] = await Promise.all([
loadProcesses(d.db, now).catch(() => [] as SystemProcess[]),
const [{ processes, retiredProcesses }, db, paths, engine, schedules] = await Promise.all([
loadProcesses(d.db, now).catch(() => ({
processes: [] as SystemProcess[],
retiredProcesses: [] as SystemProcess[],
})),
loadDb(d.db),
loadDisksAndMounts(d).catch(() => ({ disks: [] as SystemDisk[], mounts: [] as SystemMount[] })),
loadEngine(d.db, d.engineSchema, now).catch((): SystemHealth["engine"] => ({
@@ -388,6 +431,7 @@ export async function buildHealth(d: HealthDeps): Promise<SystemHealth> {
uptimeSec: Math.max(0, Math.round((now.getTime() - d.self.startedAt.getTime()) / 1000)),
},
processes,
retiredProcesses,
sse: { connections: sse },
db,
disks: paths.disks,
+28 -10
View File
@@ -6,12 +6,17 @@ import serverDeno from "../../../deno.json" with { type: "json" };
// 进程心跳写入(W9-I):每个常驻进程每 15s upsert process_heartbeat 一行 + 追加一条采样。
// 写失败只记 debug(库暂时不可达不影响业务;读取方按 last_beat_at 判 stale)。
// 正常退出(stop)时把本行标 stopped_at,读取方立即把它归入已下线实例;裁剪由定时任务 system.heartbeat_cleanup 负责。
export const HEARTBEAT_WRITE_MS = 15_000;
/** 超过这个时长没有心跳即 stale(3 个写周期) */
export const HEARTBEAT_STALE_MS = 45_000;
/** 采样保留天数 */
export const SAMPLE_RETENTION_DAYS = 7;
/** 进程行保留:超过这个时长没心跳的行删除 */
export const HEARTBEAT_ROW_RETENTION_MS = 24 * 3600_000;
/** 当前实例判定窗口:最近这段时间内有心跳的 host 视为当前在跑的容器 */
export const CURRENT_HOST_WINDOW_MS = 10 * 60_000;
export const SERVER_VERSION: string = serverDeno.version;
@@ -109,7 +114,7 @@ export async function writeHeartbeat(
host = excluded.host, version = excluded.version, started_at = excluded.started_at,
last_beat_at = excluded.last_beat_at, event_loop_lag_ms = excluded.event_loop_lag_ms,
event_loop_lag_max_ms = excluded.event_loop_lag_max_ms, concurrency = excluded.concurrency,
running_runs = excluded.running_runs, extra = excluded.extra`.execute(db);
running_runs = excluded.running_runs, extra = excluded.extra, stopped_at = null`.execute(db);
await sql`
insert into process_heartbeat_sample (instance_id, process_kind, at, event_loop_lag_ms, event_loop_lag_max_ms,
running_runs, sse_connections)
@@ -117,10 +122,22 @@ export async function writeHeartbeat(
${s.sseConnections ?? null})`.execute(db);
}
/** 裁剪过期采样与 7 天没心跳的进程行 */
export async function pruneHeartbeats(db: Kysely<DB>, days = SAMPLE_RETENTION_DAYS): Promise<void> {
await sql`delete from process_heartbeat_sample where at < now() - make_interval(days => ${days})`.execute(db);
await sql`delete from process_heartbeat where last_beat_at < now() - make_interval(days => ${days})`.execute(db);
/** 正常退出:把本实例的行标 stopped_at(之后读取方不再当活进程) */
export async function markHeartbeatStopped(db: Kysely<DB>, instanceId: string, at = new Date()): Promise<void> {
await sql`update process_heartbeat set stopped_at = ${at} where instance_id = ${instanceId}`.execute(db);
}
/** 裁剪:超过 24 小时没心跳的进程行、超过 7 天的采样;返回删除行数 */
export async function pruneHeartbeats(
db: Kysely<DB>,
o: { now?: Date; rowRetentionMs?: number; sampleDays?: number } = {},
): Promise<{ rows: number; samples: number }> {
const now = o.now ?? new Date();
const rowCutoff = new Date(now.getTime() - (o.rowRetentionMs ?? HEARTBEAT_ROW_RETENTION_MS));
const sampleCutoff = new Date(now.getTime() - (o.sampleDays ?? SAMPLE_RETENTION_DAYS) * 24 * 3600_000);
const rows = await db.deleteFrom("process_heartbeat").where("last_beat_at", "<", rowCutoff).executeTakeFirst();
const samples = await db.deleteFrom("process_heartbeat_sample").where("at", "<", sampleCutoff).executeTakeFirst();
return { rows: Number(rows.numDeletedRows), samples: Number(samples.numDeletedRows) };
}
export interface HeartbeatWriter {
@@ -130,7 +147,7 @@ export interface HeartbeatWriter {
stop(): Promise<void>;
}
/** 起一个周期写入器:立即写一次,之后每 intervalMs 写一次;每约 1 小时裁剪一次 */
/** 起一个周期写入器:立即写一次,之后每 intervalMs 写一次;stop 时把本行标 stopped_at */
export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter {
const host = o.host ?? safeHostname();
const pid = o.pid ?? Deno.pid;
@@ -140,8 +157,6 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
const intervalMs = o.intervalMs ?? HEARTBEAT_WRITE_MS;
const lag = o.lag ?? createLagSampler();
const log = o.logger.child({ component: "heartbeat", kind: o.kind });
const pruneEvery = Math.max(1, Math.round(3_600_000 / intervalMs));
let n = 0;
let inflight: Promise<void> | null = null;
let stopped = false;
@@ -165,8 +180,6 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
lagMaxMs: l.windowMaxMs,
sample: { ...sample, extra: { ...memory(), lifetimeMaxLagMs: l.lifetimeMaxMs, ...(sample.extra ?? {}) } },
});
if (n % pruneEvery === 0) await pruneHeartbeats(o.db);
n += 1;
} catch (err) {
log.debug({ err }, "心跳写入失败");
}
@@ -194,6 +207,11 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
clearInterval(timer);
lag.stop();
await inflight?.catch(() => {});
try {
await markHeartbeatStopped(o.db, instanceId);
} catch (err) {
log.debug({ err }, "心跳退出标记失败");
}
},
};
}
+29 -1
View File
@@ -4,9 +4,17 @@ import type { z } from "zod";
import { dataPaths } from "../../config.ts";
import { requireScope } from "../../http/auth.ts";
import { sseConnectionCount } from "../../http/sse.ts";
import { registerScheduledJob } from "../schedules/registry.ts";
import type { ModuleApp, ServerContext, ServerModule } from "../types.ts";
import { buildHealth, type HealthDeps, loadHistory } from "./health.ts";
import { type HeartbeatWriter, instanceIdOf, SERVER_VERSION, safeHostname, startHeartbeatWriter } from "./heartbeat.ts";
import {
type HeartbeatWriter,
instanceIdOf,
pruneHeartbeats,
SERVER_VERSION,
safeHostname,
startHeartbeatWriter,
} from "./heartbeat.ts";
export { HEARTBEAT_STALE_MS, HEARTBEAT_WRITE_MS, startHeartbeatWriter } from "./heartbeat.ts";
@@ -15,6 +23,7 @@ export { HEARTBEAT_STALE_MS, HEARTBEAT_WRITE_MS, startHeartbeatWriter } from "./
* - GET /system/health:进程心跳 / 事件循环延迟 / SSE 连接 / DB / 磁盘 / 挂载根 / 引擎 / 定时任务,仅 admin;
* - GET /system/health/history?hours=:心跳采样时间序列。
* 心跳写入:worker 进程在 worker/index.ts 里起写入器;纯 api 进程(role=api)在注册路由时起(role=all 由 worker 写 kind=all)。
* 清理:定时任务 system.heartbeat_cleanup(维护组,1 小时)删 24 小时没心跳的进程行与 7 天前的采样。
*/
export interface SystemModuleOptions {
/** 引擎 schema(测试覆盖);缺省 openworkflow */
@@ -23,6 +32,8 @@ export interface SystemModuleOptions {
healthDeps?: Partial<Pick<HealthDeps, "diskUsage" | "checkRoot" | "now" | "ioTimeoutMs" | "dataDir">>;
/** 缺省 true;false 时 role=api 也不起心跳写入器(测试) */
apiHeartbeat?: boolean;
/** 定时任务 system.heartbeat_cleanup 的默认周期,默认 1 小时;<=0 不登记 */
cleanupIntervalMs?: number;
}
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
@@ -93,6 +104,23 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
route(app, Api.system.healthHistory, async ({ query }) => await loadHistory(ctx.db, query.hours));
},
registerTasks() {},
start(ctx) {
const everyMs = opts.cleanupIntervalMs ?? 3600_000;
if (everyMs <= 0) return;
registerScheduledJob(ctx, {
key: "system.heartbeat_cleanup",
defaults: {
name: "进程心跳清理",
description: "删除 24 小时没有心跳的进程行和 7 天前的心跳采样",
group: "maintain",
schedule: { type: "interval", everyMs },
},
run: async ({ now }) => {
const r = await pruneHeartbeats(ctx.db, { now });
return { summary: `删除 ${r.rows} 个进程行、${r.samples} 条采样` };
},
});
},
async stop() {
await writer?.stop();
writer = null;
+158 -4
View File
@@ -6,9 +6,17 @@ import { SystemHealth, SystemHealthHistory } from "@umt/contracts";
import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { inferTriggeredBy, triggeredByFromActor } from "../../src/modules/auth-settings/engine-business.ts";
import { diskLevel, processLevel, worst } from "../../src/modules/system/health.ts";
import { HEARTBEAT_STALE_MS, startHeartbeatWriter, writeHeartbeat } from "../../src/modules/system/heartbeat.ts";
import { scheduleRegistry } from "../../src/modules/schedules/registry.ts";
import { diskLevel, processLevel, splitProcesses, worst } from "../../src/modules/system/health.ts";
import {
HEARTBEAT_STALE_MS,
markHeartbeatStopped,
pruneHeartbeats,
startHeartbeatWriter,
writeHeartbeat,
} from "../../src/modules/system/heartbeat.ts";
import { createSystemModule } from "../../src/modules/system/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
@@ -39,6 +47,7 @@ describe("纯函数", () => {
lastBeatAt: new Date().toISOString(),
beatAgeSec: 0,
stale: o.stale ?? false,
stoppedAt: null,
eventLoopLagMs: 0,
eventLoopLagMaxMs: o.lag ?? 0,
concurrency: {},
@@ -53,6 +62,82 @@ describe("纯函数", () => {
expect(processLevel([p("api"), p("worker", { lag: 6000 })])).toBe("critical");
});
it("当前实例 / 已下线实例:host 10 分钟无心跳、已退出、被同 host 新进程替换的都算下线", () => {
const now = new Date("2026-09-28T12:00:00Z");
const ago = (sec: number) => new Date(now.getTime() - sec * 1000).toISOString();
const p = (id: string, kind: "api" | "worker" | "worker:agent", host: string, beatAgo: number, o = {}) => ({
instanceId: id,
processKind: kind,
pid: 1,
host,
version: "t",
startedAt: ago(beatAgo + 600),
lastBeatAt: ago(beatAgo),
beatAgeSec: beatAgo,
stale: beatAgo > 45,
stoppedAt: null as string | null,
eventLoopLagMs: 0,
eventLoopLagMaxMs: 0,
concurrency: {},
runningRuns: null,
sseConnections: null,
extra: {},
...o,
});
const rows = [
// 当前容器 3 个进程,心跳正常
p("api:new-a:1", "api", "new-a", 5),
p("worker:new-w:1", "worker", "new-w", 5),
p("worker:agent:new-g:1", "worker:agent", "new-g", 5),
// 容器重建前的 3 行,最后心跳 40 分钟前
p("api:old-a:1", "api", "old-a", 2400),
p("worker:old-w:1", "worker", "old-w", 2400),
p("worker:agent:old-g:1", "worker:agent", "old-g", 2400),
// 同容器进程重启:旧 pid 行 2 分钟没心跳、启动更早
p("worker:new-w:0", "worker", "new-w", 120, { startedAt: ago(7200) }),
// 正常退出
p("api:new-a:0", "api", "new-a", 20, { startedAt: ago(9000), stoppedAt: ago(20) }),
];
const r = splitProcesses(rows, now);
expect(r.processes.map((x) => x.instanceId).sort()).toEqual([
"api:new-a:1",
"worker:agent:new-g:1",
"worker:new-w:1",
]);
expect(r.retiredProcesses.map((x) => x.instanceId)).toEqual([
"api:new-a:0",
"worker:new-w:0",
"api:old-a:1",
"worker:old-w:1",
"worker:agent:old-g:1",
]);
expect(processLevel(r.processes)).toBe("ok");
// 当前容器里的 worker 停了心跳(1 分钟):仍是当前实例,stale 告警
const hung = splitProcesses([p("api:a:1", "api", "a", 5), p("worker:w:1", "worker", "w", 60)], now);
expect(hung.processes).toHaveLength(2);
expect(processLevel(hung.processes)).toBe("warn");
// 退出后立即不算活进程
const gone = splitProcesses(
[p("api:a:1", "api", "a", 5), p("worker:w:1", "worker", "w", 3, { stoppedAt: ago(1) })],
now,
);
expect(gone.processes.map((x) => x.instanceId)).toEqual(["api:a:1"]);
expect(gone.retiredProcesses.map((x) => x.instanceId)).toEqual(["worker:w:1"]);
});
it("system.heartbeat_cleanup:1 小时,维护组", async () => {
const ctx = {} as ServerContext;
await createSystemModule({ apiHeartbeat: false }).start?.(ctx);
expect(scheduleRegistry(ctx).get("system.heartbeat_cleanup")?.defaults).toMatchObject({
group: "maintain",
schedule: { type: "interval", everyMs: 3_600_000 },
});
const off = {} as ServerContext;
await createSystemModule({ apiHeartbeat: false, cleanupIntervalMs: 0 }).start?.(off);
expect(scheduleRegistry(off).get("system.heartbeat_cleanup")).toBeUndefined();
});
it("触发来源推断", () => {
expect(triggeredByFromActor("api_key:abc")).toBe("api_key");
expect(triggeredByFromActor("admin_session:u1")).toBe("user");
@@ -156,10 +241,15 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
});
await w.beat();
await w.beat();
expect(
(await h.db.selectFrom("process_heartbeat").select("stopped_at").executeTakeFirstOrThrow()).stopped_at,
).toBeNull();
await w.stop();
expect(w.instanceId).toBe("worker:test-host:4242");
const rows = await h.db.selectFrom("process_heartbeat").selectAll().execute();
expect(rows).toHaveLength(1);
// 正常退出:标 stopped_at
expect(rows[0]!.stopped_at).toBeInstanceOf(Date);
expect(rows[0]).toMatchObject({
process_kind: "worker",
pid: 4242,
@@ -190,8 +280,15 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
expect(res.status).toBe(200);
const d = SystemHealth.parse((await json(res)).data);
const byId = new Map(d.processes.map((p) => [p.instanceId, p]));
expect(byId.get("worker:test-host:4242")).toMatchObject({ stale: false, runningRuns: 2, concurrency: { io: 4 } });
expect(byId.get("api:old-host:1")).toMatchObject({ stale: true, sseConnections: 3 });
// 已退出的 worker 立即归入已下线实例,不再当活进程
expect(byId.has("worker:test-host:4242")).toBe(false);
expect(d.retiredProcesses.find((p) => p.instanceId === "worker:test-host:4242")).toMatchObject({
runningRuns: 2,
concurrency: { io: 4 },
stoppedAt: expect.any(String),
});
// 60s 前的心跳,host 仍在 10 分钟窗口内 → 当前实例,stale
expect(byId.get("api:old-host:1")).toMatchObject({ stale: true, sseConnections: 3, stoppedAt: null });
// stale 进程的 SSE 不计入
expect(d.sse.connections).toBe(0);
});
@@ -379,4 +476,61 @@ describe.skipIf(!hasPg)("W9-I 进程与健康(真实 PG)", () => {
expect(detail.business).toMatchObject({ kind: "agent", refId: agentId });
expect(detail.stepAttempts).toEqual([]);
});
it("容器重建留下的旧心跳行不计入告警;24 小时的行由清理任务删除;重启后 upsert 清掉 stopped_at", async () => {
await sql`delete from process_heartbeat`.execute(h.db);
const now = Date.now();
const beat = (id: string, kind: "api" | "worker" | "worker:agent", host: string, agoMs: number) =>
writeHeartbeat(h.db, {
instanceId: id,
kind,
pid: 1,
host,
version: "0.2.0",
startedAt: new Date(now - agoMs - 3600_000),
lagMs: 0,
lagMaxMs: 0,
sample: {},
at: new Date(now - agoMs),
});
await beat("api:c-api:1", "api", "c-api", 2_000);
await beat("worker:c-io:1", "worker", "c-io", 2_000);
await beat("worker:agent:c-ag:1", "worker:agent", "c-ag", 2_000);
await beat("api:o-api:1", "api", "o-api", 40 * 60_000);
await beat("worker:o-io:1", "worker", "o-io", 40 * 60_000);
await beat("worker:agent:o-ag:1", "worker:agent", "o-ag", 40 * 60_000);
await beat("worker:ancient:1", "worker", "ancient", 25 * 3600_000);
const d = SystemHealth.parse((await json(await admin("GET", "/system/health"))).data);
expect(d.processes.map((p) => p.instanceId).sort()).toEqual([
"api:c-api:1",
"worker:agent:c-ag:1",
"worker:c-io:1",
]);
expect(d.processes.every((p) => !p.stale)).toBe(true);
// 超过 24 小时的行不再返回
expect(d.retiredProcesses.map((p) => p.instanceId).sort()).toEqual([
"api:o-api:1",
"worker:agent:o-ag:1",
"worker:o-io:1",
]);
// 清理任务:删 24 小时没心跳的行
const r = await pruneHeartbeats(h.db);
expect(r.rows).toBe(1);
const left = await h.db.selectFrom("process_heartbeat").select("instance_id").execute();
expect(left.map((x) => x.instance_id)).not.toContain("worker:ancient:1");
expect(left).toHaveLength(6);
// 退出标记后再次心跳(同 instance_id 重启):stopped_at 清回 null
await markHeartbeatStopped(h.db, "worker:c-io:1");
const d2 = SystemHealth.parse((await json(await admin("GET", "/system/health"))).data);
expect(d2.processes.map((p) => p.instanceId)).not.toContain("worker:c-io:1");
await beat("worker:c-io:1", "worker", "c-io", 0);
const row = await h.db
.selectFrom("process_heartbeat")
.select("stopped_at")
.where("instance_id", "=", "worker:c-io:1")
.executeTakeFirstOrThrow();
expect(row.stopped_at).toBeNull();
});
});
+98 -4
View File
@@ -1,6 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import type { HealthLevel, SystemHealth, SystemProcess } from "@umt/contracts";
import { AlertTriangle, CircleCheck, Cpu, Database, HardDrive, ListTodo, RotateCw, Timer, XCircle } from "lucide-react";
import {
AlertTriangle,
ChevronDown,
CircleCheck,
Cpu,
Database,
HardDrive,
ListTodo,
RotateCw,
Timer,
XCircle,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { useSystemHealth, useSystemHealthHistory } from "~/api/system.ts";
@@ -35,6 +46,14 @@ const KIND_LABEL: Record<string, string> = {
all: "单进程(all)",
};
/** 横幅告警里的进程称呼 */
const KIND_NAME: Record<string, string> = {
api: "API",
worker: "io worker",
"worker:agent": "Agent worker",
all: "单进程",
};
const MOUNT_REASON: Record<string, string> = {
root_missing: "目录不存在",
sentinel_missing: "哨兵文件缺失",
@@ -74,8 +93,11 @@ function Card({
/** 顶部横幅:整体档位 + 需要看的几条 */
function problems(d: SystemHealth): string[] {
const out: string[] = [];
const stale = d.processes.filter((p) => p.stale);
if (stale.length) out.push(`${stale.length} 个进程超过 45 秒没有心跳`);
// 只看当前实例;已下线实例不告警
for (const p of d.processes.filter((x) => x.stale)) {
const line = `${KIND_NAME[p.processKind] ?? p.processKind} 45 秒没有心跳`;
if (!out.includes(line)) out.push(line);
}
if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行");
const lag = d.processes.filter((p) => !p.stale && p.eventLoopLagMaxMs >= 1000);
if (lag.length) out.push(`${lag.length} 个进程事件循环延迟超过 1 秒`);
@@ -136,7 +158,7 @@ function Processes({ d }: { d: SystemHealth }) {
<DataTable<SystemProcess>
rows={d.processes}
rowKey={(p) => p.instanceId}
empty={{ title: "24 小时内没有进程心跳", description: "进程启动后每 15 秒写一次心跳。" }}
empty={{ title: "没有在线的进程", description: "进程启动后每 15 秒写一次心跳。" }}
columns={[
{
key: "kind",
@@ -236,6 +258,77 @@ function Processes({ d }: { d: SystemHealth }) {
);
}
/** 已下线实例:已退出、容器已重建或被同容器新进程替换的旧心跳行,默认折叠,不参与告警 */
function RetiredProcesses({ rows }: { rows: SystemProcess[] }) {
const [open, setOpen] = useState(false);
if (!rows.length) return null;
return (
<div className="mt-3">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex cursor-pointer items-center gap-1.5 text-caption text-muted-foreground hover:text-foreground"
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !open && "-rotate-90")} />
<span>已下线实例 {rows.length} 个</span>
<span className="text-meta">不计入告警,24 小时后清理</span>
</button>
{open ? (
<div className="mt-2">
<DataTable<SystemProcess>
rows={rows}
rowKey={(p) => p.instanceId}
empty={{ title: "没有已下线实例" }}
columns={[
{
key: "id",
header: "实例",
cell: (p) => (
<div className="min-w-0">
<p className="flex items-center gap-2 font-medium text-muted-foreground">
{KIND_LABEL[p.processKind] ?? p.processKind}
{p.stoppedAt ? <StatusBadge tone="neutral">已退出</StatusBadge> : null}
</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={p.instanceId}>
{p.instanceId}
</p>
</div>
),
},
{
key: "ver",
header: "版本",
hideBelow: "md",
cell: (p) => <span className="font-mono text-meta">{p.version}</span>,
},
{
key: "start",
header: "启动",
hideBelow: "md",
cell: (p) => (
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(p.startedAt)}>
{formatRelative(p.startedAt)}
</span>
),
},
{
key: "beat",
header: "最后心跳",
cell: (p) => (
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(p.lastBeatAt)}>
{formatRelative(p.lastBeatAt)}
</span>
),
},
]}
/>
</div>
) : null}
</div>
);
}
function History() {
const [hours, setHours] = useState(6);
const h = useSystemHealthHistory(hours);
@@ -397,6 +490,7 @@ export default function StatusPage() {
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
</h2>
<Processes d={d} />
<RetiredProcesses rows={d.retiredProcesses} />
</section>
<History />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/web",
"version": "0.2.0",
"version": "0.2.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.2.0",
"version": "0.2.2",
"workspace": [
"./packages/adapters",
"./packages/agent",
+2
View File
@@ -42,6 +42,8 @@
| agent.propose_options | 方案;动作叫"选定",都不要叫"退回" | 选项、提案 |
| scope.proposeFirst | 先看方案再执行 | 只要方案、只给方案、plan_only 模式 |
| 工作流引擎的一次执行 | 运行 | run |
| 正在写心跳的进程(process_heartbeat 当前行) | 当前实例;横幅按角色称呼:API / io worker / Agent worker / 单进程 | 活进程、在线实例 |
| 已退出、容器已重建或被同容器新进程替换的心跳行 | 已下线实例;正常退出的标"已退出" | 历史实例、僵尸进程、死进程 |
## 文案规则
+7 -4
View File
@@ -13,7 +13,8 @@
- 每 15 秒 upsert `process_heartbeat` 一行,同时往 `process_heartbeat_sample` 追加一条采样。
- 行主键 `instance_id = <kind>:<host>:<pid>`,容器重启覆盖同一行。
- 写入方每小时裁剪一次:采样保留 7 天,7 天没心跳的进程行删除。
- 正常退出(SIGTERM / SIGINT)时写入器把本行标 `stopped_at`,读取方立即归入已下线实例;重启后同一行的 upsert 把它清回 null。
- 裁剪由定时任务 `system.heartbeat_cleanup`(维护组,1 小时)负责:24 小时没心跳的进程行删除,采样保留 7 天。
- 写库失败只记 debug 日志,不影响业务。
## GET /system/health 字段
@@ -22,8 +23,10 @@
| --- | --- |
| `level` | 各段最差档:`ok` / `warn` / `critical`。取不到数据的段是 `unknown`,不参与汇总 |
| `api` | 处理本次请求的 API 进程:版本、启动时间、运行秒数 |
| `processes[]` | 最近 24 小时有心跳的进程 |
| `processes[].stale` | 超过 45 秒没心跳 |
| `processes[]` | 当前实例:最近 10 分钟内有心跳的 host 上、同 host 同 kind 最新启动且没标 `stopped_at` 的进程 |
| `processes[].stale` | 超过 45 秒没心跳。只有当前实例参与告警 |
| `processes[].stoppedAt` | 正常退出时间;非 null 即已下线 |
| `retiredProcesses[]` | 已下线实例:正常退出的、所在 host 10 分钟没有心跳(容器已重建)的、被同 host 新进程替换的。不计入 `level`,页面折叠显示 |
| `processes[].eventLoopLagMs` | 最近一次采样的事件循环延迟(1 秒采一次) |
| `processes[].eventLoopLagMaxMs` | 最近一个心跳周期(15 秒)内的最大延迟 |
| `processes[].concurrency` | 引擎 worker 并发 `{io, agent}`;api 进程为 `{}` |
@@ -48,7 +51,7 @@
## 告警阈值建议
- 进程 `stale`:warn。连续 2 分钟仍 stale 就去查容器状态和日志。
- 当前实例 `stale`:warn。连续 2 分钟仍 stale 就去查容器状态和日志。容器整个没了 10 分钟后它的进程转入已下线实例,此时靠下一条"没有存活的 worker"兜底。
- 没有任何存活的 worker / all 进程:warn。任务不会被执行。
- 事件循环最大延迟 ≥ 1 秒记 warn,≥ 5 秒记 critical。延迟接近 15 秒时,引擎租约有丢失风险。
- DB 延迟 ≥ 100 ms 或连接数 ≥ 75% `max_connections` 记 warn;≥ 500 ms 或 ≥ 90% 记 critical。
+1
View File
@@ -30,6 +30,7 @@
| `trash.purge` | 回收站清理 | 维护 | 24 小时 | 入队 `purgeTrash` |
| `pi.token_refresh` | 上游登录刷新 | 维护 | 10 分钟 | 入队 `refreshPiTokens`,幂等键 `refreshPiTokens:<时间槽>`;对 30 分钟内过期的订阅登录上游刷新 token |
| `auth.cleanup` | 登录会话清理 | 维护 | 1 小时 | 直接执行 `cleanupAuthTables`:删 7 天前的登录尝试、30 天前过期或吊销的会话 |
| `system.heartbeat_cleanup` | 进程心跳清理 | 维护 | 1 小时 | 删 24 小时没有心跳的进程行、7 天前的心跳采样 |
| `maintain.backup` | 备份 | 维护 | 每天 03:30(占位) | 未配置。备份目前由宿主机上的 `scripts/backup.sh` 执行 |
| `check.pending` | 待处理巡检 | Agent | 6 小时,默认关闭 | 只读。有未关闭事项时才会运行 |
| `check.downloads` | 下载诊断 | Agent | 2 小时,默认关闭 | 默认只读。打开 `allowRecover` 后,在授权范围内重试或重新校验 |
+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 } };