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}`,
+2 -2
View File
@@ -109,8 +109,8 @@ export function createAuthorizer(deps: {
// 自动化总开关 / 只读对账:自动来源(agent / 后台任务)一律不执行写操作
if (ctx.source !== "web" && d.kind === "execute") {
const a = await deps.settings.get("automation");
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,自动执行被拒绝" };
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式,自动执行被拒绝" };
if (!a.enabled) return { allowed: false, reason: "自动化总开关已关闭,不自动执行" };
if (a.readOnlyReconcile) return { allowed: false, reason: "只读对账模式下不自动执行" };
}
if (ctx.source === "web" && /^(user|admin):/.test(ctx.actorId)) return { allowed: true };
@@ -79,7 +79,7 @@ export const DEMO_SCRIPTS: FakeScript[] = [
finalText: (results) => {
const r = results.find((x) => x.capabilityId === "inventory.query");
const n = (r?.json as { items?: unknown[] } | undefined)?.items?.length ?? 0;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不会提交下载;需要真实执行请在设置里配置 Pi 上游。`;
return `[fake] 演示模式:库存查询返回 ${n} 个作品。演示模型不提交下载;要真实执行,在设置里配置 Agent 上游。`;
},
},
];
+9 -10
View File
@@ -126,11 +126,10 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
}
function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous")
return `${reason}。建议在待处理中心人工选择身份,或发布识别策略授权 identity.resolve。`;
if (d.kind === "identity_ambiguous") return `${reason}。建议在待处理中人工选择身份,或为 identity.resolve 发放授权。`;
return (
`${reason}。建议:在待处理中心选择"强制重新校验"(download.recheck),或为自动恢复发放持续授权` +
`(action=download.recheck / task.retry,可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
`${reason}。建议在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权` +
`(可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
);
}
@@ -234,9 +233,9 @@ export async function autoInvestigate(
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
taskId ? `关联任务 ${taskId}。` : "",
mode === "recover"
? "在已授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
: "只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。",
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join("");
const { run, created } = await startRun(ctx, {
goal,
@@ -382,14 +381,14 @@ export async function applyRecoveryOutcome(
outcome === "recovered"
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
: outcome === "failed"
? `未恢复:执行 ${what} 后业务状态仍为失败,已交还待处理中心。`
? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。`
: outcome === "unverified"
? `未恢复:执行 ${what} 后在核实时限内未见业务状态恢复,已交还待处理中心。`
? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。`
: outcome === "budget_exceeded"
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已交还待处理中心。`
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。`
: outcome === "cancelled"
? "未恢复:运行已取消。"
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已交还待处理中心。`;
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`;
const evidence: Partial<AutoRecoverEvidence> = {
runId,
outcome,
@@ -31,13 +31,13 @@ export function externalUpstream(capabilityId: string, input: unknown): Upstream
export class UpstreamBusyError extends Error {
constructor(readonly upstream: Upstream) {
super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,请稍后重试`);
super(`${upstream === "tmdb" ? "TMDB" : "站点"}并发已满,稍后重试`);
}
}
export class UpstreamTimeoutError extends Error {
constructor(readonly upstream: Upstream) {
super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s),已放弃等待`);
super(`调用${upstream === "tmdb" ? " TMDB" : "站点"}超时(${EXTERNAL_INVOKE_TIMEOUT_MS / 1000}s)`);
}
}
+2 -2
View File
@@ -102,10 +102,10 @@ export async function buildGoalReport(
const done = records.filter((r) => r.status === "succeeded" || r.status === "partial");
const summary =
state === "no_actions"
? "本次运行没有提交下载或整理;结论见模型汇报(未产生可核查的业务变更)。"
? "本次没有提交下载或整理,结论见模型汇报。"
: [
`已提交下载 ${submitted} 个,已入库 ${imported} 个,进行中 ${inProgress} 个,失败 ${failed} 个。`,
state === "waiting" ? "已提交≠已入库:仍在等待下载完成与核验入库。" : "",
state === "waiting" ? "已提交≠已入库:等待下载完成并核验入库。" : "",
done.length ? `整理记录:${done.map((r) => `${r.id}(${r.status})`).join("、")}。` : "",
]
.filter(Boolean)
+6 -7
View File
@@ -227,7 +227,7 @@ export function registerAgentRoutes(
const cur = await db().selectFrom("automation_grant").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) throw new ApiException("not_found", "授权不存在");
if (body.expiresAt && cur.expires_at && new Date(body.expiresAt) > cur.expires_at)
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围请新建授权");
throw new ApiException("validation_failed", "只能缩短有效期;扩大范围需新建授权");
const r = await db()
.updateTable("automation_grant")
.set({
@@ -302,7 +302,7 @@ export function registerAgentRoutes(
.executeTakeFirst();
if (!cur) throw new ApiException("not_found", "审批不存在");
if (cur.payload_hash !== body.payloadHash)
throw new ApiException("conflict", "payloadHash 不一致:计划已变化,请重新申请审批");
throw new ApiException("conflict", "计划已变化(payloadHash 不一致),重新申请审批");
const allowed = body.status === "revoked" ? ["pending", "approved"] : ["pending"];
if (!allowed.includes(cur.status)) throw new ApiException("conflict", `审批已是 ${cur.status}`);
return trx
@@ -361,16 +361,15 @@ export function registerAgentRoutes(
.select(["id", "media_entity_id"])
.where("id", "=", body.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw new ApiException("not_found", "来源资源不存在");
if (!rel) throw new ApiException("not_found", "资源不存在");
let slotIds: string[];
if (body.slotId) {
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
if (!s) throw new ApiException("not_found", "收录目标(Slot)不存在");
if (!s) throw new ApiException("not_found", "版本目标不存在");
slotIds = [s.id];
} else {
const entityId = body.entityId ?? rel.media_entity_id;
if (!entityId)
throw new ApiException("precondition_failed", "来源资源尚未识别到本地作品,请先在“身份与元数据”指定作品");
if (!entityId) throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在“身份与元数据”指定作品");
slotIds = (
await db()
.selectFrom("acquisition_slot")
@@ -382,7 +381,7 @@ export function registerAgentRoutes(
}
const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标(Slot),请先在订阅里建目标" });
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标,先在订阅里新建" });
const invokeCtx = {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
+6 -7
View File
@@ -123,7 +123,7 @@ async function agentSettings(
const pi = s.pi;
let apiKey = "fake-key-not-used";
if (!fake) {
if (!pi.apiKeySecretRef) throw new Error("未配置 Pi 上游密钥(设置 → pi.apiKey)");
if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游密钥(设置 → pi.apiKey)");
apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef);
if (!pi.model) throw new Error("未配置模型(设置 → pi.model)");
}
@@ -250,17 +250,16 @@ export async function reconcilePending(
function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
const constraints: string[] = [
`可用能力:${scope.capabilities.join(", ")}`,
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒绝时如实说明缺少哪项授权。",
"有副作用的能力(提交下载、执行整理、发布策略、重试/取消任务)需要服务端授权;被拒时说明缺少哪项授权。",
];
if (run.mode === "plan_only")
constraints.push("本次为“先给方案”:只调查与规划,不提交下载、不做文件变更、不发布策略。");
if (run.mode === "plan_only") constraints.push("本次只给方案:只调查与规划,不提交下载、不改文件、不发布策略。");
if (run.trigger === "event" && run.mode === "execute")
constraints.push(
"本任务由后台事件触发,是有界的授权内恢复:先查证据,再只在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
"每种恢复动作最多一次;不换源、不删除、不清理;恢复是否成功由系统按业务状态核实,不要自行宣称已恢复。",
"本任务由后台事件触发,只做授权内的有界恢复:先查证据,再在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
"每种动作最多一次;不换源、不删除、不清理;是否恢复由系统按业务状态核实,不要自行宣称已恢复。",
);
else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,是有界的只读调查:收集证据、给出结论与建议,不要反复重试同一操作。");
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手",
goal: run.goal,
+1 -1
View File
@@ -333,7 +333,7 @@ export async function autoInvestigate(
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
d.summary ? `摘要:${d.summary}。` : "",
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
"只做有界的只读调查:查证据、给出结论与处理建议;不要提交下载或变更文件。",
"只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join("");
const { run, created } = await startRun(ctx, {
goal,
@@ -136,7 +136,7 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
endpoint(app, D.password, async ({ body, c, actor }) => {
const session = c.get("session");
if (!session) throw new ApiException("forbidden", "该操作只能在控制台登录后进行");
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
const revoked = await changePassword(deps(), body, {
adminUserId: session.adminUserId,
sessionId: session.id,
@@ -103,7 +103,7 @@ async function recentFailures(db: Kysely<DB>, col: "ip" | "username", value: str
export async function assertNotRateLimited(db: Kysely<DB>, ip: string, username: string): Promise<void> {
const [byIp, byAccount] = await Promise.all([recentFailures(db, "ip", ip), recentFailures(db, "username", username)]);
if (byIp >= MAX_FAILURES_PER_IP || byAccount >= MAX_FAILURES_PER_ACCOUNT) {
throw new ApiException("rate_limited", "失败次数过多,请 15 分钟后再试");
throw new ApiException("rate_limited", "失败次数过多,15 分钟后再试");
}
}
@@ -190,7 +190,7 @@ export async function setupAdmin(
const admin = await loadAdmin(deps.db);
if (admin?.password_hash) throw new ApiException("conflict", "管理员已初始化");
await recordAttempt(deps.db, { ip, username: "(setup)", success: false, reason: "bad_setup_token" });
throw new ApiException("unauthorized", "setup token 无效(以服务端日志最新打印的为准)");
throw new ApiException("unauthorized", "setup token 无效。使用服务端日志里最新打印的那个");
}
const admin = await loadAdmin(deps.db);
if (!admin) throw new Error("admin_user 行丢失");
@@ -206,7 +206,7 @@ export async function verifyLogin(
): Promise<LoginOutcome> {
await assertNotRateLimited(deps.db, ip, input.username);
const admin = await loadAdmin(deps.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化,请先完成初始化");
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
const userOk = admin.username === input.username;
// 用户名不对也跑一次 argon2,避免用时差探测账号
let pwOk = false;
@@ -284,7 +284,7 @@ export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri:
}
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "请先生成 TOTP 密钥");
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥");
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
const counter = verifyTotp(secret, code, null);
if (counter === null) throw new ApiException("unauthorized", "验证码错误");
@@ -68,7 +68,7 @@ const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"]
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
const LR = crudDefs("/library-roots", "媒体库根", {
const LR = crudDefs("/library-roots", "库根", {
row: LibraryRoot,
create: LibraryRootCreate,
update: LibraryRootUpdate,
@@ -174,7 +174,7 @@ export function registerKeyRoutes(app: ModuleApp, ctx: ServerContext) {
.where("id", "=", params.id)
.returning("id")
.executeTakeFirst()
.catch((e) => pgConflict(e, "凭证仍被引用(实例/站点账号/Feed/设置),请先解除引用"));
.catch((e) => pgConflict(e, "凭证仍被实例、站点账号、Feed 或设置引用,先解除引用"));
if (!r) throw new ApiException("not_found", "凭证不存在");
await audit(ctx, actor, "secret.delete", { type: "secret", id: params.id });
return { ok: true as const };
@@ -122,7 +122,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `无权限访问(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
}
@@ -233,7 +233,7 @@ export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHe
export async function onlineRoot(d: FmDeps, def: RootDef): Promise<string> {
const h = await probeRoot(def, d.options.healthTimeoutMs);
if (h.health !== "healthy" || !h.real)
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health}),不显示为空目录`);
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health})`);
return h.real;
}
@@ -288,7 +288,7 @@ export async function resolveIn(
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`);
}
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接逃出根: ${rel}`);
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外: ${rel}`);
const abs = join(parentReal, basename(joined));
let lst: Awaited<ReturnType<typeof lstat>> | null = null;
try {
@@ -302,7 +302,7 @@ export async function resolveIn(
if (isSymlink) {
const target = await realpath(abs).catch(() => null);
if (!target || !within(target, rootReal))
throw new FilesError("path_escape", `符号链接指向根外或无法解析,已禁用操作: ${rel}`);
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作: ${rel}`);
}
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
}
@@ -316,7 +316,7 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
throw new FilesError("plan_stale", `源已不存在: ${abs}`);
}
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时发现路径逃出根(符号链接被替换?): ${abs}`);
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外: ${abs}`);
return r;
}
@@ -43,13 +43,12 @@ export async function downloadFile(
): Promise<Response> {
// 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御)
if (!isAdminSession(principal))
throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key / DEV key 不可用)");
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind))
throw new FilesError("forbidden", `只允许下载 NFO/字幕/图片(${kind}),视频等大文件不经浏览器下载`);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
@@ -156,7 +156,7 @@ export async function identify(
if (remoteQueries >= REMOTE_QUERY_MAX) {
res = {
list: [],
warning: `本次识别的远端查询已达上限 ${REMOTE_QUERY_MAX} 个,该项仅本地召回;可缩小范围后再识别`,
warning: `远端查询已达上限 ${REMOTE_QUERY_MAX} 次,此项只查了本地。缩小范围后重新识别`,
};
remoteCache.set(key, res);
return res;
@@ -193,7 +193,7 @@ export async function identify(
}));
res = { list, warning: null };
} catch (e) {
res = { list: [], warning: `远端候选不可用(仅本地召回):${errMessage(e).slice(0, 200)}` };
res = { list: [], warning: `远端查询失败,只查了本地:${errMessage(e).slice(0, 200)}` };
}
remoteCache.set(key, res);
return res;
@@ -44,13 +44,12 @@ export async function loadPlan<C>(d: FmDeps, planId: string, op: string, request
.select(["id", "content", "content_hash", "expires_at", "title", "kind"])
.where("id", "=", planId)
.executeTakeFirst();
if (row?.kind !== "plan" || row.title !== "files_plan")
throw new FilesError("plan_stale", "planId 不存在,请重新预览");
if (row?.kind !== "plan" || row.title !== "files_plan") throw new FilesError("plan_stale", "planId 不存在,重新预览");
const content = row.content as StoredPlan<C>["content"];
if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`);
if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime())
throw new FilesError("plan_stale", "计划已过期,请重新预览");
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求内容与预览时不一致,请重新预览");
throw new FilesError("plan_stale", "计划已过期,重新预览");
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求与预览时不一致,重新预览");
return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at };
}
@@ -101,7 +100,7 @@ export async function idempotent<T>(
if (!row) return undefined;
const c = row.content as { reqHash: string; state: string; result?: T };
if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求");
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
return c.result as T;
};
const prev = await replay();
@@ -116,7 +115,7 @@ export async function idempotent<T>(
if (!id) {
const again = await replay();
if (again !== undefined) return again;
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,稍后重试");
}
try {
const result = await fn();
@@ -16,7 +16,7 @@ const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
function assertCleanName(name: string) {
if (sanitizeSegment(name) !== name)
throw new FilesError("bad_request", `文件名含非法字符或首尾空白/点,建议:${sanitizeSegment(name)}`);
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
}
export async function mkdirIn(
@@ -56,8 +56,8 @@ export async function renameEntry(
body: FilesRenameRequest,
): Promise<FilesRenameResult> {
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "不能改名根目录");
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 为只读登记(register_only)`);
if (r.rel === "") throw new FilesError("bad_request", "根目录不能重命名");
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 只读,不能重命名`);
assertCleanName(body.newName);
const oldName = basename(r.abs);
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
@@ -68,7 +68,7 @@ export async function renameEntry(
if (hits.length > 0)
throw new FilesError(
"download_active",
`该路径属于纳管下载任务(${hits.map((h) => h.taskId).join(", ")}),改名会让下载器找不到文件;请先完成整理或移交`,
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
);
const dirAbs = dirname(r.abs);
@@ -124,10 +124,10 @@ export async function renameEntry(
const before = await lstat(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `改名失败(${res.status}): ${p.from}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs);
if (after.ino !== before.ino || after.dev !== before.dev)
throw new FilesError("bad_request", `改名后 inode 不一致,已中止: ${p.to}`);
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止: ${p.to}`);
done.push(p);
} catch (e) {
await rollback();
@@ -126,7 +126,7 @@ export async function organizeFiles(
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`);
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
roots.add(it.root);
const isDir = !!r.lst?.isDirectory();
const kind = entities.get(it.mediaEntityId);
@@ -173,10 +173,10 @@ export async function organizeFiles(
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== it.mediaEntityId) {
warnings.push(`${r.rel}: Slot ${slotId} 不属于该作品,未应用`);
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}: Slot 正在获取中(${s.status}),不改其状态`);
warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
@@ -313,7 +313,7 @@ export async function organizeFiles(
const again = await planImport(d.ao, importReq(true));
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览");
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (g.action === "move") {
@@ -329,7 +329,7 @@ export async function organizeFiles(
if (block)
throw new FilesError(
"download_active",
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
);
}
await retirePlan(d, planId);
@@ -415,6 +415,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<
.where("title", "=", "files_plan")
.execute();
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,请重新预览");
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
return hit.id;
}
@@ -117,7 +117,7 @@ export async function scrape(
.where("media_entity_id", "=", e.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!registered) throw new FilesError("bad_request", "作品还没有已登记资产;未登记文件请先识别并整理入库,再刮削");
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: e.id },
@@ -95,17 +95,16 @@ export async function transfer(
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`);
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "不能移动/复制根目录本身");
if (mode === "move" && !s.def.modifiable)
throw new FilesError("not_writable", `源根 ${s.def.name} 为只读登记,不能移动(可复制)`);
if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制");
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不移动符号链接本身: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
@@ -149,7 +148,7 @@ export async function transfer(
const reason = planned.reason ?? "plan_failed";
throw new FilesError(
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
`无法生成搬运计划:${reason}`,
`无法生成计划:${reason}`,
);
}
const planFiles = planned.plan.organize.files;
@@ -190,9 +189,9 @@ export async function transfer(
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
const warnings: string[] = [];
if (crossBlocked) warnings.push("跨卷移动未开启(设置 → 整理 → 允许跨卷 Move),执行会被拒绝;可改用复制");
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,请分批操作");
if (truncated) warnings.push("源文件数超过上限,分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
if (body.dryRun) {
@@ -219,19 +218,19 @@ export async function transfer(
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
}
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标已变化),请重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作");
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (crossBlocked)
throw new FilesError(
"bad_request",
"跨卷移动未开启(settings.organize.allowCrossVolumeMove),请改用复制或开启后重试",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
);
if (mode === "move") {
if (hnrBlocks.length > 0)
throw new FilesError(
"download_active",
`源属于纳管下载任务(${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`,
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
);
if (!isAdmin(principal)) {
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
@@ -273,7 +272,7 @@ export async function transfer(
}
if (!rec.recordId || rec.status === "failed") {
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
throw new FilesError("bad_request", `搬运计划不可执行:${rec.reason ?? rec.status}`);
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
}
const recordId = rec.recordId;
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
+12 -12
View File
@@ -155,13 +155,13 @@ const BLOCK_ERR: Record<
NonNullable<FileDeleteCheck["blocked"]>,
[ConstructorParameters<typeof FilesError>[0], string]
> = {
download_active: ["download_active", "属于纳管下载任务(未完成或做种义务未满足)"],
is_root: ["bad_request", "不能删除根本身或只读登记根下的内容"],
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
root_unavailable: ["root_unavailable", "根不可用,不当作已删除"],
root_unavailable: ["root_unavailable", "根不可用"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径逃出根"],
escape: ["path_escape", "路径指向根外"],
};
export async function deleteFiles(
@@ -171,7 +171,7 @@ export async function deleteFiles(
): Promise<FilesDeleteResult> {
if (body.mode === "permanent") {
if (!d.options.allowPermanentDelete)
throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站");
throw new FilesError("permanent_disabled", "未开启永久删除(FILES_ALLOW_PERMANENT_DELETE)。改用回收站");
if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
@@ -276,8 +276,8 @@ export async function deleteFiles(
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `Slot 满足资产已被删除:${s.name}`,
summary: "文件管理删除了满足该 Slot 的资产(进回收站或永久删除),Slot 退回 attention,请确认是否重新获取",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
@@ -396,7 +396,7 @@ async function moveToTrash(
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes};请使用永久删除`,
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
@@ -487,10 +487,10 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
throw e;
});
if (st?.isSymbolicLink())
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,拒绝`);
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
if (!st) await mkdir(cur);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径逃出根: ${rel}`);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外: ${rel}`);
}
}
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
@@ -509,7 +509,7 @@ export async function restoreTrash(
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写(仅 managed 库根可作为目标)`);
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
@@ -547,7 +547,7 @@ export async function restoreTrash(
if (bytes > d.options.trashCrossMaxBytes)
throw new FilesError(
"too_large",
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes};请在同卷还原,或手工处理 ${row.trash_path}`,
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
);
await copyTree(d, row.trash_path, dst, files);
await rm(row.trash_path, { recursive: true });
@@ -107,7 +107,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
.selectAll()
.where("id", "=", input.sourceReleaseId)
.executeTakeFirst();
if (!row) throw new Error("来源资源不存在");
if (!row) throw new Error("资源不存在");
// 库存(media_asset)归 W2-C,这里只按作品只读查询
const inventory = row.media_entity_id
? await r.ctx.db
@@ -130,7 +130,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
.executeTakeFirst();
srId = o?.source_release_id ?? null;
}
if (!srId) throw new Error("找不到来源资源");
if (!srId) throw new Error("资源不存在");
const actor = { type: "agent" as const, id: ctx.actorId };
const c = input.candidate;
if (c?.mediaEntityId || (c?.provider && c.entityType && c.externalId)) {
@@ -157,7 +157,7 @@ export const D = {
sourceReleases: {
list: list(
"/source-releases",
"站点资源列表",
"资源列表",
SourceRelease,
{
siteId: Id.optional(),
@@ -167,7 +167,7 @@ export const D = {
},
TR,
),
get: get("/source-releases/{id}", "站点资源详情", SourceRelease, TR),
get: get("/source-releases/{id}", "资源详情", SourceRelease, TR),
},
observations: {
list: list(
@@ -129,7 +129,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.where("source_release_id", "=", d.subject_id)
.executeTakeFirst();
if (!(pool?.candidates as PoolCandidate[] | undefined)?.[opt.input.candidateIndex]) {
throw new ApiException("conflict", "候选已变化,请刷新后重试");
throw new ApiException("conflict", "候选已变化,刷新后重试");
}
await assignReleaseIdentity(
rt(),
@@ -521,7 +521,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
});
route(app, D.sourceReleases.get, async ({ params }) => {
const row = await db().selectFrom("source_release").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!row) throw new ApiException("not_found", "来源资源不存在");
if (!row) throw new ApiException("not_found", "资源不存在");
return toApi(row);
});
route(
@@ -530,7 +530,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/source-releases/{id}/identity",
scope: "organize.execute",
summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)",
summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)",
tags: T_RELEASES,
params: IdParam,
body: ReleaseIdentityAssign,
@@ -544,7 +544,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "post",
path: "/source-releases/{id}/unlock",
scope: "organize.execute",
summary: "解除来源资源的人工锁并重新识别",
summary: "解除资源的人工锁并重新识别",
tags: T_RELEASES,
params: IdParam,
response: Accepted,
@@ -556,7 +556,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.where("id", "=", params.id)
.returning(["id", "site_id"])
.executeTakeFirst();
if (!row) throw new ApiException("not_found", "来源资源不存在");
if (!row) throw new ApiException("not_found", "资源不存在");
await audit(db(), actor, "identity.release_unlocked", { type: "source_release", id: params.id });
const r = await rt().ctx.engine.enqueue(
"resolveObservation",
@@ -17,7 +17,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/sites/{id}/releases",
scope: "read",
summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
tags: T,
params: z.object({ id: Id }),
query: WorkbenchReleaseQuery,
@@ -31,7 +31,7 @@ export function workbenchRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
method: "get",
path: "/sites/{id}/releases/{rid}",
scope: "read",
summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
tags: T,
params: z.object({ id: Id, rid: Id }),
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
@@ -209,14 +209,14 @@ export async function ingestFeed(
kind: "source_unreachable",
severity: "warning",
title: `Feed「${feed.name}」凭证失效`,
summary: "RSS 返回 401/403 或过期提示;已停止自动重试。请更新 Feed 链接或站点账号凭证后手动恢复。",
summary: "RSS 返回 401/403 或过期提示,已停止自动重试。更新 Feed 链接或站点账号凭证后恢复抓取。",
subjectType: "feed_source",
subjectId: feed.id,
dedupeKey: `feed-auth:${feed.id}`,
evidence: { feedId: feed.id, siteId: feed.site_id, siteAccountId: feed.site_account_id, error: lastError },
options: [
{ key: "reset", label: "已更新凭证,恢复抓取" },
{ key: "disable", label: "停用该 Feed" },
{ key: "disable", label: "停用 Feed" },
],
});
out = { ...out, status: "auth_failed", reason: "auth" };
@@ -64,7 +64,7 @@ export async function assignReleaseIdentity(
): Promise<{ mediaEntityId: string; contentUnitIds: string[] }> {
const { ctx } = rt;
const release = await ctx.db.selectFrom("source_release").selectAll().where("id", "=", srId).executeTakeFirst();
if (!release) throw new ApiException("not_found", "来源资源不存在");
if (!release) throw new ApiException("not_found", "资源不存在");
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const site = ((release.raw as Record<string, unknown>)?._site ?? {}) as { categoryName?: string };
const variety = looksLikeVariety(parsed, site.categoryName ?? release.category_raw);
@@ -241,7 +241,7 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
if (!id) throw new ApiException("not_found", "作品不存在");
const other = await entityByExternal(trx, input);
if (other && other !== id) {
throw new ApiException("conflict", `该外部身份已属于作品 ${other},如为重复作品请使用合并`);
throw new ApiException("conflict", `该外部身份已属于作品 ${other}。如果是重复作品,改用合并`);
}
if (other === id) return { mediaEntityId: id, changed: false };
const olds = await trx
@@ -557,7 +557,7 @@ async function toPool(
kind: "identity_ambiguous",
severity: "info",
title: `待确认身份:${release.title}`,
summary: `${candidates.length} 个候选证据等级相近(${detail}),请选择或忽略`,
summary: `${candidates.length} 个候选证据等级相近(${detail})。选择一个或忽略`,
subjectType: "source_release",
subjectId: release.id,
dedupeKey: `identity:${release.id}`,
@@ -85,7 +85,7 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
kind: "source_unreachable",
severity: "warning",
title: `站点「${site.name}」登录失效`,
summary: `${e.kind}:${e.message}。已停止该站自动访问,请更新账号凭证后恢复。`,
summary: `${e.kind}:${e.message}。已停止自动访问该站点。更新账号凭证后恢复访问。`,
subjectType: "site",
subjectId: site.id,
dedupeKey: `site-auth:${site.id}`,