- 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]>
471 lines
18 KiB
TypeScript
471 lines
18 KiB
TypeScript
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>;
|