Files
UHD_Media_Tool/packages/contracts/src/execution.ts
T
rootandClaude Opus 5.5 1dd460d359 feat(db,contracts): W5-C subscription units migration 0025, contracts and mapping
- subscription: status pending (TMDB not ready: searchable/evaluable, not completable),
  last_searched_at / next_search_at
- content_unit (season): episode_total ledger with source / lock / timestamp
- acquisition_plan: unit_keys (Slot × unit reservation) and file_selection; drop the
  one-in-flight-per-slot unique index (checked under slot row lock per unit instead)
- new tables slot_deferral, slot_unit (per-unit quality ledger), subscription_history
- contracts: SubscriptionParams, SubscriptionHistory, SlotDeferral, SlotUnit, SubscriptionProgress,
  EpisodeTotalSet, CandidateEvaluation pri/trace/unitKeys and decision deferred;
  endpoints subscriptions.search/progress/setEpisodeTotal and subscription-history
- mapping: evaluation messages via describeViolation, pri/trace passthrough

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:33:56 +08:00

327 lines
11 KiB
TypeScript

import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.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<typeof TaskKind>;
export const TaskStatus = z.enum(["pending", "running", "waiting", "succeeded", "failed", "cancelled", "dead"]);
export type TaskStatus = z.infer<typeof TaskStatus>;
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<typeof Task>;
/** 内部创建输入(幂等键由服务端按语义生成,不能由模型挑选) */
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<typeof TaskCreate>;
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<typeof TaskUpdate>;
/** 取消 = 停止未来步骤,不撤销已完成外部动作(REQ §11.4) */
export const TaskCancelRequest = z.object({ reason: z.string().min(1).max(500) });
export type TaskCancelRequest = z.infer<typeof TaskCancelRequest>;
export const PlanPurpose = z.enum(["fill", "upgrade", "replace_source"]);
export type PlanPurpose = z.infer<typeof PlanPurpose>;
export const PlanStatus = z.enum(["draft", "reserved", "submitted", "completed", "cancelled", "failed", "expired"]);
export type PlanStatus = z.infer<typeof PlanStatus>;
/**
* 下载计划:与 Slot 预留、预算预留同事务创建。
* 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。
*/
export const AcquisitionPlan = EntityBase.extend({
slotId: Id,
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<typeof AcquisitionPlan>;
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<typeof AcquisitionPlanCreate>;
/**
* 手动提交下载(工作台下载入口,POST /acquisition-plans):与自动收录同一流程——
* download.plan(evaluateAcquisition,硬条件/预算/阈值/Slot 忙)→ download.submit(预留 + submitDownload 任务)。
* 目标 Slot:slotId 优先;否则 entityId(缺省取来源资源已识别的作品)下按创建顺序逐个尝试,第一个生成计划的 Slot 胜出。
*/
export const ManualAcquisitionRequest = z.object({
sourceReleaseId: Id,
slotId: Id.optional(),
entityId: Id.optional(),
note: z.string().max(2000).optional(),
});
export type ManualAcquisitionRequest = z.infer<typeof ManualAcquisitionRequest>;
export const ManualAcquisitionResult = z.object({
/** true = 计划已预留并入队 submitDownload */
submitted: z.boolean(),
plan: AcquisitionPlan.nullable(),
slotId: Id.nullable(),
/** 业务任务 ID(submitDownload) */
taskId: Id.nullable(),
/** 未生成计划时逐个 Slot 的拒绝原因 */
rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })),
});
export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;
export const DownloadOrigin = z.enum(["submitted", "adopted"]);
export type DownloadOrigin = z.infer<typeof DownloadOrigin>;
export const DownloadStatus = z.enum([
"queued",
"submitting",
"downloading",
"paused",
"stalled",
"completed",
"importing",
"imported",
"failed",
"removed",
"missing",
]);
export type DownloadStatus = z.infer<typeof DownloadStatus>;
/** 暂停原因:阈值恢复时只恢复 threshold 暂停项,不恢复用户手动暂停(REQ §8.4.1) */
export const PauseReason = z.enum(["user", "threshold", "policy", "agent"]);
export type PauseReason = z.infer<typeof PauseReason>;
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(),
sourceReleaseId: Id.nullable(),
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(),
});
export type DownloadTask = z.infer<typeof DownloadTask>;
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<typeof DownloadTaskCreate>;
export const DownloadTaskUpdate = DownloadTask.omit({
...SERVER_MANAGED,
instanceId: true,
torrentHash: true,
}).partial();
export type DownloadTaskUpdate = z.infer<typeof DownloadTaskUpdate>;
export const OperationPhase = z.enum(["decision", "prepare", "transfer", "verify", "commit", "cleanup", "compensate"]);
export type OperationPhase = z.infer<typeof OperationPhase>;
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<typeof OperationKind>;
export const OperationStatus = z.enum(["planned", "started", "succeeded", "failed", "skipped", "compensated"]);
export type OperationStatus = z.infer<typeof OperationStatus>;
/** 副作用账本: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<typeof AssetOperation>;
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<typeof AssetOperationCreate>;
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<typeof AssetOperationUpdate>;