Files
UHD_Media_Tool/packages/contracts/src/organization.ts
T
rootandClaude Opus 5.5 05d1f76b9f feat(library): download history lists adopted tasks and organize records
- Migration 0053: download_task.media_entity_id (FK, set null on delete),
  backfilled from plan slot / source release / latest organize record.
- Submitted and adopted tasks carry the entity; entity merge follows it.
- GET /library/{id}: downloads also matched by dt.media_entity_id (adopted
  takeover tasks), plus organizationRecords (incl. manual path organizes);
  library list / inventory counts use it too.
- Web detail: "接管" marker and a list of records without a download task.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:54:10 +08:00

665 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { AgentRunStatus, DecisionKind } from "./agent.ts";
import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask } from "./execution.ts";
import { ExternalIdentity } from "./identity.ts";
import { AcquisitionSlot, CandidateEvaluation, SlotStatus } from "./policy.ts";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/**
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
*/
export const OrganizationAction = z.enum([
"copy",
"move",
"rename",
"relocate",
"hardlink",
"trash",
"delete",
"restore",
]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
export const OrganizationRecord = EntityBase.extend({
taskId: Id.nullable(),
trigger: OrganizationTrigger,
actorType: ActorType,
actorId: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
downloadTaskId: Id.nullable(),
sourceDownloaderInstanceId: Id.nullable(),
sourcePathView: SourcePathView,
sourcePath: z.string().nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
reason: z.string().nullable(),
error: JsonObject.nullable(),
/** 冻结的整理计划 */
plan: JsonObject,
planFingerprint: z.string().nullable(),
fileTotal: NonNegInt,
fileSucceeded: NonNegInt,
fileFailed: NonNegInt,
fileSkipped: NonNegInt,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
taskId: true,
trigger: true,
actorType: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
action: true,
plan: true,
planFingerprint: true,
}).partial({
taskId: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
plan: true,
planFingerprint: true,
});
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
export const OrganizationRecordUpdate = OrganizationRecord.pick({
status: true,
reason: true,
error: true,
targetPath: true,
fileTotal: true,
fileSucceeded: true,
fileFailed: true,
fileSkipped: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
export const OrganizationFileResult = EntityBase.extend({
organizationRecordId: Id,
ordinal: z.number().int().nonnegative(),
assetOperationId: Id.nullable(),
mediaAssetId: Id.nullable(),
contentUnitId: Id.nullable(),
role: FileRole,
mode: OrganizationAction,
sourcePath: z.string().min(1),
sourceSizeBytes: NonNegInt.nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
status: FileResultStatus,
skipReason: z.string().nullable(),
error: JsonObject.nullable(),
checksum: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
organizationRecordId: true,
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
mode: true,
sourcePath: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
}).partial({
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
});
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
assetOperationId: true,
mediaAssetId: true,
targetPath: true,
status: true,
skipReason: true,
error: true,
checksum: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
export const PublicPathRef = z.object({
path: z.string().nullable(),
view: z.enum(["downloader", "local", "library"]),
downloaderInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
});
export type PublicPathRef = z.infer<typeof PublicPathRef>;
export const PublicOrganizationFile = z.object({
id: Id,
ordinal: z.number().int().nonnegative(),
role: FileRole,
mode: OrganizationAction,
status: FileResultStatus,
source: PublicPathRef,
target: PublicPathRef,
sizeBytes: NonNegInt.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
operationId: Id.nullable(),
skipReason: PublicReason.nullable(),
failure: PublicReason.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
taskId: Id.nullable(),
/** 来源下载任务(手动整理本地路径时为 null) */
downloadTaskId: Id.nullable(),
/** 相关资产操作账本 ID */
operationIds: z.array(Id),
media: z
.object({
entityId: Id,
kind: MediaKind,
title: z.string(),
year: z.number().int().nullable(),
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** 已确认的外部身份,如 tmdb:movie:693134 */
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
})
.nullable(),
trigger: OrganizationTrigger,
/** 手动 / 自动来源(agent 视为自动) */
origin: z.enum(["manual", "auto"]),
actor: z.object({ type: ActorType, id: z.string().nullable() }),
action: OrganizationAction,
status: OrganizationStatus,
source: PublicPathRef,
target: PublicPathRef,
reason: PublicReason.nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
/** includeFiles=false 时为 null */
files: z.array(PublicOrganizationFile).nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
export const OrganizationChangesQuery = z.object({
after: ChangeCursor.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
status: OrganizationStatus.optional(),
mediaEntityId: Id.optional(),
});
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
/**
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
*/
export const OrganizationChangesResponse = z.object({
items: z.array(PublicOrganizationRecord),
nextCursor: ChangeCursor,
hasMore: z.boolean(),
});
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
downloadTaskId: Id.optional(),
q: z.string().max(200).optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
export const OrganizeRequest = z
.object({
downloadTaskId: Id.optional(),
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
})
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
export const OrganizeAccepted = z.object({
recordId: Id.nullable(),
taskId: Id,
planId: Id.nullable(),
/** dryRun 按路径遍历时文件数超过上限(ORGANIZE_WALK_MAX_FILES),计划只含前 N 个文件 */
truncated: z.boolean().optional(),
});
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
// ── 媒体库 / 库存叠加 / 首页聚合(W2-C 读模型,均为只读投影) ──
/** 各 Slot 状态计数(不用 record:键集固定,缺省 0) */
export const SlotStatusCounts = z.object({
empty: NonNegInt,
reserved: NonNegInt,
downloading: NonNegInt,
verifying: NonNegInt,
satisfied: NonNegInt,
upgrade_pending: NonNegInt,
attention: NonNegInt,
});
export type SlotStatusCounts = z.infer<typeof SlotStatusCounts>;
export const LibraryListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
kind: MediaKind.optional(),
/** 标题/原名/别名模糊搜索 */
q: z.string().max(200).optional(),
/** gap = 有未满足 Slot;downloading = 有下载中任务;attention = 有 Slot 需处理 */
filter: z.enum(["gap", "downloading", "attention", "satisfied"]).optional(),
});
export type LibraryListQuery = z.infer<typeof LibraryListQuery>;
export const LibraryItem = z.object({
entity: MediaEntity.pick({ id: true, kind: true, title: true, originalTitle: true, year: true, status: true }),
assetCount: NonNegInt,
slotCounts: SlotStatusCounts,
activeDownloads: NonNegInt,
lastOrganizedAt: Timestamp.nullable(),
/** TMDB 海报路径(metadata.posterPath);前端经图片代理显示,没有时显示无彩占位 */
posterPath: z.string().nullable().optional(),
});
export type LibraryItem = z.infer<typeof LibraryItem>;
/** 资产位置 + 执行进程可访问的实际路径(库根离线时仍返回拼接路径,状态看 location.status) */
export const LibraryAssetView = z.object({
asset: MediaAsset,
locations: z.array(AssetLocation.extend({ absolutePath: z.string().nullable() })),
/** 覆盖的内容单元 */
contentUnitIds: z.array(Id),
});
export type LibraryAssetView = z.infer<typeof LibraryAssetView>;
export const LibraryDownloadView = z.object({
downloadTask: DownloadTask,
plan: AcquisitionPlan.nullable(),
/** 该下载任务的整理记录(含文件级核验结果) */
organizationRecordIds: z.array(Id),
lastOrganizationStatus: OrganizationStatus.nullable(),
});
export type LibraryDownloadView = z.infer<typeof LibraryDownloadView>;
export const LibraryDetail = z.object({
entity: MediaEntity,
/** 身份映射状态(含 proposed/conflicted,前端区分展示) */
identities: z.array(ExternalIdentity),
/** 元数据来源:metadata 中各字段的来源标注 + 人工锁 */
metadataSources: z.object({ fields: JsonObject, fieldLocks: z.array(z.string()) }),
pathBindings: z.array(PathBinding),
assets: z.array(LibraryAssetView),
slots: z.array(AcquisitionSlot),
candidates: z.array(CandidateEvaluation),
downloads: z.array(LibraryDownloadView),
/**
* 该作品的整理记录(最近 100 条,新→旧):含没有下载任务的手动整理(文件管理按路径整理)。
* 下载历史里已按下载任务列出的记录也在这里,前端按 downloadTaskId 去重展示。
*/
organizationRecords: z
.array(
z.object({
id: Id,
status: OrganizationStatus,
action: OrganizationAction,
trigger: OrganizationTrigger,
downloadTaskId: Id.nullable(),
sourcePath: z.string().nullable(),
targetPath: z.string().nullable(),
fileSucceeded: NonNegInt,
fileTotal: NonNegInt,
createdAt: Timestamp,
finishedAt: Timestamp.nullable(),
}),
)
.optional(),
});
export type LibraryDetail = z.infer<typeof LibraryDetail>;
/** 站点工作台叠加:按作品返回已有版本 / Slot 缺口 / 下载中 */
export const InventoryQuery = z.object({
/** 逗号分隔的作品 UUID,最多 200 个 */
entityIds: z.string().min(1).max(8000),
});
export type InventoryQuery = z.infer<typeof InventoryQuery>;
export const InventoryEntry = z.object({
entityId: Id,
/** 已入库版本(主视频资产的交付特征) */
versions: z.array(
z.object({
assetId: Id,
resolution: z.string().nullable(),
sourceType: z.string().nullable(),
releaseGroup: z.string().nullable(),
contentEdition: z.string().nullable(),
sizeBytes: NonNegInt.nullable(),
available: z.boolean(),
}),
),
/** 未满足的 Slot(empty/attention/upgrade_pending) */
gaps: z.array(z.object({ slotId: Id, name: z.string(), status: SlotStatus })),
downloading: z.array(
z.object({ downloadTaskId: Id, name: z.string(), status: DownloadStatus, progress: z.number().min(0).max(1) }),
),
/** 有生效中的订阅(active) */
subscribed: z.boolean(),
/**
* 候选理由:该作品各 Slot 对来源资源的最近一次评估(每个 (Slot, 资源) 一条,新→旧,最多 50 条)。
* 工作台按 sourceReleaseId 取当前资源"为什么选 / 不选"。
*/
candidateReasons: z.array(
z.object({
slotId: Id,
slotName: z.string(),
sourceReleaseId: Id,
decision: CandidateEvaluation.shape.decision,
/** 拒绝原因 message + 评估说明,已合并去重 */
reasons: z.array(z.string()),
planId: Id.nullable(),
evaluatedAt: Timestamp,
}),
),
});
export type InventoryEntry = z.infer<typeof InventoryEntry>;
export const InventoryResponse = z.object({ items: z.array(InventoryEntry) });
export type InventoryResponse = z.infer<typeof InventoryResponse>;
/** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */
/**
* 按本地作品(用它已接受的外部身份)或按 TMDB 条目(还没建档的作品,SEARCH-2 搜索页/详情页)查 Emby。
* 两者给其一;都给时以 mediaEntityId 为准。
*/
export const MediaServerInventoryQuery = z
.object({
mediaEntityId: Id.optional(),
tmdbType: z.enum(["movie", "tv"]).optional(),
tmdbId: z
.string()
.regex(/^\d{1,12}$/)
.optional(),
})
.refine((q) => !!q.mediaEntityId || (!!q.tmdbType && !!q.tmdbId), {
message: "mediaEntityId 或 tmdbType + tmdbId 必须给一个",
});
export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>;
export const MediaServerInventory = z.object({
/** 按 TMDB 条目查且本地没有建档时为 null */
mediaEntityId: Id.nullable(),
instances: z.array(
z.object({
instanceId: Id,
name: z.string(),
status: z.enum(["present", "not_found", "unknown"]),
reason: z.string().nullable(),
items: z.array(
z.object({
itemId: z.string(),
type: z.string(),
name: z.string(),
year: z.number().int().nullable(),
path: z.string().nullable(),
versions: z.array(JsonObject),
}),
),
observedAt: Timestamp,
}),
),
/** 至少一个实例因查询失败 / 超时为 unknown(没有外部 ID 不算);旧服务端不返回 */
partial: z.boolean().optional(),
});
export type MediaServerInventory = z.infer<typeof MediaServerInventory>;
/** 首页六区块(REQ §13.4.3);统计未知时对应字段为 null 并在 notes 说明 */
export const Dashboard = z.object({
generatedAt: Timestamp,
automation: z.object({
enabled: z.boolean(),
readOnlyReconcile: z.boolean(),
today: z.object({
plansCreated: NonNegInt,
downloadsSubmitted: NonNegInt,
filesImported: NonNegInt,
recordsSucceeded: NonNegInt,
recordsFailed: NonNegInt,
upgrades: NonNegInt,
}),
activeSubscriptions: NonNegInt,
slotCounts: SlotStatusCounts,
/** 版本目标满足率 = satisfied / 全部版本目标(0–1);没有版本目标时为 null */
slotSatisfiedRate: z.number().min(0).max(1).nullable(),
/** 本地账本计数:作品不含已合并;季 = content_unit.kind=season(不含 retired);资产 = active 的视频 / 原盘目录 */
library: z.object({
entities: NonNegInt,
movies: NonNegInt,
series: NonNegInt,
seasons: NonNegInt,
episodes: NonNegInt,
assets: NonNegInt,
}),
}),
currentWork: z.object({
downloading: NonNegInt,
paused: NonNegInt,
stalled: NonNegInt,
importing: NonNegInt,
reservedPlans: NonNegInt,
runningRecords: NonNegInt,
agentRuns: z.array(z.object({ id: Id, goal: z.string(), status: AgentRunStatus })),
items: z.array(
z.object({
downloadTaskId: Id,
name: z.string(),
status: DownloadStatus,
progress: z.number().min(0).max(1),
waitReason: z.string().nullable(),
}),
),
}),
needsAttention: z.object({
total: NonNegInt,
byKind: z.array(z.object({ kind: DecisionKind, count: NonNegInt })),
}),
cache: z.object({
volumes: z.array(
z.object({
volumeKey: z.string(),
instanceIds: z.array(Id),
usedPercent: z.number().min(0).max(100).nullable(),
totalBytes: NonNegInt.nullable(),
usedBytes: NonNegInt.nullable(),
pendingBytes: NonNegInt,
reservedBytes: NonNegInt,
hardThresholdPercent: z.number(),
resumePercent: z.number(),
pausedByThreshold: NonNegInt,
cleanableBytes: NonNegInt,
retentionLockedBytes: NonNegInt,
notes: z.array(z.string()),
}),
),
}),
sources: z.object({
sites: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSnapshotAt: Timestamp.nullable(),
}),
),
feeds: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSuccessAt: Timestamp.nullable(),
consecutiveFailures: NonNegInt,
lastError: z.string().nullable(),
}),
),
downloaders: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSeenAt: Timestamp.nullable(),
}),
),
mediaServers: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSeenAt: Timestamp.nullable(),
}),
),
}),
recentOutput: z.array(
z.object({
recordId: Id,
mediaEntityId: Id.nullable(),
title: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
targetPath: z.string().nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
finishedAt: Timestamp.nullable(),
}),
),
});
export type Dashboard = z.infer<typeof Dashboard>;
/**
* 手动整理 dryRun 冻结的计划 artifact(kind=plan,title=organize_plan)的 content:
* 原请求 + 冻结计划(与 domain OrganizePlan 同形,这里只约束展示字段)。GET /artifacts/{id} 读取。
*/
export const OrganizePlanArtifactContent = z.object({
request: z.record(z.string(), z.unknown()).optional(),
/** 源目录文件数超过上限,计划只含前 N 个文件 */
truncated: z.boolean().optional(),
status: z.string().optional(),
reason: z.string().nullable().optional(),
plan: z
.object({
targetDirs: z.array(z.string()).optional(),
organize: z
.object({
operation: z.string(),
crossDevice: z.boolean(),
requiresApproval: z.boolean(),
files: z.array(
z.object({
src: z.string(),
dst: z.string(),
size: z.number(),
kind: z.string(),
action: z.string(),
skipReason: z.string().optional(),
}),
),
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
conflicts: z.array(
z.object({
kind: z.string(),
blocking: z.boolean(),
src: z.string().optional(),
detail: z.string().optional(),
}),
),
})
.optional(),
})
.nullable()
.optional(),
});
export type OrganizePlanArtifactContent = z.infer<typeof OrganizePlanArtifactContent>;