- contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性) 的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点; FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置 - outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅, 没有订阅方时也被中继认领并镜像到 SSE - 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销; ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks - ADR-006 与 server README「如何写一个功能模块」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
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=<seq> 补读 */
|
|
export const EventEnvelope = z.object({
|
|
seq: z.number().int().nonnegative(),
|
|
type: z.string().min(1),
|
|
at: Timestamp,
|
|
payload: z.unknown(),
|
|
});
|
|
export type EventEnvelope<P = unknown> = {
|
|
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:<id>` 属于后者);
|
|
* - 整理结束 = `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<EventType, z.ZodType>;
|
|
|
|
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
|
|
|
|
/** 已知事件的判别联合:`{ type, payload }` */
|
|
export type TypedEvent = { [T in EventType]: { type: T; payload: EventPayload<T> } }[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 幂等键:`<type>/<部分…>`。用斜杠与已有的 `<type>:<id>` 键分开
|
|
* (`download.completed:<id>` 已被 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<T extends EventType>(type: T, payload: unknown): EventPayload<T> | null {
|
|
const schema: z.ZodType = EVENT_PAYLOADS[type];
|
|
const r = schema.safeParse(payload);
|
|
return r.success ? (r.data as EventPayload<T>) : 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<typeof EventReplayQuery>;
|
|
|
|
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<typeof EventReplayPage>;
|