import { z } from "zod"; import { ActorType, ApiScope, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; // 运维域:校验运行、媒体服务器库存映射、API Key、审计、outbox、凭证(REQ §8.1、§7.4、§13.2) export const ReconcileKind = z.enum(["identity", "metadata", "asset", "acquisition", "server"]); export type ReconcileKind = z.infer; /** read_only:恢复后默认只读对账模式(REQ §8.5) */ export const ReconcileMode = z.enum(["normal", "read_only"]); export type ReconcileMode = z.infer; export const ReconcileStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]); export type ReconcileStatus = z.infer; export const ReconcileRun = EntityBase.extend({ kind: ReconcileKind, mode: ReconcileMode, status: ReconcileStatus, scope: JsonObject, /** 中断续扫位置 */ resumeCursor: JsonObject, stats: JsonObject, taskId: Id.nullable(), error: JsonObject.nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), }); export type ReconcileRun = z.infer; export const ReconcileRunCreate = ReconcileRun.pick({ kind: true, mode: true, scope: true }).partial({ mode: true, scope: true, }); export type ReconcileRunCreate = z.infer; export const ReconcileRunUpdate = ReconcileRun.pick({ status: true, resumeCursor: true, stats: true, taskId: true, error: true, startedAt: true, finishedAt: true, }).partial(); export type ReconcileRunUpdate = z.infer; export const ServerItemType = z.enum(["movie", "series", "season", "episode", "other"]); export type ServerItemType = z.infer; /** 未知 ≠ 缺失:stale/unmatched 不能推出"没有资源" */ export const ServerMatchStatus = z.enum(["matched", "ambiguous", "unmatched", "stale"]); export type ServerMatchStatus = z.infer; /** Emby 兼容库存条目与本地对象的映射(辅助证据,不构成文件纳管授权) */ export const ServerMapping = EntityBase.extend({ mediaServerInstanceId: Id, serverItemId: z.string().min(1), itemType: ServerItemType, mediaEntityId: Id.nullable(), contentUnitId: Id.nullable(), mediaAssetId: Id.nullable(), serverPath: z.string().nullable(), pathMappingId: Id.nullable(), matchStatus: ServerMatchStatus, versionInfo: JsonObject, evidence: JsonObject, observedAt: Timestamp, }); export type ServerMapping = z.infer; export const ServerMappingCreate = ServerMapping.omit(SERVER_MANAGED).partial({ mediaEntityId: true, contentUnitId: true, mediaAssetId: true, serverPath: true, pathMappingId: true, matchStatus: true, versionInfo: true, evidence: true, observedAt: true, }); export type ServerMappingCreate = z.infer; export const ServerMappingUpdate = ServerMappingCreate.omit({ mediaServerInstanceId: true, serverItemId: true, }).partial(); export type ServerMappingUpdate = z.infer; /** API Key(不含 key_hash) */ export const ApiKey = EntityBase.extend({ name: z.string().min(1), scopes: z.array(ApiScope), revokedAt: Timestamp.nullable(), lastUsedAt: Timestamp.nullable(), }); export type ApiKey = z.infer; export const ApiKeyCreate = z.object({ name: z.string().min(1).max(80), scopes: z.array(ApiScope).min(1), }); export type ApiKeyCreate = z.infer; /** 创建响应:明文 key 只在此返回一次 */ export const ApiKeyCreated = z.object({ apiKey: ApiKey, plaintextKey: z.string().min(16) }); export type ApiKeyCreated = z.infer; export const ApiKeyUpdate = z.object({ name: z.string().min(1).max(80).optional(), scopes: z.array(ApiScope).min(1).optional(), revoked: z.literal(true).optional(), }); export type ApiKeyUpdate = z.infer; /** 审计事件(只追加) */ export const AuditEvent = z.object({ id: Id, at: Timestamp, actorType: z.string(), actorId: z.string().nullable(), action: z.string().min(1), targetType: z.string().nullable(), targetId: z.string().nullable(), payload: JsonObject, traceId: z.string().nullable(), }); export type AuditEvent = z.infer; export const AuditEventCreate = AuditEvent.omit({ id: true, at: true }) .partial({ actorId: true, targetType: true, targetId: true, payload: true, traceId: true }) .extend({ actorType: ActorType }); export type AuditEventCreate = z.infer; export const OutboxStatus = z.enum(["pending", "dispatched", "failed", "dead"]); export type OutboxStatus = z.infer; /** outbox:业务事务内写入,分发器轮询 pending 并以 idempotencyKey 入队(ADR-002 §2.1) */ export const OutboxEvent = z.object({ id: Id, at: Timestamp, type: z.string().min(1), payload: JsonObject, publishedAt: Timestamp.nullable(), idempotencyKey: z.string().nullable(), status: OutboxStatus, attempts: NonNegInt, nextAttemptAt: Timestamp, lastError: JsonObject.nullable(), }); export type OutboxEvent = z.infer; export const OutboxEventCreate = OutboxEvent.pick({ type: true, payload: true, idempotencyKey: true, nextAttemptAt: true, }).partial({ payload: true, idempotencyKey: true, nextAttemptAt: true, }); export type OutboxEventCreate = z.infer; export const OutboxEventUpdate = OutboxEvent.pick({ status: true, attempts: true, nextAttemptAt: true, lastError: true, publishedAt: true, }).partial(); export type OutboxEventUpdate = z.infer; export const SecretPurpose = z.enum([ "site_cookie", "site_passkey", "site_api_key", "site_password", "feed_url", "downloader_password", "media_server_api_key", "metadata_api_key", "llm_api_key", "storage_credential", "other", ]); export type SecretPurpose = z.infer; /** 加密凭证的元数据视图:密文/nonce 永不出 API */ export const Secret = EntityBase.extend({ name: z.string().min(1), purpose: SecretPurpose, algorithm: z.string(), kdf: z.string(), kdfParams: JsonObject, keyVersion: z.number().int().min(1), rotatedAt: Timestamp.nullable(), }); export type Secret = z.infer; /** plaintext 只写;服务端用 SECRET_MASTER_KEY 派生密钥加密后存 ciphertext/nonce */ export const SecretCreate = z.object({ name: z.string().min(1).max(120), purpose: SecretPurpose, plaintext: z.string().min(1).max(16384), }); export type SecretCreate = z.infer; export const SecretUpdate = z.object({ plaintext: z.string().min(1).max(16384) }); export type SecretUpdate = z.infer; /** GET /audit-events 过滤:按 actor / action / target;游标分页(新 → 旧) */ export const AuditEventQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), actorType: z.string().max(40).optional(), actorId: z.string().max(200).optional(), /** 精确匹配;以 `.*` 结尾时按前缀匹配(如 `admin.*`) */ action: z.string().max(80).optional(), targetType: z.string().max(80).optional(), targetId: z.string().max(200).optional(), }); export type AuditEventQuery = z.infer; // ── 引擎面板(只读直查 openworkflow schema,仅 admin;docs/design/openworkflow-dashboard-reference.md) ── /** 逗号分隔的枚举多选(查询串):`a,b` → ["a","b"],未知值报错 */ function csvEnum(e: z.ZodType, name: string) { return z .string() .max(200) .transform((s, ctx) => { const out: T[] = []; for (const p of s .split(",") .map((x) => x.trim()) .filter(Boolean)) { const r = e.safeParse(p); if (!r.success) { ctx.addIssue({ code: "custom", message: `未知 ${name}: ${p}` }); return z.NEVER; } if (!out.includes(r.data)) out.push(r.data); } return out; }); } /** 引擎 run 状态(含 legacy sleeping/succeeded) */ export const EngineRunStatus = z.enum([ "pending", "running", "sleeping", "succeeded", "completed", "failed", "canceled", ]); export type EngineRunStatus = z.infer; export const EngineStepKind = z.enum(["function", "sleep", "workflow", "signal-send", "signal-wait"]); export type EngineStepKind = z.infer; /** 引擎 ID 是 openworkflow 自己生成的文本 ID(非本地 UUID) */ const EngineId = z.string().min(1).max(200); /** run 关联的业务对象(W9-I):由 run 输入 / 业务任务行 / agent_run / tasks_run_meta / outbox 事件推断,推断不出为 null */ export const EngineBusinessKind = z.enum([ "subscription", "download", "organization", "agent", "schedule", "feed", "site", "decision", "media", ]); export type EngineBusinessKind = z.infer; /** 触发来源:user=控制台会话 / 手动按钮 / 重跑;api_key=API Key 调用;schedule=定时任务;agent=Agent 或子会话;event=outbox 事件链 */ export const EngineTriggeredBy = z.enum(["user", "api_key", "schedule", "agent", "event"]); export type EngineTriggeredBy = z.infer; export const EngineRunBusiness = z.object({ kind: EngineBusinessKind, /** 业务对象 id(schedule 为定时任务 key,如 trash.purge) */ refId: z.string(), /** 展示名(订阅/作品标题、下载任务名、Feed/站点名、会话目标等);对象已删除时为 null */ title: z.string().nullable(), triggeredBy: EngineTriggeredBy.nullable(), /** * 业务结果(与引擎状态不同时才有意义):Agent 会话为 agent_run.status。引擎 run 正常结束而会话超出预算 / 失败时, * 任务页按它显示「超出预算」/「失败」,不显示「完成」 */ outcome: z.string().nullable().optional(), }); export type EngineRunBusiness = z.infer; export const EngineRun = z.object({ id: EngineId, workflowName: z.string(), version: z.string().nullable(), status: z.string(), attempts: z.number().int(), workerId: z.string().nullable(), parentStepAttemptId: z.string().nullable(), availableAt: Timestamp.nullable(), deadlineAt: Timestamp.nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), createdAt: Timestamp, updatedAt: Timestamp, /** 关联业务对象;推断不出为 null(旧客户端可忽略) */ business: EngineRunBusiness.nullable().optional(), }); export type EngineRun = z.infer; export const EngineRunQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), /** 单值或逗号分隔多值,如 status=running,pending */ status: csvEnum(EngineRunStatus, "status").optional(), workflowName: z.string().max(200).optional(), /** created_at 下界(含) */ from: Timestamp.optional(), /** created_at 上界(不含) */ to: Timestamp.optional(), /** 按关联业务对象类型筛选 */ businessKind: EngineBusinessKind.optional(), /** 按关联业务对象 id 筛选(可与 businessKind 同用) */ businessRefId: z.string().min(1).max(200).optional(), /** 按触发来源筛选(单值或逗号分隔多值);来源在服务端逐页推断,一页可能少于 limit,以 nextCursor 为准 */ triggeredBy: csvEnum(EngineTriggeredBy, "triggeredBy").optional(), }); export type EngineRunQuery = z.infer; /** step attempt:不含 config;context 只露 signal-wait 的信号名与超时(排查卡等待用) */ export const EngineStepAttempt = z.object({ id: EngineId, stepName: z.string(), kind: z.string(), status: z.string(), output: z.unknown(), error: z.unknown(), waitSignal: z.string().nullable(), timeoutAt: z.string().nullable(), childWorkflowRunId: z.string().nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), createdAt: Timestamp, }); export type EngineStepAttempt = z.infer; export const EngineSignal = z.object({ id: EngineId, signal: z.string(), data: z.unknown(), stepAttemptId: z.string(), createdAt: Timestamp, }); export type EngineSignal = z.infer; /** run 详情:input/output/error 经敏感字段脱敏;不返回 config/context */ export const EngineRunDetail = EngineRun.extend({ input: z.unknown(), output: z.unknown(), error: z.unknown(), stepAttempts: z.array(EngineStepAttempt), signals: z.array(EngineSignal), }); export type EngineRunDetail = z.infer; export const EngineStats = z.object({ /** 全表按状态计数(不受列表筛选影响) */ byStatus: z.record(z.string(), z.number().int().nonnegative()), /** 最老的待处理 run(pending,按 available_at/created_at) */ oldestPending: z .object({ id: EngineId, workflowName: z.string(), since: Timestamp, ageSec: z.number().nonnegative() }) .nullable(), /** 当前持有租约的 worker 快照(非权威注册表:引擎没有 worker 表) */ activeWorkers: z.array(z.object({ workerId: z.string(), running: z.number().int(), since: Timestamp.nullable() })), }); export type EngineStats = z.infer; export const EngineCancelRequest = z.object({ reason: z.string().max(500).optional() }); export const EngineCancelResult = z.object({ canceled: z.boolean() }); // ── 进程与健康(W9-I;GET /system/health、/system/health/history,仅 admin) ── export const ProcessKind = z.enum(["api", "worker", "worker:agent", "all"]); export type ProcessKind = z.infer; /** 三档状态:ok / warn(需要关注)/ critical(需要处理);unknown = 取不到数据 */ export const HealthLevel = z.enum(["ok", "warn", "critical", "unknown"]); export type HealthLevel = z.infer; export const WorkerLane = z.enum(["file", "network", "maintenance"]); export type WorkerLane = z.infer; export const SystemWorkerLane = z.object({ lane: WorkerLane, /** 当前生效的并发上限 */ concurrency: NonNegInt, /** env = 环境变量覆盖(设置页改了不生效);settings = 设置页的值 */ source: z.enum(["env", "settings"]), /** 本进程该道正在执行的数 */ running: NonNegInt, /** 全库该道已到点等待领取的数;统计失败为 null */ queued: NonNegInt.nullable(), /** 维护道:正在给文件道 / 网络道让路 */ yielding: z.boolean().optional(), }); export type SystemWorkerLane = z.infer; export const SystemProcess = z.object({ /** ::;同一容器重启覆盖同一行 */ instanceId: z.string(), processKind: ProcessKind, pid: z.number().int(), host: z.string(), version: z.string(), startedAt: Timestamp, lastBeatAt: Timestamp, /** 距上次心跳秒数 */ beatAgeSec: z.number().nonnegative(), /** 超过 45s 没心跳(只对当前实例告警) */ stale: z.boolean(), /** 进程正常退出时写入;非 null 即已下线 */ stoppedAt: Timestamp.nullable(), /** 最近一次采样的事件循环延迟 */ eventLoopLagMs: NonNegInt, /** 最近一个心跳周期(15s)内的最大延迟 */ eventLoopLagMaxMs: NonNegInt, /** 引擎 worker 并发;api 进程为 {}。io = 三条道合计(WK-1 前是 io 组总并发) */ concurrency: z.object({ io: NonNegInt.optional(), agent: NonNegInt.optional(), file: NonNegInt.optional(), network: NonNegInt.optional(), maintenance: NonNegInt.optional(), }), /** io worker 各道(WK-1):并发、本进程运行中、全库排队;非 io 进程或旧心跳为 [] */ lanes: z.array(SystemWorkerLane).default([]), /** 本组任务正在执行的 run 数(同组多实例时是组合计);api 进程为 null */ runningRuns: NonNegInt.nullable(), /** 本进程 SSE 长连接数;不托管 HTTP 的进程为 null */ sseConnections: NonNegInt.nullable(), /** 其它采样:rssBytes/heapUsedBytes/lagWarnings/lifetimeMaxLagMs/group 等 */ extra: JsonObject, }); export type SystemProcess = z.infer; export const SystemDisk = z.object({ scope: z.enum(["data_dir", "library_root", "download_dir"]), /** data_dir 为 null */ id: z.string().nullable(), name: z.string(), path: z.string(), level: HealthLevel, totalBytes: NonNegInt.nullable(), usedBytes: NonNegInt.nullable(), availableBytes: NonNegInt.nullable(), usedRatio: z.number().min(0).max(1).nullable(), /** 取不到用量时的错误码/说明 */ error: z.string().nullable(), }); export type SystemDisk = z.infer; export const SystemMount = z.object({ /** storage_instance = Alist / OpenList 实例(连通 + 根路径可访问;令牌失效为 offline/auth) */ kind: z.enum(["library_root", "path_mapping", "storage_instance"]), id: z.string(), name: z.string(), path: z.string(), /** online=目录与哨兵都在;offline=目录缺失/无权限/哨兵缺失;timeout=检查超时(挂载可能卡死) */ status: z.enum(["online", "offline", "timeout"]), /** root_missing / sentinel_missing / permission_denied / error / timeout;online 为 null */ reason: z.string().nullable(), /** 是否做了哨兵检查(下载目录映射没有哨兵,只查目录可读) */ sentinelChecked: z.boolean(), }); export type SystemMount = z.infer; export const SystemHealth = z.object({ /** 各段最差档(unknown 不参与) */ level: HealthLevel, 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, latencyMs: z.number().nonnegative().nullable(), sizeBytes: NonNegInt.nullable(), /** 本库连接数(pg_stat_activity),按状态分 */ connections: z .object({ total: NonNegInt, active: NonNegInt, idle: NonNegInt, idleInTransaction: NonNegInt, max: NonNegInt }) .nullable(), error: z.string().nullable(), }), disks: z.array(SystemDisk), /** 磁盘阈值(取自设置 cache.resumePercent / cache.hardThresholdPercent) */ diskThresholds: z.object({ warnPercent: z.number(), criticalPercent: z.number() }), mounts: z.array(SystemMount), engine: z.object({ level: HealthLevel, pending: NonNegInt, running: NonNegInt, oldestPendingAgeSec: z.number().nonnegative().nullable(), finished24h: NonNegInt, failed24h: NonNegInt, /** failed / (completed + failed),24h 内无结束 run 为 null */ failureRate24h: z.number().min(0).max(1).nullable(), }), schedules: z.object({ level: HealthLevel, total: NonNegInt, enabled: NonNegInt, running: NonNegInt, /** 最近一次运行失败的已启用定时任务 */ failing: z.array( z.object({ id: z.string(), name: z.string(), lastRunAt: Timestamp.nullable(), lastError: z.string().nullable() }), ), failedRuns24h: NonNegInt, }), /** 存储预算(CACHE-2):PG 库体积、图片缓存、备份、会话与证据对照 settings.storage.budgets;明细见 GET /system/storage */ storage: z.object({ level: HealthLevel, /** 超出告警线 / 预算的项(人读文案) */ alerts: z.array(z.string()), }), }); export type SystemHealth = z.infer; // ── 存储预算与保留(CACHE-2;GET /system/storage、POST /system/storage/cleanup/{target},仅 admin) ── /** 手动触发的清理目标;每个对应一个维护组定时任务 */ export const StorageCleanupTarget = z.enum([ "observation", "sse_event", "heartbeat", "task_run", "agent_event", "audit", "capability_stats", "pipeline", "identity_pool", "analyze", "images", "kv_cache", ]); export type StorageCleanupTarget = z.infer; export const StorageCleanupParam = z.object({ target: StorageCleanupTarget }); export const StorageCleanupResult = z.object({ target: StorageCleanupTarget, /** 对应的定时任务 key */ jobId: z.string(), /** false = 已在排队或运行中(请求被合并) */ queued: z.boolean(), }); export type StorageCleanupResult = z.infer; /** 一项占用对照预算 */ export const StorageBudgetUsage = z.object({ usedBytes: NonNegInt.nullable(), /** 预算(critical 线);无预算为 null */ budgetBytes: NonNegInt.nullable(), /** 告警线;无单独告警线时为预算的 80% */ warnBytes: NonNegInt.nullable(), level: HealthLevel, error: z.string().nullable(), }); export type StorageBudgetUsage = z.infer; /** 一条语句的累计统计(pg_stat_statements,参数已归一化为 $n) */ export const StatementStat = z.object({ query: z.string(), calls: NonNegInt, totalMs: z.number().nonnegative(), meanMs: z.number().nonnegative(), rows: NonNegInt, }); export type StatementStat = z.infer; export const SystemStorage = z.object({ /** 各项最差档(unknown 不参与) */ level: HealthLevel, checkedAt: Timestamp, /** 目录用量的统计时间(目录遍历结果缓存 5 分钟) */ dirsScannedAt: Timestamp.nullable(), db: StorageBudgetUsage.extend({ /** 体积前 15 的表(含索引与 TOAST) */ tables: z.array( z.object({ schema: z.string(), name: z.string(), totalBytes: NonNegInt, tableBytes: NonNegInt, indexBytes: NonNegInt, /** 估算行数(pg_class.reltuples);未 ANALYZE 为 null */ rowsEstimate: NonNegInt.nullable(), deadRows: NonNegInt.nullable(), lastAutovacuumAt: Timestamp.nullable(), lastAnalyzeAt: Timestamp.nullable(), }), ), /** * 语句统计(pg_stat_statements):enabled=false 时 note 说明原因(没装扩展 / postgres 没预加载); * slowest 按平均耗时(调用 ≥ 10 次),frequent 按调用次数,各前 5;since 为统计起点(上次重置) */ statements: z.object({ enabled: z.boolean(), note: z.string().nullable(), since: Timestamp.nullable(), slowest: z.array(StatementStat), frequent: z.array(StatementStat), }), }), /** PG kv_cache 各 region(条数含未清理的过期行) */ kvCache: z.array(z.object({ region: z.string(), entries: NonNegInt, expired: NonNegInt, bytes: NonNegInt })), images: StorageBudgetUsage.extend({ files: NonNegInt.nullable() }), backups: StorageBudgetUsage.extend({ count: NonNegInt.nullable() }), sessionsEvidence: StorageBudgetUsage, /** DATA_DIR 下各一级目录(cache 再细分一层)的用量 */ dataDir: z.array( z.object({ name: z.string(), path: z.string(), usedBytes: NonNegInt, files: NonNegInt, error: z.string().nullable(), }), ), /** 各清理任务的当前设置与最近运行 */ cleanup: z.array( z.object({ target: StorageCleanupTarget, jobId: z.string(), /** 保留期说明(如 "90 天"、"24 小时"、"容量 20 GB") */ retention: z.string().nullable(), /** 定时任务不存在(未登记 / 调度器未同步)时为 null */ enabled: z.boolean().nullable(), lastRunAt: Timestamp.nullable(), lastStatus: z.string().nullable(), lastSummary: z.string().nullable(), nextRunAt: Timestamp.nullable(), }), ), }); export type SystemStorage = z.infer; export const SystemHealthHistoryQuery = z.object({ /** 回看小时数,1–168,缺省 6 */ hours: z.coerce.number().int().min(1).max(168).default(6), }); export type SystemHealthHistoryQuery = z.infer; export const SystemHealthHistory = z.object({ from: Timestamp, to: Timestamp, /** 聚合桶宽(秒);点数约 ≤ 360 */ bucketSec: z.number().int().positive(), series: z.array( z.object({ instanceId: z.string(), processKind: z.string(), points: z.array( z.object({ at: Timestamp, /** 桶内平均 */ lagMs: z.number().nonnegative(), /** 桶内最大 */ lagMaxMs: z.number().nonnegative(), runningRuns: z.number().nonnegative().nullable(), sseConnections: z.number().nonnegative().nullable(), }), ), }), ), }); export type SystemHealthHistory = z.infer;