import { z } from "zod"; import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; import { ExternalRef } from "./identity.ts"; import { AcquisitionPriority } from "./policy.ts"; // 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002) /** 持久任务类型(任务书 §3.1-8)+ Agent 有界任务 */ export const TaskKind = z.enum([ "ingestFeed", "resolveObservation", "evaluateAcquisition", "submitDownload", "verifyAndImport", "refreshMetadata", "reconcileLibrary", "cleanupDownloadCache", "resolveDecision", "agentRun", ]); export type TaskKind = z.infer; export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]); export type TaskStatus = z.infer; export const Task = EntityBase.extend({ kind: TaskKind, handlerVersion: z.number().int().min(1), idempotencyKey: z.string().min(1), status: TaskStatus, /** 数值越小越先执行 */ priority: z.number().int(), input: JsonObject, output: JsonObject.nullable(), subjectType: z.string().nullable(), subjectId: Id.nullable(), parentTaskId: Id.nullable(), engineRunId: z.string().nullable(), attempts: NonNegInt, maxAttempts: z.number().int().min(1), nextRunAt: Timestamp, waitReason: z.string().nullable(), cancelRequestedAt: Timestamp.nullable(), cancelReason: z.string().nullable(), lastError: JsonObject.nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), triggeredBy: z.string().nullable(), }); export type Task = z.infer; /** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */ export const TaskCreate = Task.pick({ kind: true, handlerVersion: true, idempotencyKey: true, priority: true, input: true, subjectType: true, subjectId: true, parentTaskId: true, maxAttempts: true, nextRunAt: true, triggeredBy: true, }).partial({ handlerVersion: true, priority: true, input: true, subjectType: true, subjectId: true, parentTaskId: true, maxAttempts: true, nextRunAt: true, triggeredBy: true, }); export type TaskCreate = z.infer; export const TaskUpdate = Task.pick({ status: true, output: true, engineRunId: true, attempts: true, nextRunAt: true, waitReason: true, lastError: true, startedAt: true, finishedAt: true, }).partial(); export type TaskUpdate = z.infer; /** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */ export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) }); export type TaskCancelRequest = z.infer; export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]); export type PlanPurpose = z.infer; export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]); export type PlanStatus = z.infer; /** * 下载计划:与 Slot 预留、预算预留同事务创建。 * 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。 */ export const AcquisitionPlan = EntityBase.extend({ slotId: Id.nullable(), sourceReleaseId: Id, taskId: Id.nullable(), purpose: PlanPurpose, status: PlanStatus, priority: AcquisitionPriority, /** evaluateAcquisition 的确定性评估结果与拒绝/接受理由 */ evaluation: JsonObject, reservedBytes: NonNegInt, budgetReservation: JsonObject, payloadHash: z.string().min(1), expectedRevision: z.number().int().min(1), idempotencyKey: z.string().min(1), expiresAt: Timestamp.nullable(), statusReason: z.string().nullable(), /** 计划预留的内容单元(content_unit id 与合成键 s{季}e{集};s{季}* = 整季未核对),在途期间计入缺集的"已预留" */ unitKeys: z.array(z.string()), /** 季包按缺集选文件的结果(提交后读文件清单;无法判定时整包下载并记理由) */ fileSelection: JsonObject.nullable(), }); export type AcquisitionPlan = z.infer; export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, status: true }).partial({ taskId: true, purpose: true, priority: true, evaluation: true, reservedBytes: true, budgetReservation: true, expectedRevision: true, expiresAt: true, statusReason: true, unitKeys: true, fileSelection: true, }); export type AcquisitionPlanCreate = z.infer; /** 手动下载为一次性计划,不要求订阅或Slot;未识别先提示一次,确认后可继续下载。 * slotId保留兼容入口,手动计划不预留或创建订阅Slot。 */ export const ManualAcquisitionRequest = z.object({ sourceReleaseId: Id, slotId: Id.optional(), entityId: Id.optional(), note: z.string().max(2000).optional(), downloaderInstanceId: Id.optional(), savePath: z.string().trim().min(1).max(4096).optional(), allowUnrecognized: z.boolean().optional(), importMode: z.enum(["normal", "replace"]).default("normal"), replacementAssetIds: z.array(Id).default([]), /** * 下载前指定作品:本地作品或外部身份(未登记的按 TMDB 详情加入媒体库), * 与 POST /source-releases/{id}/identity 同一实现,绑定到资源并加人工锁;给了就不再用 entityId。 */ identity: z .union([z.object({ mediaEntityId: Id }).strict(), z.object({ external: ExternalRef }).strict()]) .optional(), }); export type ManualAcquisitionRequest = z.infer; export const ManualAcquisitionResult = z.object({ /** true = 计划已预留并入队 submitDownload */ submitted: z.boolean(), requiresConfirmation: z.boolean().optional(), plan: AcquisitionPlan.nullable(), slotId: Id.nullable(), /** 业务任务 ID(submitDownload) */ taskId: Id.nullable(), /** 未生成计划的原因;未识别时附requiresConfirmation */ rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })), }); export type ManualAcquisitionResult = z.infer; /** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */ export const DownloadSavePathQuery = z.object({ /** 资源所在站点;站点指定了下载器时「自动」按它解析 */ siteId: Id.optional(), }); export type DownloadSavePathQuery = z.infer; export const DownloadSaveDir = z.object({ /** 下载器视角的路径(提交给下载器的保存路径) */ path: z.string(), /** 本机视角的路径;配置了映射但对不上时为 null */ hostPath: z.string().nullable(), /** 下载器设置里的默认保存目录(「自动」用它) */ isDefault: z.boolean(), }); export type DownloadSaveDir = z.infer; export const DownloadSaveTarget = z.object({ instanceId: Id, name: z.string(), isDefault: z.boolean(), /** 第一个是默认保存目录 */ dirs: z.array(DownloadSaveDir), }); export type DownloadSaveTarget = z.infer; export const DownloadSavePaths = z.object({ /** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */ autoInstanceId: Id.nullable(), instances: z.array(DownloadSaveTarget), }); export type DownloadSavePaths = z.infer; export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial(); export type AcquisitionPlanUpdate = z.infer; export const DownloadOrigin = z.enum(["submitted", "adopted"]); export type DownloadOrigin = z.infer; export const DownloadStatus = z.enum([ "queued", "submitting", "downloading", "paused", "stalled", "completed", "importing", "imported", "failed", "removed", "missing", ]); export type DownloadStatus = z.infer; /** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */ export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]); export type PauseReason = z.infer; export const TorrentHash = z.string().regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/); export const DownloadTask = EntityBase.extend({ instanceId: Id, /** 该下载器实例内的 torrent 标识(v1 hash 或 v2 hash,小写十六进制) */ torrentHash: TorrentHash, infohashV1: z.string().nullable(), infohashV2: z.string().nullable(), acquisitionPlanId: Id.nullable(), /** 本次手动整理计划;保留原下载计划关联用于订阅推进 */ importPlanId: Id.nullable().optional(), sourceReleaseId: Id.nullable(), /** 关联作品:计划 / 站点资源 / 接管任务按文件名识别 / 手动指定 / 手动整理后回填(QB-1) */ mediaEntityId: Id.nullable().optional(), name: z.string(), savePath: z.string().nullable(), contentPath: z.string().nullable(), category: z.string().nullable(), tags: z.array(z.string()), /** 是否在管理标签范围内;标签移除后暂停后续自动动作 */ managed: z.boolean(), origin: DownloadOrigin, status: DownloadStatus, pauseReason: PauseReason.nullable(), rawState: z.string().nullable(), progress: z.number().min(0).max(1), sizeBytes: NonNegInt.nullable(), downloadedBytes: NonNegInt, remainingBytes: NonNegInt.nullable(), ratio: z.number().nullable(), seedingTimeSec: NonNegInt.nullable(), completedAt: Timestamp.nullable(), lastProgressAt: Timestamp.nullable(), /** 无进展起点:只记录并汇入待处理,不自动换源(REQ §8.4.2) */ noProgressSince: Timestamp.nullable(), lastSyncedAt: Timestamp.nullable(), nextCheckAt: Timestamp.nullable(), removedAt: Timestamp.nullable(), fileList: z.array(z.object({ path: z.string(), size: NonNegInt, progress: z.number().optional() })).nullable(), lastError: JsonObject.nullable(), /** 下载器里连续查不到的起点与轮数:达到阈值才判 removed,中途出现即清零 */ missingSince: Timestamp.nullable().optional(), missingCount: NonNegInt.optional(), /** removed 行被再次提交 / 接管复活的次数(完成事件幂等键随之换代) */ generation: NonNegInt.optional(), }); export type DownloadTask = z.infer; export const DownloadTaskCreate = DownloadTask.pick({ instanceId: true, torrentHash: true, infohashV1: true, infohashV2: true, acquisitionPlanId: true, sourceReleaseId: true, name: true, savePath: true, category: true, tags: true, managed: true, origin: true, }).partial({ infohashV1: true, infohashV2: true, acquisitionPlanId: true, sourceReleaseId: true, savePath: true, category: true, tags: true, managed: true, origin: true, }); export type DownloadTaskCreate = z.infer; export const DownloadTaskUpdate = DownloadTask.omit({ ...SERVER_MANAGED, instanceId: true, torrentHash: true, }).partial(); export type DownloadTaskUpdate = z.infer; export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]); export type OperationPhase = z.infer; export const OperationKind = z.enum([ "copy", "move", "rename", "mkdir", "hardlink", "delete_source", "delete_target", "verify", "register", "qb_pause", "qb_resume", "qb_remove", "qb_set_tags", "other", ]); export type OperationKind = z.infer; export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]); export type OperationStatus = z.infer; /** 副作用账本:plan → attempt(started, attemptToken) → result;提交时以 attemptToken 做 fencing */ export const AssetOperation = EntityBase.extend({ operationId: z.string().min(1), taskId: Id.nullable(), organizationRecordId: Id.nullable(), planFingerprint: z.string().nullable(), ordinal: z.number().int().nonnegative(), phase: OperationPhase, kind: OperationKind, status: OperationStatus, intent: JsonObject, attemptToken: Id.nullable(), attempts: NonNegInt, result: JsonObject.nullable(), error: JsonObject.nullable(), mediaAssetId: Id.nullable(), downloadTaskId: Id.nullable(), startedAt: Timestamp.nullable(), finishedAt: Timestamp.nullable(), }); export type AssetOperation = z.infer; export const AssetOperationCreate = AssetOperation.pick({ operationId: true, taskId: true, organizationRecordId: true, planFingerprint: true, ordinal: true, phase: true, kind: true, intent: true, mediaAssetId: true, downloadTaskId: true, }).partial({ taskId: true, organizationRecordId: true, planFingerprint: true, ordinal: true, intent: true, mediaAssetId: true, downloadTaskId: true, }); export type AssetOperationCreate = z.infer; export const AssetOperationUpdate = AssetOperation.pick({ status: true, attemptToken: true, attempts: true, result: true, error: true, mediaAssetId: true, startedAt: true, finishedAt: true, }).partial(); export type AssetOperationUpdate = z.infer;