Files
UHD_Media_Tool/packages/contracts/src/browser.ts
T
rootandClaude Opus 5.5 b4c9b1ead2 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]>
2026-09-28 23:31:21 +08:00

471 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { ManualAcquisitionResult } from "./execution.ts";
import { InstanceHealth } from "./instances.ts";
// 浏览器能力合同(REQ §4.4、§4.7.2–4.7.4、§12 浏览器实体)。
// 这是本项目自有接口,不等于上游同名接口:上游对接由 @umt/adapters 的 BrowserProvider 实现
// (首个实现为用户已有的 camofox-browser;Playwright 只用于前端截图测试,不是产品浏览器能力)。
// v0.1 只落合同与骨架,会话/接管/下载捕获在 M2 原型验证后启用(docs/design/browser-provider.md)。
/** 浏览器服务实现种类;none = 未配置(默认),所有浏览器动作返回不可用 */
export const BrowserProviderKind = z.enum(["camofox", "none"]);
export type BrowserProviderKind = z.infer<typeof BrowserProviderKind>;
/**
* 单项能力声明。supported = 实现/上游声明具备;verified = 已对用户真实实例做过原型验证(REQ §4.7.4)。
* 界面只把 supported && verified 的能力当作可用,未验证的明确标注"未验证",不得用其他浏览器替代后宣称可用。
*/
export const BrowserCapabilityFlag = z.object({
supported: z.boolean(),
verified: z.boolean(),
/** static = 适配器按文档静态声明;probed = 从服务端 OpenAPI/健康端点探测到 */
source: z.enum(["static", "probed"]),
note: z.string().optional(),
});
export type BrowserCapabilityFlag = z.infer<typeof BrowserCapabilityFlag>;
export const BROWSER_CAPABILITY_KEYS = [
"sessions",
"tabs",
"navigate",
"snapshot",
"screenshot",
"interact",
"extract",
"downloadCapture",
"takeover",
"persistentProfile",
] as const;
export type BrowserCapabilityKey = (typeof BROWSER_CAPABILITY_KEYS)[number];
export const BrowserCapabilities = z.object({
/** 会话(站点/账号隔离的浏览器上下文) */
sessions: BrowserCapabilityFlag,
/** 标签页列举/新建/关闭 */
tabs: BrowserCapabilityFlag,
navigate: BrowserCapabilityFlag,
/** 结构化快照(可访问性树 + 元素引用),非原始 HTML */
snapshot: BrowserCapabilityFlag,
screenshot: BrowserCapabilityFlag,
/** click / type / select */
interact: BrowserCapabilityFlag,
/** 结构化提取(按 schema 取字段) */
extract: BrowserCapabilityFlag,
/** 捕获浏览器下载产物(.torrent 等)交后端暂存核验 */
downloadCapture: BrowserCapabilityFlag,
/** 人工接管:同一会话的画面与输入通道 */
takeover: BrowserCapabilityFlag,
/** 登录状态随 profile 持久化,重启后恢复 */
persistentProfile: BrowserCapabilityFlag,
/** 上游版本(来自 OpenAPI info.version 等);未取得为 null */
upstreamVersion: z.string().nullable(),
probedAt: Timestamp.nullable(),
});
export type BrowserCapabilities = z.infer<typeof BrowserCapabilities>;
/** 会话控制权:自动化 / 人工接管 / 等待处理(验证码、2FA 等)/ 已关闭(REQ §4.7.3) */
export const BrowserController = z.enum(["automation", "human", "waiting", "closed"]);
export type BrowserController = z.infer<typeof BrowserController>;
export const BrowserProviderRow = EntityBase.extend({
name: z.string().min(1),
kind: BrowserProviderKind,
baseUrl: z.string().nullable(),
/** 访问凭据(如 camofox CAMOFOX_ACCESS_KEY)只以 secret 引用保存 */
secretRef: Id.nullable(),
enabled: z.boolean(),
/** 最近一次声明/探测到的 BrowserCapabilities 快照 */
capabilities: JsonObject,
/** 最近一次对真实实例完成原型验证的时间;null = 从未验证 */
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
config: JsonObject,
});
export type BrowserProviderRow = z.infer<typeof BrowserProviderRow>;
/** 浏览器会话:按站点/账号绑定;同一 profileKey 同时至多一个未关闭会话 */
export const BrowserSession = EntityBase.extend({
providerId: Id,
siteId: Id,
accountId: Id.nullable(),
/** 站点/账号维度的 profile 键,决定上游登录状态隔离与串行锁 */
profileKey: z.string().min(1),
controller: BrowserController,
currentUrl: z.string().nullable(),
lastActivityAt: Timestamp,
/** 上游会话标识(camofox: userId/sessionKey);不含凭据 */
upstreamRef: JsonObject,
/** 接管/断线/重启时的可恢复状态;恢复后必须重新读取页面,不重放旧元素引用 */
recoveryState: JsonObject,
closedAt: Timestamp.nullable(),
closeReason: z.string().nullable(),
});
export type BrowserSession = z.infer<typeof BrowserSession>;
export const BrowserTabStatus = z.enum(["open", "closed", "lost"]);
export type BrowserTabStatus = z.infer<typeof BrowserTabStatus>;
export const BrowserTab = EntityBase.extend({
sessionId: Id,
/** 上游标签页 ID(camofox tabId) */
upstreamTabId: z.string().min(1),
url: z.string().nullable(),
title: z.string().nullable(),
status: BrowserTabStatus,
lastSnapshotAt: Timestamp.nullable(),
closedAt: Timestamp.nullable(),
});
export type BrowserTab = z.infer<typeof BrowserTab>;
export const BrowserLeaseHolderKind = z.enum(["automation", "human"]);
export type BrowserLeaseHolderKind = z.infer<typeof BrowserLeaseHolderKind>;
export const BrowserLeaseStatus = z.enum(["active", "released", "expired", "revoked"]);
export type BrowserLeaseStatus = z.infer<typeof BrowserLeaseStatus>;
/** 会话控制权租约:每个会话同时至多一个 active(DB 部分唯一索引保证) */
export const BrowserControlLease = EntityBase.extend({
sessionId: Id,
holderKind: BrowserLeaseHolderKind,
/** 持有者标识:agent_run id / admin 用户名等 */
holder: z.string().min(1),
reason: z.string().min(1),
status: BrowserLeaseStatus,
acquiredAt: Timestamp,
expiresAt: Timestamp,
releasedAt: Timestamp.nullable(),
releaseReason: z.string().nullable(),
});
export type BrowserControlLease = z.infer<typeof BrowserControlLease>;
/** 导航预算(REQ §4.4):域名范围、超时、重试与资源上限;空白名单 = 拒绝全部导航 */
export const NavigationBudget = z.object({
/** 允许的主机名;"example.org" 同时放行其子域 */
domainAllowlist: z.array(z.string().min(1)),
timeoutMs: z.number().int().min(1),
maxRetries: z.number().int().min(0).max(10),
/** 单次响应(快照文本/截图)字节上限 */
maxBytes: z.number().int().min(1),
});
export type NavigationBudget = z.infer<typeof NavigationBudget>;
export const SnapshotRedactionKind = z.enum(["password_field", "cookie", "credential_url", "secret_value"]);
export type SnapshotRedactionKind = z.infer<typeof SnapshotRedactionKind>;
/**
* 可交给模型的页面快照:只含经 redactSnapshot 脱敏后的结构化文本与元素引用,
* 密码框值、Cookie 值、含 passkey 等凭据的 URL 已遮蔽;不含原始 HTML/脚本。
*/
export const BrowserSnapshot = z.object({
sessionId: Id,
tabId: z.string().min(1),
/** 已脱敏的当前地址 */
url: z.string(),
format: z.enum(["a11y_text"]),
/** 已脱敏的可访问性快照文本,元素以 eN 引用 */
text: z.string(),
/** 表单/表格摘要(已脱敏) */
structure: JsonObject.nullable(),
refsCount: NonNegInt,
truncated: z.boolean(),
nextOffset: NonNegInt.nullable(),
redactions: z.array(z.object({ kind: SnapshotRedactionKind, count: NonNegInt })),
capturedAt: Timestamp,
});
export type BrowserSnapshot = z.infer<typeof BrowserSnapshot>;
/** 二进制内容的引用:blob 暂存路径或 secret(下载产物可能含 passkey,不内联进合同/日志) */
export const BlobRef = z.object({
kind: z.enum(["blob", "secret"]),
ref: z.string().min(1),
});
export type BlobRef = z.infer<typeof BlobRef>;
/** 浏览器捕获的下载产物:交后端受控暂存与核验后再走原有提交链路(REQ §4.7.1) */
export const DownloadArtifact = z.object({
id: Id,
sessionId: Id,
filename: z.string().min(1),
mimeType: z.string().nullable(),
sizeBytes: NonNegInt,
bytes: BlobRef,
/** 十六进制 sha256 */
sha256: z.string().min(1),
/** 已脱敏的下载来源地址(上游提供时) */
sourceUrl: z.string().nullable().optional(),
capturedAt: Timestamp,
});
export type DownloadArtifact = z.infer<typeof DownloadArtifact>;
export const BrowserHealth = z.object({
status: z.enum(["ok", "degraded", "down", "disabled"]),
kind: BrowserProviderKind,
detail: JsonObject,
checkedAt: Timestamp,
});
export type BrowserHealth = z.infer<typeof BrowserHealth>;
export const BrowserErrorKind = z.enum([
"not_implemented",
"unavailable",
"auth",
"timeout",
"network",
"not_found",
"rate_limited",
"bad_request",
"upstream",
"protocol",
"domain_blocked",
"budget_exceeded",
"control_conflict",
"lock_timeout",
/** 元素引用来自导航/交接之前的快照,已失效:先重新读取页面 */
"stale_ref",
]);
export type BrowserErrorKind = z.infer<typeof BrowserErrorKind>;
// ── C1-E:浏览器 API 与 Agent 能力的请求/响应(docs/runbook/browser.md) ──
/** 查询串布尔 */
const QueryBool = z.enum(["true", "false"]).optional();
/** 浏览器服务(GET /browser/providers);凭证只给引用是否存在,不给明文 */
export const BrowserProviderView = z.object({
id: Id,
name: z.string(),
kind: BrowserProviderKind,
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 或适配器内置的已核验版本表) */
capabilities: BrowserCapabilities.nullable(),
verifiedAt: Timestamp.nullable(),
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
/** probe=true 时的即时健康结果 */
health: BrowserHealth.nullable(),
});
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,
});
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
export const BrowserSessionView = BrowserSession.extend({
tabs: z.array(BrowserTab),
currentTabId: z.string().nullable(),
lease: BrowserControlLease.nullable(),
/** 控制权交还后到重新读取页面之前为 true:旧元素引用一律失效 */
needsSnapshot: z.boolean(),
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
waitingReason: z.string().nullable(),
});
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
export const BrowserSessionOpen = z.object({
siteId: Id,
accountId: Id.nullable().optional(),
/** 缺省取第一个启用的浏览器服务 */
providerId: Id.optional(),
/** 缺省打开站点首页;必须在站点域名内 */
url: z.url().optional(),
});
export type BrowserSessionOpen = z.infer<typeof BrowserSessionOpen>;
export const BrowserSessionsQuery = z.object({
siteId: Id.optional(),
includeClosed: QueryBool,
});
export type BrowserSessionsQuery = z.infer<typeof BrowserSessionsQuery>;
export const BrowserSessionParams = z.object({ id: Id });
export const BrowserTabParams = z.object({ id: Id, tabId: z.string().min(1).max(200) });
export const BrowserTabOpen = z.object({ url: z.url().optional() });
/** 导航:地址栏(url)或导航按钮(action),二选一(url 优先) */
export const BrowserNavigate = z.object({
tabId: z.string().min(1).optional(),
url: z.url().max(4000).optional(),
action: z.enum(["back", "forward", "reload"]).optional(),
});
export type BrowserNavigate = z.infer<typeof BrowserNavigate>;
export const BrowserNavigation = z.object({
/** 已脱敏的最终地址 */
url: z.string(),
httpStatus: z.number().int().nullable(),
ok: z.boolean(),
attempts: z.number().int().min(0),
/** 落在登录页(登录失效) */
loginRequired: z.boolean(),
session: BrowserSessionView,
});
export type BrowserNavigation = z.infer<typeof BrowserNavigation>;
export const BrowserSnapshotQuery = z.object({
tabId: z.string().min(1).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
export const BrowserScreenshotQuery = z.object({ tabId: z.string().min(1).optional() });
/** 截图:base64 直接给原站视图;不落盘、不进模型(modelSafe 标注) */
export const BrowserScreenshotView = z.object({
mimeType: z.string(),
dataBase64: z.string(),
/** 图片像素尺寸(PNG/JPEG 头解析;解析不出为 null) */
width: z.number().int().nullable(),
height: z.number().int().nullable(),
url: z.string().nullable(),
modelSafe: z.boolean(),
capturedAt: Timestamp,
controller: BrowserController,
});
export type BrowserScreenshotView = z.infer<typeof BrowserScreenshotView>;
/** 元素定位:快照引用 / CSS 选择器 / 截图像素坐标(原站视图点击) */
export const BrowserTarget = z.union([
/** 快照元素引用,形如 e12 */
z.object({ ref: z.string().min(1).max(40) }),
z.object({ selector: z.string().min(1).max(500) }),
z.object({ x: z.number().min(0), y: z.number().min(0) }),
]);
export type BrowserTarget = z.infer<typeof BrowserTarget>;
export const BrowserClick = z.object({ tabId: z.string().min(1).optional(), target: BrowserTarget });
export type BrowserClick = z.infer<typeof BrowserClick>;
/** 输入:按键(key)> 站点账号凭证字段(credential,服务端解析后直接下发)> 明文(text);target 缺省 = 当前焦点 */
export const BrowserType = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
text: z.string().max(4000).optional(),
credential: z.enum(["username", "password", "totp"]).optional(),
key: z.string().min(1).max(40).optional(),
submit: z.boolean().optional(),
clear: z.boolean().optional(),
});
export type BrowserType = z.infer<typeof BrowserType>;
export const BrowserScroll = z.object({
tabId: z.string().min(1).optional(),
direction: z.enum(["up", "down", "left", "right"]),
amount: z.number().int().min(1).max(10_000).default(600),
});
export type BrowserScroll = z.infer<typeof BrowserScroll>;
export const BrowserSelect = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget,
value: z.string().max(500),
});
export type BrowserSelect = z.infer<typeof BrowserSelect>;
export const BrowserActionResult = z.object({ ok: z.literal(true), session: BrowserSessionView });
export type BrowserActionResult = z.infer<typeof BrowserActionResult>;
export const BrowserExtract = z.object({
tabId: z.string().min(1).optional(),
/** JSON Schema;属性用 x-ref 指向快照元素(camofox /extract) */
schema: JsonObject,
});
export const BrowserExtractResult = z.object({ data: JsonObject });
/** 捕获下载:可先点击 target 触发;submit 给出时经 POST /acquisition-plans 同一流程提交 */
export const BrowserDownload = z.object({
tabId: z.string().min(1).optional(),
target: BrowserTarget.optional(),
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
submit: z
.object({ slotId: Id.optional(), entityId: Id.optional(), note: z.string().max(2000).optional() })
.optional(),
});
export type BrowserDownload = z.infer<typeof BrowserDownload>;
/** 暂存核验结果:种子解析出的 infohash、与本地资源的匹配 */
export const BrowserDownloadVerification = z.object({
valid: z.boolean(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
name: z.string().nullable(),
totalSize: NonNegInt.nullable(),
private: z.boolean().nullable(),
error: z.string().nullable(),
});
export type BrowserDownloadVerification = z.infer<typeof BrowserDownloadVerification>;
export const BrowserDownloadResult = z.object({
artifact: DownloadArtifact,
verification: BrowserDownloadVerification,
/** 按 infohash / 站点资源 ID 匹配到的本地资源;null = 不在本地索引 */
sourceReleaseId: Id.nullable(),
/** 同一会话已捕获过同一文件(sha256 相同):不重复提交 */
duplicate: z.boolean(),
/** submit 给出且匹配到资源时的提交结果(与 POST /acquisition-plans 相同) */
submission: ManualAcquisitionResult.nullable(),
note: z.string().nullable(),
});
export type BrowserDownloadResult = z.infer<typeof BrowserDownloadResult>;
export const BrowserControlAcquire = z.object({
reason: z.string().min(1).max(200).default("人工接管"),
/** 租约时长;到期未交还 → 会话转"等待处理",不自动恢复自动化 */
ttlMs: z
.number()
.int()
.min(60_000)
.max(4 * 3_600_000)
.default(30 * 60_000),
});
export type BrowserControlAcquire = z.infer<typeof BrowserControlAcquire>;
export const BrowserControlRelease = z.object({
leaseId: Id.optional(),
reason: z.string().min(1).max(200).default("人工交还"),
/** 交还给自动化(缺省)或留在等待处理 */
to: z.enum(["automation", "waiting"]).default("automation"),
});
export type BrowserControlRelease = z.infer<typeof BrowserControlRelease>;
export const BrowserControlResult = z.object({
lease: BrowserControlLease.nullable(),
session: BrowserSessionView,
});
export type BrowserControlResult = z.infer<typeof BrowserControlResult>;