docs(plan): v0.2 adds Pi subagents and scheduled-jobs board

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-28 02:09:39 +08:00
1 parent b839ed4056
commit 16ab2a6c84
97 files changed
+592 -648

No files matched your search

@@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
for (const dryRun of [true, false]) {
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不在已登记的媒体库根或下载器路径映射之下");
expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下");
}
// `..` 即使规范化后仍在根内也拒绝
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
@@ -240,7 +240,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
// 按下载任务 ID(管理员也一样被做种义务拦下)
const byId = await post({ downloadTaskId: dtId, action: "move" });
expect(byId.status).toBe(409);
expect(byId.body.error.message).toContain("未改为 Copy");
expect(byId.body.error.message).toContain("未改为复制");
// 按路径命中纳管任务的内容
const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv");
expect(existsSync(content)).toBe(true);
@@ -80,13 +80,13 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
const d = deps();
const db = d.ctx.db;
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
if (!slot) throw new CapabilityError("not_found", "收录目标(Slot)不存在");
if (!slot) throw new CapabilityError("not_found", "版本目标不存在");
const rel = await db
.selectFrom("source_release")
.select(["id", "media_entity_id"])
.where("id", "=", input.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw new CapabilityError("not_found", "来源资源不存在");
if (!rel) throw new CapabilityError("not_found", "资源不存在");
const out = await evaluateAcquisition(d, {
sourceReleaseId: input.sourceReleaseId,
acquisitionSlotId: input.slotId,
@@ -111,7 +111,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] };
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划(Slot 忙或条件不满足)" });
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
}
const fresh = await db
.selectFrom("acquisition_slot")
@@ -148,8 +148,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
.where("id", "=", plan.slot_id)
.forUpdate()
.executeTakeFirstOrThrow();
if (slot.revision + 1 !== plan.expected_revision) return "Slot 已变化,请重新生成计划";
if (BUSY.includes(slot.status)) return `Slot 正忙(${slot.status})`;
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
st = transition(st, { type: "reserve" });
@@ -172,7 +172,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return null;
})
.catch((err) => {
if ((err as { code?: string }).code === "23505") return "Slot 已有其它有效预留";
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
throw err;
});
if (ok) throw new CapabilityError("conflict", ok);
@@ -205,7 +205,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
registry.register(capabilitySpec("organize.plan"), async (input, ctx) => {
const d = deps();
if (!input.downloadTaskId)
throw new CapabilityError("precondition", "v0.1 只支持按下载任务(downloadTaskId)生成整理计划");
throw new CapabilityError("precondition", "只支持按下载任务(downloadTaskId)生成整理计划");
const dt = await d.ctx.db
.selectFrom("download_task")
.select(["status"])
@@ -231,9 +231,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
if (!frozen) {
throw new CapabilityError(
"precondition",
res.recordId
? `该下载任务已有进行中的整理记录 ${res.recordId}`
: `无法生成整理计划:${res.reason ?? res.status}`,
res.recordId ? `下载任务已有进行中的整理记录 ${res.recordId}` : `无法生成整理计划:${res.reason ?? res.status}`,
);
}
const entity = await d.ctx.db
@@ -315,7 +313,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
dryRun: true,
});
if (!again.plan || planHash(req, again.plan) !== art.content_hash)
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效,请重新生成整理计划");
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效。重新生成整理计划");
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: taskKey,
@@ -107,7 +107,7 @@ export function createPorts(
return client;
},
async torrentSource({ release, site }) {
if (!site) throw new Error("来源站点不存在,无法取种子");
if (!site) throw new Error("站点不存在,无法获取种子");
const raw = (release.raw ?? {}) as Record<string, unknown>;
const siteMeta = (raw._site ?? {}) as Record<string, unknown>;
const defId = [str(siteMeta.adapterSiteId), site.key].find((k) => k && getSiteDefinition(k));
@@ -121,9 +121,9 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
const dv = decideVolume(v, cache);
const cands = await cleanupCandidates(d, v);
const notes: string[] = [];
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已保守阻止新增下载" : "实例未配置保存路径,无法统计");
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
if (dv.unmanagedGrowthRisk) notes.push("存在未纳管任务仍在增长,暂停纳管任务后磁盘仍可能增长");
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
const pct = usedPercent(v);
volumes.push({
volumeKey: v.volumeKey,
@@ -75,8 +75,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
if (!exists) notFound("下载任务");
const r = await userResume(d, params.id);
if (r === "threshold_active")
throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,阈值暂停不能被手动绕过");
if (r === "threshold_active") throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,不能手动恢复");
return toApi<DownloadTask>(r);
},
{ tag: TAG },
@@ -137,8 +137,8 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
if (block) {
throw new ApiException(
"conflict",
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},` +
`拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},已拒绝移动(未改为复制)。` +
`待处理事项 ${block.decisionId}`,
);
}
await audit(d, actorOf(c), "organize.move_authorized", {
@@ -249,7 +249,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", params.id)
.executeTakeFirst();
if (!sub) notFound("订阅");
if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅尚未关联作品,无法按标题搜索");
if (!sub!.media_entity_id) throw new ApiException("bad_request", "订阅未关联作品,无法搜索");
try {
return await enqueueSubscriptionSearch(d, sub!.id, "manual");
} catch (err) {
@@ -371,7 +371,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.acquisitionSlots.get,
async (_c, { params }) => {
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<AcquisitionSlot>(r ?? notFound("收录目标"));
return toApi<AcquisitionSlot>(r ?? notFound("版本目标"));
},
{ tag: TAG },
);
@@ -404,7 +404,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!r) conflict("同一作品/内容单元下已有同名 Slot");
if (!r) conflict("该作品或内容单元下已有同名版本目标");
await kickEvaluate(d, { acquisitionSlotId: r!.id });
return toApi<AcquisitionSlot>(r!);
},
@@ -439,7 +439,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.select("revision")
.where("id", "=", params.id)
.executeTakeFirst();
if (!exists) notFound("收录目标");
if (!exists) notFound("版本目标");
conflict(`revision 不匹配(当前 ${exists!.revision})`);
}
await kickEvaluate(d, { acquisitionSlotId: params.id });
@@ -523,7 +523,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
async (_c, { params, body }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.status !== "draft") conflict("只有草案可以修改;已发布版本请新建草案");
if (cur!.status !== "draft") conflict("只能修改草案。要改已发布版本,新建草案");
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
const r = await db()
.updateTable("policy_revision")
@@ -670,7 +670,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst();
if (sup) {
// Agent 不得据旧证据自动加回;用户手动(或对话确认)重新添加即解除抑制
if (origin === "agent_inferred") conflict("该偏好已被用户取消(存在抑制记录),Agent 不能自动加回");
if (origin === "agent_inferred") conflict("用户已取消该偏好,Agent 不能自动加回");
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p);
}
if (!roots.some((root) => within(r, root)))
throw new PathGuardError(`源路径不在已登记的媒体库根或下载器路径映射之下: ${p}`, p);
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下: ${p}`, p);
return r;
},
};
@@ -205,7 +205,7 @@ export async function authorizeMoveEntities(
const req = ((a.content ?? {}) as { request?: Parameters<typeof requestKey>[0] }).request;
if (!req || requestKey(req) !== key) continue;
if (a.payload_hash !== a.content_hash) {
reasons.push(`审批 ${a.id} 绑定的计划内容已变化`);
reasons.push(`审批 ${a.id} 对应的计划内容已变化`);
continue;
}
// 预检(不消费):执行阶段会再调一次并原子消费
@@ -249,7 +249,7 @@ export async function authorizeMoveEntities(
else
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
why.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
why.push(`授权 ${g.id} 的策略版本已不是发布状态`);
continue;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
@@ -280,7 +280,7 @@ export async function authorizeMoveEntities(
unauthorized.push({
label: s.label,
entityId: null,
reason: "无法确定所属作品,持续授权无从核对范围(只接受一次性审批)",
reason: "无法确定所属作品,只能用一次性审批",
});
continue;
}
@@ -306,8 +306,8 @@ export async function authorizeMoveEntities(
unauthorized,
reason:
detail.length > 0 && (reasons.length > 0 || grants.length > 0 || !single || unauthorized[0]?.entityId === null)
? `Move 需要授权:${detail.join(";")}`
: "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
? `移动需要授权:${detail.join(";")}`
: "移动会删除源文件和下载器任务,需要管理员会话,或覆盖该作品的持续授权 / 一次性审批",
};
}
@@ -339,11 +339,11 @@ export async function hnrBlockForMove(
if (hnr === true) continue;
const decisionId = await upsertDecision(d, db, {
kind: "approval_required",
title: `手动 Move 被做种义务阻止:${dt.name ?? dt.torrent_hash}`,
title: `做种义务未满足,已阻止移动:${dt.name ?? dt.torrent_hash}`,
summary:
hnr === false
? "来源是纳管下载任务,H&R / 做种要求尚未满足;Move 会删源并移除任务,已拒绝(未改为 Copy)"
: "来源是纳管下载任务,H&R 要求未知,按未满足处理;Move 已拒绝(未改为 Copy)",
? "H&R / 做种要求未满足,移动会删除源文件和下载任务,已拒绝(未改为复制)"
: "H&R 要求未知,按未满足处理,已拒绝移动(未改为复制)",
subjectType: "download_task",
subjectId: dt.id,
dedupeKey: `manual_move_hnr:${dt.id}`,
@@ -356,8 +356,8 @@ export async function hnrBlockForMove(
requestedBy: ctx.actor,
},
options: [
{ key: "wait", label: "等待做种义务满足后再 Move" },
{ key: "copy", label: "改用 Copy 入库(需重新发起)" },
{ key: "wait", label: "做种满足后再移动" },
{ key: "copy", label: "改用复制(需重新发起)" },
],
});
return { downloadTaskId: dt.id, hnr, decisionId };
@@ -327,7 +327,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "identity_ambiguous",
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
summary: "下载内容没有关联本地作品,需要先识别/指定作品再整理",
summary: "下载内容未关联作品。识别或指定作品后再整理",
subjectType: "organization_record",
subjectId: rid.recordId,
dedupeKey: `organize_identity:${dt?.id ?? rid.recordId ?? "x"}`,
@@ -387,8 +387,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "other",
severity: "critical",
title: `媒体库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),入库暂停;不会因此判定文件缺失`,
title: `库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
subjectType: "library_root",
subjectId: root.id,
dedupeKey: `library_root_offline:${root.id}`,
@@ -440,7 +440,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "path_conflict",
title: `作品存在多个历史目录:${entity.title}`,
summary: "不按最新标题自动合并,请指定保留的目录",
summary: "不会按最新标题自动合并。指定要保留的目录",
subjectType: "media_entity",
subjectId: entity.id,
dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`,
@@ -622,7 +622,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const hnrConflict = blocking.find((c) => c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown");
await upsertDecision(d, trx, {
kind: hnrConflict ? "approval_required" : "path_conflict",
title: hnrConflict ? `Move 入库被做种义务阻止:${entity.title}` : `整理冲突:${entity.title}`,
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
summary: blocking
.map((c) => c.detail ?? c.kind)
.join(";")
@@ -633,10 +633,10 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
evidence: { conflicts: blocking, action },
options: hnrConflict
? [
{ key: "wait", label: "等待做种义务满足后再 Move" },
{ key: "copy", label: "授权改用 Copy 入库" },
{ key: "wait", label: "做种满足后再移动" },
{ key: "copy", label: "改用复制" },
]
: [{ key: "review", label: "查看冲突并指定处理方式" }],
: [{ key: "review", label: "指定处理方式" }],
});
if (slotId) await applySlotEvent(d, trx, slotId, { type: "verify_failed" }, { reason: reason ?? "blocked" });
}
@@ -750,7 +750,7 @@ export async function executeRecord(
const fsr = fsRuntime(d);
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
if (health.kind === "offline") throw new Error(`媒体库根离线(${health.reason}),稍后重试`);
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
if (fsr.partialMaxAgeMs > 0)
@@ -910,13 +910,13 @@ export async function executeRecord(
if (outcome.status === "failed" && outcome.error && fsErrorClass(outcome.error.code) === "retry") {
await upsertDecision(d, db, {
kind: "other",
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,重试已达上限" : "文件读写失败,重试已达上限",
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载保留,不重下)`,
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${outcome.attempt ?? fsr.maxAttempts} 次;记录与下载已保留,不会重新下载)`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `fs_retry_exhausted:${opId}`,
evidence: { src: pf.src, dst: pf.dst, error: outcome.error, attempts: outcome.attempt ?? null },
options: [{ key: "retry", label: "检查挂载后重新执行整理" }],
options: [{ key: "retry", label: "检查挂载后重试" }],
});
}
// 被替换的旧文件登记进文件管理回收站(可在回收站视图还原/清除);登记失败只告警,旧文件留在 .umt-trash
@@ -927,7 +927,7 @@ export async function executeRecord(
if (outcome.replaced?.disposition === "left") {
await upsertDecision(d, db, {
kind: "other",
title: "替换入库后旧文件未能处置",
title: "新版本已入库,旧文件未能处置",
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
subjectType: "organization_record",
subjectId: recordId,
@@ -938,7 +938,7 @@ export async function executeRecord(
if (outcome.status === "attention") {
await upsertDecision(d, db, {
kind: "other",
title: "跨卷搬运后删源失败",
title: "跨卷移动后删除源文件失败",
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
subjectType: "organization_record",
subjectId: recordId,
@@ -1168,7 +1168,7 @@ async function transferFile(
if (!x.allowCrossVolume)
return {
status: "failed",
error: { code: "EXDEV", message: "跨卷 Move 未启用(settings.organize.allowCrossVolumeMove)" },
error: { code: "EXDEV", message: "未启用跨卷移动(settings.organize.allowCrossVolumeMove)" },
};
const r = await px.crossDeviceMove(src, dst, { hash: d.ports.options.verifyHash }, call);
const failedStep = r.steps.find((st) => st.status === "failed");
@@ -1469,7 +1469,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
kind: "other",
severity: "info",
title: "旧版本待处置",
summary: "新版本已核验入库;旧资产保留,按媒体保留策略另行处理",
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
subjectType: "media_asset",
subjectId: old,
dedupeKey: `old_version:${old}`,
@@ -464,8 +464,8 @@ export async function afterSlotVerified(
await upsertDecision(d, trx, {
kind: "other",
severity: "info",
title: "疑似缺集(总集数未知,继续追踪)",
summary: `已入库的最高集以下仍缺:${desc}`.slice(0, 500),
title: "疑似缺集",
summary: `已入库最高集之前缺:${desc}。总集数未知,订阅继续追踪`.slice(0, 500),
subjectType: "acquisition_slot",
subjectId: slot.id,
dedupeKey: `suspected_missing:${slot.id}`,