import { z } from "zod"; import { AgentRunStatus, DecisionKind, DecisionStatus, Severity } from "./agent.ts"; import { Id, NonNegInt, Timestamp } from "./common.ts"; import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts"; import { IdentifyCompletedPayload } from "./files.ts"; import { EvidenceLevel } from "./identity.ts"; import { OrganizationAction, OrganizationStatus, OrganizationTrigger } from "./organization.ts"; import { SubscriptionMediaKind, SubscriptionOrigin, SubscriptionStatus } from "./policy.ts"; import { ScrapeStatus, ScrapeTrigger } from "./scrape.ts"; import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.ts"; import { SubtitleLanguage, SubtitleSource } from "./subtitles.ts"; /** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after= 补读 */ export const EventEnvelope = z.object({ seq: z.number().int().nonnegative(), type: z.string().min(1), at: Timestamp, payload: z.unknown(), }); export type EventEnvelope

= { seq: number; type: string; at: string; payload: P; }; /** * 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema。 * * 状态流事件(`download.status_changed`、`organization.changed`)每次状态变化都发,给 SSE 与增量游标用; * 生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)只在业务节点发一次,给功能模块订阅: * - 下载完成 = `download.completed`(与 `download.status_changed → completed` 同事务发出;acquisition-organize * 仍按后者入队 verifyAndImport,幂等键 `download.completed:` 属于后者); * - 整理结束 = `organization.finished`,`outcome` 区分成功 / 部分成功 / 失败。 */ export const EventType = { heartbeat: "system.heartbeat", resync: "system.resync", taskCreated: "task.created", taskStatusChanged: "task.status_changed", downloadSubmitted: "download.submitted", downloadProgress: "download.progress", downloadStatusChanged: "download.status_changed", downloadThreshold: "download.threshold", organizationChanged: "organization.changed", agentRunStatus: "agent.run_status", agentTextDelta: "agent.text_delta", agentToolCall: "agent.tool_call", feedFetched: "feed.fetched", feedHealthChanged: "feed.health_changed", feedDue: "feed.due", feedGapSuspected: "feed.gap_suspected", releaseObserved: "release.observed", releaseResolved: "release.resolved", decisionCreated: "decision.created", decisionUpdated: "decision.updated", identifyCompleted: "identify.completed", // ── 生命周期广播事件(ADR-006):业务节点事后通知,经 outbox,至少一次 ── subscriptionCreated: "subscription.created", subscriptionUpdated: "subscription.updated", subscriptionCompleted: "subscription.completed", subscriptionCancelled: "subscription.cancelled", downloadCompleted: "download.completed", downloadFailed: "download.failed", downloadRemoved: "download.removed", organizationFinished: "organization.finished", scrapeCompleted: "scrape.completed", scrapeFailed: "scrape.failed", subtitleWritten: "subtitle.written", mediaWatched: "media.watched", mediaMetadataRefreshed: "media.metadata_refreshed", mediaEpisodeStructureChanged: "media.episode_structure_changed", siteUnreachable: "site.unreachable", siteRecovered: "site.recovered", } as const; export type EventType = (typeof EventType)[keyof typeof EventType]; /** 心跳 payload */ export const HeartbeatPayload = z.object({ role: z.string(), }); /** 服务端环形缓冲不足以补读时发出,客户端应整页刷新 */ export const ResyncPayload = z.object({ reason: z.string() }); export const TaskCreatedPayload = z.object({ taskId: Id, kind: TaskKind, subjectType: z.string().nullable(), subjectId: Id.nullable(), }); export const TaskStatusChangedPayload = z.object({ taskId: Id, kind: TaskKind, from: TaskStatus.nullable(), to: TaskStatus, error: z.string().nullable(), }); export const DownloadSubmittedPayload = z.object({ downloadTaskId: Id, instanceId: Id, torrentHash: z.string(), planId: Id.nullable(), name: z.string(), }); export const DownloadProgressPayload = z.object({ downloadTaskId: Id, progress: z.number().min(0).max(1), downloadedBytes: NonNegInt, sizeBytes: NonNegInt.nullable(), }); export const DownloadStatusChangedPayload = z.object({ downloadTaskId: Id, from: DownloadStatus.nullable(), to: DownloadStatus, pauseReason: PauseReason.nullable(), }); /** 缓存卷越过硬阈值(tripped)或回到恢复水位(recovered) */ export const DownloadThresholdPayload = z.object({ volumeKey: z.string(), state: z.enum(["tripped", "recovered", "unknown"]), usedPercent: z.number().min(0).max(100).nullable(), pausedTaskIds: z.array(Id), }); /** 整理记录新增或状态变化;changeSeq 与公开 API 的游标一致 */ export const OrganizationChangedPayload = z.object({ recordId: Id, status: OrganizationStatus, changeSeq: z.string().regex(/^\d+$/), mediaEntityId: Id.nullable(), }); export const AgentRunStatusPayload = z.object({ runId: Id, status: AgentRunStatus, summary: z.string().nullable(), }); /** Agent 文本增量:runSeq 为该 run 内序号,配合 agent_run.last_event_seq 断点续读 */ export const AgentTextDeltaPayload = z.object({ runId: Id, runSeq: NonNegInt, delta: z.string(), }); export const AgentToolCallPayload = z.object({ runId: Id, runSeq: NonNegInt, capabilityId: z.string(), phase: z.enum(["started", "succeeded", "failed", "denied"]), /** 已脱敏的摘要,不含凭据/私有 URL */ summary: z.string(), }); export const FeedFetchedPayload = z.object({ feedId: Id, newItems: NonNegInt, updatedItems: NonNegInt, notModified: z.boolean(), }); export const FeedHealthChangedPayload = z.object({ feedId: Id, from: FeedHealth.nullable(), to: FeedHealth, reason: z.string().nullable(), }); /** 来源发布已识别到本地作品(W2-B 发出,W2-C 的 evaluateAcquisition 消费);歧义/无匹配走 decision.created */ export const ReleaseResolvedPayload = z.object({ sourceReleaseId: Id, observationId: Id.nullable(), mediaEntityId: Id, contentUnitIds: z.array(Id), evidenceLevel: EvidenceLevel, }); export const DecisionCreatedPayload = z.object({ decisionId: Id, kind: DecisionKind, severity: Severity, title: z.string(), }); export const DecisionUpdatedPayload = z.object({ decisionId: Id, status: DecisionStatus, }); // ── 生命周期广播事件 payload(ADR-006)── // payload 只放业务 ID 与订阅方做判断要用的少量事实;详情由订阅方按 ID 查业务表(事件可能晚到、重复到)。 const SubscriptionRef = z.object({ subscriptionId: Id, mediaEntityId: Id.nullable(), mediaKind: SubscriptionMediaKind, }); /** 订阅已建(用户 / 智能体 / 策略) */ export const SubscriptionCreatedPayload = SubscriptionRef.extend({ origin: SubscriptionOrigin, status: SubscriptionStatus, }); /** 订阅已改:参数或状态(暂停 / 恢复 / 重新订阅);changes 是改动的字段名 */ export const SubscriptionUpdatedPayload = SubscriptionRef.extend({ status: SubscriptionStatus, changes: z.array(z.string()), }); /** 订阅已完成:reason 如 slot_satisfied / download_accepted / stop_after_reached */ export const SubscriptionCompletedPayload = SubscriptionRef.extend({ reason: z.string() }); /** 订阅已取消(删除订阅即取消,行保留) */ export const SubscriptionCancelledPayload = SubscriptionRef.extend({ reason: z.string() }); const DownloadRef = z.object({ downloadTaskId: Id, planId: Id.nullable(), mediaEntityId: Id.nullable(), name: z.string(), /** 任务行代数:removed 行复活后 +1 */ generation: NonNegInt, }); /** 下载已完成(种子下完,尚未整理) */ export const DownloadCompletedPayload = DownloadRef; /** 下载失败 */ export const DownloadFailedPayload = DownloadRef.extend({ from: DownloadStatus.nullable() }); /** 下载已移除(用户删除 / 清理 / 下载器里找不到) */ export const DownloadRemovedPayload = DownloadRef.extend({ from: DownloadStatus.nullable() }); /** 整理结束:一条整理记录进入 succeeded / partial / failed(统一 organization.changed 的终态) */ export const OrganizationFinishedPayload = z.object({ recordId: Id, outcome: z.enum(["succeeded", "partial", "failed"]), action: OrganizationAction, trigger: OrganizationTrigger, mediaEntityId: Id.nullable(), downloadTaskId: Id.nullable(), reason: z.string().nullable(), changeSeq: z.string().regex(/^\d+$/), }); const ScrapeRef = z.object({ scrapeRecordId: Id, mediaEntityId: Id.nullable(), organizationRecordId: Id.nullable(), trigger: ScrapeTrigger, status: ScrapeStatus, written: NonNegInt, failed: NonNegInt, }); /** 刮削完成(succeeded / partial / skipped) */ export const ScrapeCompletedPayload = ScrapeRef; /** 刮削失败 */ export const ScrapeFailedPayload = ScrapeRef.extend({ reason: z.string().nullable() }); /** 字幕已写入媒体库(一条写入历史一条事件) */ export const SubtitleWrittenPayload = z.object({ writeId: Id, mediaEntityId: Id, videoLocationId: Id, language: SubtitleLanguage, source: SubtitleSource, }); /** 作品进入关注(建订阅、开始下载、用户搜索 / 手动认定);reason 为触发来源 */ export const MediaWatchedPayload = z.object({ mediaEntityId: Id, reason: z.string() }); /** 作品元数据已刷新;changedFields 为本次变化的字段 */ export const MediaMetadataRefreshedPayload = z.object({ mediaEntityId: Id, full: z.boolean(), changedFields: z.array(z.string()), }); /** 季集结构已切换(TMDB 默认季 ⇄ 剧集组);null 表示默认季 */ export const MediaEpisodeStructureChangedPayload = z.object({ mediaEntityId: Id, fromGroupId: z.string().nullable(), toGroupId: z.string().nullable(), }); /** 站点不可达:登录失效、列表抓取连续失败或检测失败;reason 为原因码 */ export const SiteUnreachablePayload = z.object({ siteId: Id, reason: z.string(), message: z.string().nullable() }); /** 站点恢复 */ export const SiteRecoveredPayload = z.object({ siteId: Id, reason: z.string() }); /** 事件类型 → payload schema */ export const EVENT_PAYLOADS = { "system.heartbeat": HeartbeatPayload, "system.resync": ResyncPayload, "task.created": TaskCreatedPayload, "task.status_changed": TaskStatusChangedPayload, "download.submitted": DownloadSubmittedPayload, "download.progress": DownloadProgressPayload, "download.status_changed": DownloadStatusChangedPayload, "download.threshold": DownloadThresholdPayload, "organization.changed": OrganizationChangedPayload, "agent.run_status": AgentRunStatusPayload, "agent.text_delta": AgentTextDeltaPayload, "agent.tool_call": AgentToolCallPayload, "feed.fetched": FeedFetchedPayload, "feed.health_changed": FeedHealthChangedPayload, "feed.due": FeedDuePayload, "feed.gap_suspected": FeedGapSuspectedPayload, "release.observed": ReleaseObservedPayload, "release.resolved": ReleaseResolvedPayload, "decision.created": DecisionCreatedPayload, "decision.updated": DecisionUpdatedPayload, "identify.completed": IdentifyCompletedPayload, "subscription.created": SubscriptionCreatedPayload, "subscription.updated": SubscriptionUpdatedPayload, "subscription.completed": SubscriptionCompletedPayload, "subscription.cancelled": SubscriptionCancelledPayload, "download.completed": DownloadCompletedPayload, "download.failed": DownloadFailedPayload, "download.removed": DownloadRemovedPayload, "organization.finished": OrganizationFinishedPayload, "scrape.completed": ScrapeCompletedPayload, "scrape.failed": ScrapeFailedPayload, "subtitle.written": SubtitleWrittenPayload, "media.watched": MediaWatchedPayload, "media.metadata_refreshed": MediaMetadataRefreshedPayload, "media.episode_structure_changed": MediaEpisodeStructureChangedPayload, "site.unreachable": SiteUnreachablePayload, "site.recovered": SiteRecoveredPayload, } as const satisfies Record; export type EventPayload = z.infer<(typeof EVENT_PAYLOADS)[T]>; /** 已知事件的判别联合:`{ type, payload }` */ export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload } }[EventType]; /** 已知事件信封的 schema(判别联合) */ export const TypedEventEnvelope = z.discriminatedUnion( "type", Object.entries(EVENT_PAYLOADS).map(([type, payload]) => z.object({ seq: z.number().int().nonnegative(), at: Timestamp, type: z.literal(type), payload }), ) as unknown as [z.ZodObject, ...z.ZodObject[]], ); /** * 生命周期广播事件清单(ADR-006):给功能清单、智能体提示与文档用。 * 已有的 release.resolved 也算一条(资源识别完成)。emittedBy 是发出的模块,说明写在什么时候发。 */ export const LIFECYCLE_EVENTS: readonly { type: EventType; label: string; emittedBy: string; when: string }[] = [ { type: "release.resolved", label: "资源识别完成", emittedBy: "sources-identity", when: "资源识别到本地作品" }, { type: "subscription.created", label: "订阅已建", emittedBy: "acquisition-organize", when: "新建订阅(包括按策略自动建的)", }, { type: "subscription.updated", label: "订阅已改", emittedBy: "acquisition-organize", when: "改参数、暂停、恢复、重新订阅", }, { type: "subscription.completed", label: "订阅已完成", emittedBy: "acquisition-organize", when: "收录版本全部满足或到达停止日期", }, { type: "subscription.cancelled", label: "订阅已取消", emittedBy: "acquisition-organize", when: "删除订阅" }, { type: "download.submitted", label: "下载已提交", emittedBy: "acquisition-organize", when: "种子交给下载器" }, { type: "download.completed", label: "下载已完成", emittedBy: "acquisition-organize", when: "下载器报告已下完" }, { type: "download.failed", label: "下载失败", emittedBy: "acquisition-organize", when: "下载任务进入失败状态" }, { type: "download.removed", label: "下载已移除", emittedBy: "acquisition-organize", when: "任务被删除或在下载器里消失", }, { type: "organization.finished", label: "整理结束", emittedBy: "acquisition-organize", when: "整理记录成功、部分成功或失败", }, { type: "scrape.completed", label: "刮削完成", emittedBy: "acquisition-organize", when: "刮削记录成功 / 部分成功 / 跳过", }, { type: "scrape.failed", label: "刮削失败", emittedBy: "acquisition-organize", when: "刮削记录失败" }, { type: "subtitle.written", label: "字幕已写入", emittedBy: "subtitles", when: "补字幕写入一个字幕文件" }, { type: "media.watched", label: "作品进入关注", emittedBy: "sources-identity / acquisition-organize", when: "新建订阅、开始下载或搜索时确认了作品", }, { type: "media.metadata_refreshed", label: "作品元数据已刷新", emittedBy: "sources-identity", when: "刷新元数据任务完成", }, { type: "media.episode_structure_changed", label: "季集结构已切换", emittedBy: "sources-identity", when: "作品换了季集结构", }, { type: "site.unreachable", label: "站点不可达", emittedBy: "sources-identity", when: "登录失效、列表连续抓取失败或检测失败", }, { type: "site.recovered", label: "站点恢复", emittedBy: "sources-identity", when: "不可达的站点再次正常" }, ]; /** * 生命周期事件的 outbox 幂等键:`/<部分…>`。用斜杠与已有的 `:` 键分开 * (`download.completed:` 已被 download.status_changed 占用,outbox 幂等键全表唯一)。 */ export function lifecycleKey(type: EventType, ...parts: (string | number)[]): string { return [type, ...parts].join("/"); } export function isEventType(type: string): type is EventType { return Object.hasOwn(EVENT_PAYLOADS, type); } /** 校验一个事件 payload;不合法返回 null */ export function parseEventPayload(type: T, payload: unknown): EventPayload | null { const schema: z.ZodType = EVENT_PAYLOADS[type]; const r = schema.safeParse(payload); return r.success ? (r.data as EventPayload) : null; } /** * 事件补读(JSON,给 API Key 调用者轮询;EventSource 带不了 Bearer)。 * GET /events 带 limit 参数(或 Accept 只要 application/json)时返回这一页,否则是 SSE 流。 */ export const EventReplayQuery = z.object({ after: z.coerce.number().int().nonnegative().default(0), limit: z.coerce.number().int().min(1).max(1000).optional(), }); export type EventReplayQuery = z.infer; export const EventReplayPage = z.object({ items: z.array(EventEnvelope), /** 下次请求的 after(本页最后一条 seq;空页时等于请求的 after) */ nextAfter: z.number().int().nonnegative(), hasMore: z.boolean(), /** 当前最大 seq */ latestSeq: z.number().int().nonnegative(), /** true:after 早于保留窗口(已裁剪),应重新拉取业务快照后从 latestSeq 续读 */ gap: z.boolean(), }); export type EventReplayPage = z.infer;