- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the unidentified filter, recursive, cached 5 min, invalidated by files.changed) - G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web subline uses it and no longer parses names itself - G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and catalog.lookup, local entities from metadata.posterPath); thumbnails in the identify panel - S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending, MediaServerInventory.partial, EventType identify.completed with IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard merge remote results by root + path, buffering events that beat the response Co-Authored-By: Claude Opus 5.5 <[email protected]>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 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 { OrganizationStatus } from "./organization.ts";
|
|
import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.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.completed / organization.completed 事件,约定用现有事件表达(W2 收口):
|
|
* - "下载完成" = `download.status_changed` 且 `to === "completed"`(outbox 幂等键 `download.completed:<id>`,只发一次;
|
|
* acquisition-organize 据此入队 verifyAndImport);
|
|
* - "入库完成" = `organization.changed` 且 `status === "succeeded"`(或 partial);`changeSeq` 与公开增量 API 游标一致。
|
|
*/
|
|
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",
|
|
} 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 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,
|
|
} 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[]],
|
|
);
|
|
|
|
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>;
|