- Any session operation that hits 404/410 (or finds no open tab) reconciles
tabs; if none survive it re-injects the account cookies (BR-2), opens a new
tab under the same userId/sessionKey, returns to the recorded URL (site home
otherwise), marks old tabs lost and retries the operation once. Failure
returns 503 browser_reconnect_failed with the reason, with a 10s backoff.
- Restart detection: umt plugin GET /umt/instance (fallback /health fields);
a changed instance id marks every open session needsReconnect.
- POST /browser/sessions/{id}/reconnect (idempotent); session view exposes
needsReconnect and reconnect {status, trigger, at, reason, humanNotice}.
- Controller and lease are kept; reconnecting during takeover asks the human
to double-check the reloaded page.
- Web: screenshot and desktop modes show "reconnecting", auto-reconnect once,
pause polling, offer a reconnect button with the reason on failure; desktop
mode rebuilds the Selkies connection after a reconnect.
- Mock camofox restart(), tests, smoke step and runbook section 10.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import { z } from "zod";
|
|
|
|
/** 本地实体 ID 一律 UUID */
|
|
export const Id = z.uuid();
|
|
export type Id = z.infer<typeof Id>;
|
|
|
|
/** ISO 8601 时间串 */
|
|
export const Timestamp = z.iso.datetime({ offset: true });
|
|
export type Timestamp = z.infer<typeof Timestamp>;
|
|
|
|
/** 游标分页请求:cursor 为不透明字符串,由服务端编码 */
|
|
export const CursorQuery = z.object({
|
|
cursor: z.string().min(1).optional(),
|
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
});
|
|
export type CursorQuery = z.infer<typeof CursorQuery>;
|
|
|
|
/** 游标分页结果:nextCursor 为 null 表示已到末尾 */
|
|
export const cursorPage = <T extends z.ZodType>(item: T) =>
|
|
z.object({
|
|
items: z.array(item),
|
|
nextCursor: z.string().nullable(),
|
|
});
|
|
export type CursorPage<T> = { items: T[]; nextCursor: string | null };
|
|
|
|
/** 错误码表:新增错误码只在这里加 */
|
|
export const ApiErrorCode = z.enum([
|
|
"bad_request",
|
|
"validation_failed",
|
|
"unauthorized",
|
|
"forbidden",
|
|
"not_found",
|
|
"conflict",
|
|
"precondition_failed",
|
|
"rate_limited",
|
|
"automation_disabled",
|
|
"upstream_unavailable",
|
|
"browser_reconnect_failed",
|
|
"internal",
|
|
]);
|
|
export type ApiErrorCode = z.infer<typeof ApiErrorCode>;
|
|
|
|
/** 错误码对应的 HTTP 状态 */
|
|
export const API_ERROR_STATUS = {
|
|
bad_request: 400,
|
|
validation_failed: 422,
|
|
unauthorized: 401,
|
|
forbidden: 403,
|
|
not_found: 404,
|
|
conflict: 409,
|
|
precondition_failed: 412,
|
|
rate_limited: 429,
|
|
automation_disabled: 423,
|
|
upstream_unavailable: 502,
|
|
/** 浏览器服务重启后自动重连失败(BR-3):浏览器服务暂不可用,稍后重试 */
|
|
browser_reconnect_failed: 503,
|
|
internal: 500,
|
|
} as const satisfies Record<ApiErrorCode, number>;
|
|
|
|
export const ApiError = z.object({
|
|
code: ApiErrorCode,
|
|
message: z.string(),
|
|
});
|
|
export type ApiError = z.infer<typeof ApiError>;
|
|
|
|
/** 统一响应信封 */
|
|
export const apiResponse = <T extends z.ZodType>(data: T) =>
|
|
z.object({
|
|
ok: z.boolean(),
|
|
data: data.optional(),
|
|
error: ApiError.optional(),
|
|
});
|
|
export type ApiResponse<T> =
|
|
| { ok: true; data: T; error?: undefined }
|
|
| { ok: false; data?: undefined; error: ApiError };
|
|
|
|
/** 没有 data 的失败响应 schema(OpenAPI 错误分支用) */
|
|
export const ApiErrorResponse = z.object({
|
|
ok: z.literal(false),
|
|
error: ApiError,
|
|
});
|
|
|
|
/** API Key 权限范围 */
|
|
export const ApiScope = z.enum(["read", "organize.read", "organize.execute", "admin"]);
|
|
export type ApiScope = z.infer<typeof ApiScope>;
|
|
|
|
/** 健康检查 */
|
|
export const HealthStatus = z.object({
|
|
status: z.enum(["ok", "degraded"]),
|
|
version: z.string(),
|
|
role: z.enum(["api", "worker", "all"]),
|
|
uptimeSec: z.number(),
|
|
db: z.enum(["ok", "down", "unknown"]),
|
|
time: Timestamp,
|
|
});
|
|
export type HealthStatus = z.infer<typeof HealthStatus>;
|
|
|
|
/** 日期(无时区),如 air_date:YYYY-MM-DD */
|
|
export const DateOnly = z.iso.date();
|
|
export type DateOnly = z.infer<typeof DateOnly>;
|
|
|
|
/** JSONB 对象列(证据、规则参数、原文快照等)的 API 表示 */
|
|
export const JsonObject = z.record(z.string(), z.unknown());
|
|
export type JsonObject = z.infer<typeof JsonObject>;
|
|
|
|
/** 字节数、计数等非负整数(DB 为 bigint,客户端按 number 解析,上限 2^53) */
|
|
export const NonNegInt = z.number().int().nonnegative();
|
|
|
|
/** 操作者类型:与 DB 中 actor_type 列一致 */
|
|
export const ActorType = z.enum(["user", "agent", "system", "api_key"]);
|
|
export type ActorType = z.infer<typeof ActorType>;
|
|
|
|
/** 实体行的公共字段 */
|
|
export const EntityBase = z.object({
|
|
id: Id,
|
|
createdAt: Timestamp,
|
|
updatedAt: Timestamp,
|
|
});
|
|
|
|
/** 所有实体 Create 输入都要剔除的服务端字段 */
|
|
export const SERVER_MANAGED = { id: true, createdAt: true, updatedAt: true } as const;
|
|
|
|
/** 路径参数 `{id}` */
|
|
export const IdParam = z.object({ id: Id });
|
|
export type IdParam = z.infer<typeof IdParam>;
|