fix(organize): preserve requests and verify identity recovery results

This commit is contained in:
root committed 2026-10-13 15:49:00 +08:00
1 parent 041d59405c
commit d196235418
8 files changed
+483 -70

No files matched your search

@@ -497,6 +497,30 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.execute();
}
}
// 按路径恢复没有下载任务,用原记录关联关闭身份事项;部分成功仍保留未完成文件的处理状态。
if (status === "succeeded" && frozen.retryOfRecordId) {
const closed = await trx
.updateTable("decision")
.set({
status: "resolved",
resolved_by: "system:organize",
resolved_at: d.ports.now(),
resolution: jsonb({ key: "organize_retry", note: "整理成功", organizationRecordId: rec.id }),
})
.where("subject_type", "=", "organization_record")
.where("subject_id", "=", frozen.retryOfRecordId)
.where("dedupe_key", "like", "organize_identity:%")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.returning("id")
.execute();
for (const item of closed)
await d.ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: item.id, status: "resolved" },
{ idempotencyKey: `decision.updated:${item.id}:resolved` },
);
}
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
if (!dt && frozen.slotId && okAny) {
const slot = await trx
@@ -55,6 +55,7 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
import { resolveOrganizeSource } from "./organize-config.ts";
import { PathGuardError } from "./organize-guard.ts";
import { successfulOrganizeCounts } from "./organize-history.ts";
import { storedImportRequest } from "./organize-retry.ts";
import { planTransfer } from "./organize-transfer.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -269,6 +270,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
evidence: extra,
entityId,
sourceInstanceId: dt?.instance_id ?? null,
request: storedImportRequest(req),
});
// ── 源文件 ──
@@ -419,6 +421,13 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
}
if (!entityId) {
const mainFile =
takeover?.mainFile ??
files.filter((f) => fileKindOf(f.path) === "video").sort((a, b) => b.size - a.size)[0]?.path ??
null;
const parsed =
takeover?.parsed ??
(mainFile ? parseReleasePath(segmentsWithin(mainFile, sourceRoot), await wordsOption(db)) : null);
const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {});
await decide_({
kind: "identity_ambiguous",
@@ -429,6 +438,18 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
dedupeKey: `organize_identity:${dt?.id ?? rid.recordId ?? "x"}`,
evidence: {
downloadTaskId: dt?.id ?? null,
sourceReleaseId: rel?.id ?? null,
sourcePaths: req.sourcePaths ?? null,
torrentName: dt?.name ?? rel?.title ?? null,
mainFile,
parsed: parsed
? {
title: parsed.title ?? null,
year: parsed.year ?? null,
season: parsed.season ?? null,
episodes: parsed.episodes ?? null,
}
: null,
...(takeover
? {
torrentName: dt?.name ?? null,
@@ -445,7 +466,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
}
: {}),
},
...(dt ? { options: identityOptions(takeover, dt.name) } : {}),
options: identityOptions(takeover, dt?.name ?? parsed?.title ?? mainFile ?? "未知作品"),
});
return rid;
}
@@ -991,6 +1012,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
if (req.keepBoth && !inPlace) await keepBothTargets(plan);
const frozen: FrozenPlan = {
version: 1,
request: storedImportRequest(req),
...(req.retryOfRecordId ? { retryOfRecordId: req.retryOfRecordId } : {}),
...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}),
...(skippedSuccessful.length ? { skippedSuccessful } : {}),
...(replacement ? { replacement } : {}),
@@ -1321,6 +1344,7 @@ export async function writeFailedRecord(
evidence: Record<string, unknown>;
entityId: string | null;
sourceInstanceId: string | null;
request?: import("./organize-types.ts").StoredImportRequest;
},
): Promise<PlanResult> {
const now = d.ports.now();
@@ -1335,10 +1359,12 @@ export async function writeFailedRecord(
media_entity_id: r.entityId,
download_task_id: r.downloadTaskId,
source_downloader_instance_id: r.sourceInstanceId,
source_path: r.request?.sourcePaths?.[0] ?? null,
action: r.action,
status: "failed",
reason: r.reason,
error: jsonb(r.evidence),
...(r.request ? { plan: jsonb({ request: r.request, retryOfRecordId: r.request.retryOfRecordId }) } : {}),
started_at: now,
finished_at: now,
})
@@ -23,8 +23,9 @@ export function attentionSql(alias = "organization_record") {
const r = sql.raw(alias);
return sql<boolean>`(not exists (
select 1 from organization_record r2
where ${r}.download_task_id is not null and r2.download_task_id = ${r}.download_task_id
where (${r}.download_task_id is not null and r2.download_task_id = ${r}.download_task_id
and r2.created_at > ${r}.created_at)
or (r2.plan->>'retryOfRecordId' = ${r}.id::text and r2.status in ('queued', 'running', 'succeeded', 'partial')))
and (exists (
select 1 from decision dd
where dd.subject_type = 'organization_record' and dd.subject_id = ${r}.id
@@ -71,8 +72,15 @@ async function loadAttention(exec: Exec, rows: OrganizationRecordRow[]) {
eb
.selectFrom("organization_record as r2")
.select("r2.id")
.whereRef("r2.download_task_id", "=", "r.download_task_id")
.whereRef("r2.created_at", ">", "r.created_at"),
.where((eb) =>
eb.or([
eb.and([
eb("r2.download_task_id", "=", eb.ref("r.download_task_id")),
eb("r2.created_at", ">", eb.ref("r.created_at")),
]),
sql<boolean>`r2.plan->>'retryOfRecordId' = r.id::text and r2.status in ('queued', 'running', 'succeeded', 'partial')`,
]),
),
),
)
.execute(),
@@ -102,7 +110,10 @@ async function loadAttention(exec: Exec, rows: OrganizationRecordRow[]) {
evidence: ev,
}
: null,
agentIdentify: !!d && d.dedupe_key.startsWith("organize_identity:") && typeof ev.downloadTaskId === "string",
agentIdentify:
!!d &&
d.dedupe_key.startsWith("organize_identity:") &&
(typeof ev.downloadTaskId === "string" || Array.isArray(ev.sourcePaths)),
});
}
return out;
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { type OrganizationRecordRow, sql } from "@umt/db";
import type { DecisionHookActor } from "../../decision-hooks.ts";
import { type AoDeps, ensureTaskRow, markTask } from "./common.ts";
import { sourceGuard } from "./organize-guard.ts";
import { planImport } from "./organize-plan.ts";
import type { ImportRequest, StoredImportRequest } from "./organize-types.ts";
/** 只保留业务参数;路径守卫与任务身份在恢复时重新装配。 */
export function storedImportRequest(req: ImportRequest): StoredImportRequest {
const {
trigger: _trigger,
actorType: _actorType,
actorId: _actorId,
taskId: _taskId,
dryRun: _dryRun,
pathGuard: _pathGuard,
idempotencyKey: _idempotencyKey,
...business
} = req;
return JSON.parse(JSON.stringify(business));
}
export function organizeRequestFromRecord(
record: Pick<OrganizationRecordRow, "plan" | "download_task_id">,
): StoredImportRequest | null {
const request = (record.plan as { request?: StoredImportRequest } | null)?.request;
if (request && (request.downloadTaskId || request.sourcePaths?.length)) return structuredClone(request);
// 老记录没有完整请求,不猜测一次性选项。
return null;
}
/** 按原请求重新规划;不复用旧冻结计划,来源路径重新经过当前目录边界。 */
export async function retryOrganizeRecord(
d: AoDeps,
recordId: string,
actor: DecisionHookActor,
via: string,
overrides: { mediaEntityId?: string; idempotencyKey?: string; skipSuccessfulRecords?: boolean } = {},
) {
const record = await d.ctx.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", recordId)
.executeTakeFirst();
if (!record) throw new Error("整理记录不存在");
if (overrides.idempotencyKey) {
const existing = await d.ctx.db
.selectFrom("organization_record")
.select(["id", "task_id", "status", "reason"])
.where(sql<string>`plan->>'idempotencyKey'`, "=", overrides.idempotencyKey)
.where("status", "in", ["queued", "running", "succeeded", "partial"])
.executeTakeFirst();
if (existing?.task_id)
return { recordId: existing.id, taskId: existing.task_id, status: existing.status, reason: existing.reason };
}
const request = organizeRequestFromRecord(record);
if (!request) throw new Error("整理记录没有可恢复的来源,重新选择来源后整理");
if (overrides.skipSuccessfulRecords && request.sourcePaths?.length) {
const done = await d.ctx.db
.selectFrom("organization_file_result")
.select("source_path")
.where("organization_record_id", "=", recordId)
.where((eb) =>
eb.or([
eb("status", "=", "succeeded"),
eb.and([eb("status", "=", "skipped"), eb("skip_reason", "=", "already_in_library")]),
]),
)
.execute();
const completed = new Set(done.map((f) => f.source_path));
request.sourcePaths = request.sourcePaths.filter((p) => !completed.has(p));
if (!request.sourcePaths.length) throw new Error("这条记录没有剩余待整理文件");
}
let guarded: Pick<ImportRequest, "sourcePaths" | "pathGuard"> = {};
if (request.sourcePaths?.length) {
const guard = await sourceGuard(d.ctx.db);
guarded = {
sourcePaths: await Promise.all(request.sourcePaths.map((p) => guard.check(p))),
pathGuard: guard.check,
};
}
return startManualImport(
d,
{
...request,
...guarded,
...(overrides.mediaEntityId ? { mediaEntityId: overrides.mediaEntityId } : {}),
...(overrides.idempotencyKey ? { idempotencyKey: overrides.idempotencyKey } : {}),
...(overrides.skipSuccessfulRecords !== undefined
? { skipSuccessfulRecords: overrides.skipSuccessfulRecords }
: {}),
retryOfRecordId: recordId,
},
actor,
via,
);
}
export async function startManualImport(
d: AoDeps,
req: Omit<ImportRequest, "trigger" | "actorType" | "actorId" | "taskId">,
actor: DecisionHookActor,
via: string,
): Promise<{ recordId: string | null; status: string; reason: string | null; taskId: string }> {
const db = d.ctx.db;
const idem = req.idempotencyKey ?? `${via}:${req.downloadTaskId ?? "x"}:${randomUUID()}`;
const by = actor.id ? `${actor.type}:${actor.id}` : actor.type;
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: idem,
input: { ...storedImportRequest({ ...req, trigger: "manual", actorType: "system" }), via },
subjectType: req.downloadTaskId ? "download_task" : "organize_request",
subjectId: req.downloadTaskId ?? null,
triggeredBy: by,
});
const actorType = actor.type === "api_key" || actor.type === "agent" || actor.type === "system" ? actor.type : "user";
const plan = await planImport(d, {
...req,
trigger: "manual",
actorType,
actorId: by,
taskId,
});
if (plan.status === "running" || plan.status === "queued") {
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: plan.recordId, downloadTaskId: req.downloadTaskId, taskId },
{ idempotencyKey: `verifyAndImport:${idem}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
} else if (plan.status === "succeeded" || plan.status === "partial") {
await markTask(db, taskId, "succeeded", {
output: { recordId: plan.recordId, status: plan.status, reason: plan.reason },
});
} else {
await markTask(db, taskId, "failed", { error: plan.reason ?? plan.status });
}
return { recordId: plan.recordId, status: plan.status, reason: plan.reason, taskId };
}
@@ -8,6 +8,8 @@ import type { FrozenReplacement } from "./replacement.ts";
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
export interface ImportRequest {
/** 从这条记录补齐事实后重新规划,用于原记录与后继结果关联。 */
retryOfRecordId?: string;
skipSuccessfulRecords?: boolean | undefined;
scrapeAfterImport?: boolean | undefined;
importMode?: "normal" | "replace";
@@ -57,6 +59,11 @@ export interface ImportRequest {
episodeStructure?: OrganizeEpisodeStructure | undefined;
}
export type StoredImportRequest = Omit<
ImportRequest,
"trigger" | "actorType" | "actorId" | "taskId" | "dryRun" | "pathGuard" | "idempotencyKey"
>;
/** 整理时指定的季集结构:groupId null = TMDB 默认季 */
export interface OrganizeEpisodeStructure {
groupId: string | null;
@@ -100,6 +107,8 @@ export type EpisodeFacts = Pick<
/** organization_record.plan 的冻结内容 */
export interface FrozenPlan {
request?: StoredImportRequest;
retryOfRecordId?: string;
scrapeAfterImport?: boolean;
skippedSuccessful?: OrganizePlan["files"];
replacement?: FrozenReplacement;
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { basename, dirname, extname, join } from "node:path";
import type { DownloadTaskRow } from "@umt/db";
import {
@@ -16,8 +15,8 @@ import { addAliasIfAbsent } from "../../sources-identity/service/entities.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
import type { NameIdentifyResult } from "../ports.ts";
import { jsonb } from "../util.ts";
import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts";
import { type ImportRequest, planImport } from "./organize.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances } from "./common.ts";
import { retryOrganizeRecord, startManualImport } from "./organize-retry.ts";
// QB-1 接管任务(用户手动加进 qB、贴了管理标签,没有站点资源 / 计划)完成后的作品识别与事项闭环。
// 识别对照 MP transfer:没有下载历史时按主视频文件名(合并上级 / 上上级目录名,即 MetaInfoPath)+ 种子名识别,
@@ -214,43 +213,7 @@ export async function keepBothTargets(plan: OrganizePlan): Promise<void> {
// ── 手动整理(待处理里选了处理方式):冻结计划 + 入队执行 ──
export async function startManualImport(
d: AoDeps,
req: Omit<ImportRequest, "trigger" | "actorType" | "actorId" | "taskId">,
actor: DecisionHookActor,
via: string,
): Promise<{ recordId: string | null; status: string; reason: string | null; taskId: string }> {
const db = d.ctx.db;
const idem = `${via}:${req.downloadTaskId ?? "x"}:${randomUUID()}`;
const by = actor.id ? `${actor.type}:${actor.id}` : actor.type;
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: idem,
input: { ...req, via },
subjectType: req.downloadTaskId ? "download_task" : "organize_request",
subjectId: req.downloadTaskId ?? null,
triggeredBy: by,
});
const actorType = actor.type === "api_key" || actor.type === "agent" || actor.type === "system" ? actor.type : "user";
const plan = await planImport(d, {
...req,
trigger: "manual",
actorType,
actorId: by,
taskId,
});
if (plan.status === "running" || plan.status === "queued") {
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: plan.recordId, downloadTaskId: req.downloadTaskId, taskId },
{ idempotencyKey: `verifyAndImport:${idem}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
} else {
await markTask(db, taskId, "failed", { error: plan.reason ?? plan.status });
}
return { recordId: plan.recordId, status: plan.status, reason: plan.reason, taskId };
}
export { startManualImport } from "./organize-retry.ts";
// ── 待处理执行钩子:organize_identity / organize_blocked ──
@@ -330,9 +293,8 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook {
? await d.ctx.db.selectFrom("download_task").selectAll().where("id", "=", dtId).executeTakeFirst()
: undefined;
if (row.dedupe_key.startsWith("organize_identity:")) {
if (!dt) throw new HookError("not_found", "关联的下载任务不存在");
const entityId = await chosenEntity(d, body, opt, actor);
return reorganizeAs(d, dt.id, entityId, actor, "decision");
return reorganizeAs(d, row, entityId, actor, "decision");
}
// organize_blocked:目标已存在 → 替换 / 保留两者 / 跳过
const key = body.optionKey;
@@ -367,20 +329,23 @@ export function organizeDecisionHook(d: AoDeps): DecisionHook {
};
}
/** 回填下载任务的作品后按该作品重新整理(人工指定与智能体识别共用) */
/** 身份补齐后沿用失败整理请求,不重置一次性选项。 */
async function reorganizeAs(
d: AoDeps,
downloadTaskId: string,
row: import("@umt/db").DecisionRow,
entityId: string,
actor: DecisionHookActor,
via: string,
) {
await d.ctx.db
.updateTable("download_task")
.set({ media_entity_id: entityId })
.where("id", "=", downloadTaskId)
.execute();
const r = await startManualImport(d, { downloadTaskId, mediaEntityId: entityId }, actor, via);
const ev = (row.evidence ?? {}) as { downloadTaskId?: string | null };
const recordId = row.subject_type === "organization_record" ? row.subject_id : null;
if (!recordId) throw new HookError("not_found", "这条事项没有关联的整理记录");
const dtId = ev.downloadTaskId;
const r = await retryOrganizeRecord(d, recordId, actor, via, { mediaEntityId: entityId });
if (!["running", "queued", "succeeded", "partial"].includes(r.status))
throw new HookError("conflict", `作品已确认,但重新整理没能执行:${r.reason ?? r.status}`);
if (dtId)
await d.ctx.db.updateTable("download_task").set({ media_entity_id: entityId }).where("id", "=", dtId).execute();
return { kind: "organize", mediaEntityId: entityId, ...r };
}
@@ -388,7 +353,7 @@ const OPEN_DECISION = ["open", "investigating", "awaiting_user"];
/**
* organize.assign_identity:智能体给「没识别出作品」的整理事项指定作品(本地作品或 TMDB),随即重新整理并关闭事项。
* 与人工在整理记录里「指定作品」走同一条路(回填下载任务 → 冻结手动整理计划 → 入队执行)。
* 与人工指定作品共用原记录恢复入口;重新规划失败保留事项,排队成功后解决的是身份问题,入库结果另查整理记录。
*/
export async function assignOrganizeIdentity(
d: AoDeps,
@@ -403,9 +368,6 @@ export async function assignOrganizeIdentity(
if (!row.dedupe_key.startsWith("organize_identity:"))
throw new HookError("bad_request", "只能用于整理时「没识别出作品」的事项");
if (!OPEN_DECISION.includes(row.status)) throw new HookError("conflict", "这条记录已经处理过了");
const ev = (row.evidence ?? {}) as { downloadTaskId?: string | null };
const dtId = ev.downloadTaskId ?? null;
if (!dtId) throw new HookError("not_found", "这条记录没有关联的下载任务,只能在整理向导里手动整理");
const entityId = pick.mediaEntityId
? await chosenEntity(
d,
@@ -419,7 +381,7 @@ export async function assignOrganizeIdentity(
{ key: "x", label: "x", input: { requires: "tmdb" } },
actor,
);
const r = await reorganizeAs(d, dtId, entityId, actor, "agent-identify");
const r = await reorganizeAs(d, row, entityId, actor, "agent-identify");
const by = actor.id ? `${actor.type}:${actor.id}` : actor.type;
await db.transaction().execute(async (trx) => {
const closed = await trx
+45 -10
View File
@@ -191,9 +191,14 @@ export async function autoInvestigate(
// Agent 自己引起的事项不再触发 Agent(防止递归自触发)
if (d.subject_type === "agent_run") return { runId: null, created: false };
const s = await ctx.settings.get("agent");
if (!s.autoInvestigate) return { runId: null, created: false };
const organizeEpisodes =
d.kind === "episode_mapping" &&
Array.isArray((d.evidence as { fileSamples?: unknown })?.fileSamples) &&
(d.evidence as { fileSamples: unknown[] }).fileSamples.length > 0;
if (organizeEpisodes ? !s.autoIdentifyOrganize : !s.autoInvestigate) return { runId: null, created: false };
const requested = s.autoInvestigateMode;
// 显式整理的文件季集异常沿用整理识别开关;写规则仍要经过原有持续授权。
const requested = organizeEpisodes ? "recover" : s.autoInvestigateMode;
let mode: "investigate" | "recover" = requested;
let degraded: { reason: string } | undefined;
let attempt = 0;
@@ -328,6 +333,10 @@ function evidenceLine(ev: Record<string, unknown>): string {
const parts = [
typeof ev.torrentName === "string" ? `种子名「${ev.torrentName}」` : "",
typeof ev.mainFile === "string" ? `主视频「${ev.mainFile}」` : "",
typeof ev.sourceReleaseId === "string"
? `来源资源 ${ev.sourceReleaseId}(可用 release.inspect 查看下载来源详情)`
: "",
Array.isArray(ev.sourcePaths) ? `整理来源 ${ev.sourcePaths.slice(0, 5).join("、")}` : "",
parsed.title ? `解析出的标题「${String(parsed.title)}」` : "",
parsed.year ? `年份 ${String(parsed.year)}` : "",
parsed.season != null ? `第 ${String(parsed.season)} 季` : "",
@@ -372,7 +381,7 @@ export async function identifyOrganize(
evidenceLine(ev),
"用 catalog.lookup 按标题 / 年份查 TMDB,用 inventory.query 看媒体库里有没有这个作品;标题是拼音、缩写或译名对不上时可以用 web.search 查正式名称。",
`确认标题、年份、类型(电影 / 剧集)和季集都对得上时,调用一次 organize.assign_identity(decisionId 填 ${d.id},` +
"本地已有作品填 mediaEntityId,否则填 tmdb 类型和 ID,reasons 写依据),系统会按这个作品重新整理。",
"本地已有作品填 mediaEntityId,否则填 tmdb 类型和 ID,reasons 写依据),系统会保留这次整理的选项按这个作品重新整理。指定作品成功只表示已补齐身份并重新排队,不能据此声称已经入库。",
"拿不准就不要指定,只说明查到了什么、为什么认不出。不下载、不删除、不改别的记录。",
].join("");
const { run, created } = await startRun(ctx, {
@@ -502,13 +511,34 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[
: "pending",
);
} else if (a.capabilityId === "organize.assign_identity") {
// 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录)
// 身份已补齐与入库成功分开核实,以重新整理记录的业务终态为准。
const id = String(a.input.decisionId ?? "");
const d = id
? await ctx.db.selectFrom("decision").select(["status"]).where("id", "=", id).executeTakeFirst()
? await ctx.db.selectFrom("decision").select(["status", "resolution"]).where("id", "=", id).executeTakeFirst()
: undefined;
facts[`decision:${id}`] = d ? { status: d.status } : null;
states.push(!d ? "failed" : d.status === "resolved" ? "recovered" : "pending");
const recordId = (d?.resolution as { execution?: { recordId?: string } } | null)?.execution?.recordId;
const record = recordId
? await ctx.db
.selectFrom("organization_record")
.select(["status", "reason"])
.where("id", "=", recordId)
.executeTakeFirst()
: null;
facts[`decision:${id}`] = d
? {
status: d.status,
recordId: recordId ?? null,
organizeStatus: record?.status ?? null,
reason: record?.reason ?? null,
}
: null;
states.push(
!d || !record || record.status === "failed"
? "failed"
: record.status === "succeeded"
? "recovered"
: "pending",
);
} else if (a.capabilityId === "episode_mapping.apply_rule") {
// 事项内资源都已不是待确认(写进作品识别词后的重算会把事项自动关闭)即视为恢复
const id = String(a.input.decisionId ?? "");
@@ -563,8 +593,13 @@ export async function applyRecoveryOutcome(
else if (res.reason === "budget") outcome = "budget_exceeded";
else outcome = "not_attempted";
const what = (done.length > 0 ? done : acts.actions).map((a) => a.capabilityId).join(" + ");
const summary =
outcome === "recovered"
const organizePending =
outcome === "unverified" &&
done.some((a) => a.capabilityId === "organize.assign_identity") &&
check?.state === "pending";
const summary = organizePending
? "作品已确认,重新整理已提交;入库结果以整理记录为准。"
: outcome === "recovered"
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
: outcome === "failed"
? done.length > 0
@@ -584,7 +619,7 @@ export async function applyRecoveryOutcome(
denied: acts.denied,
facts: check?.facts ?? {},
verifiedAt: new Date().toISOString(),
...(outcome === "recovered" ? {} : { suggestion: suggestionFor(d, summary.replace(/。$/, "")) }),
...(outcome === "recovered" || organizePending ? {} : { suggestion: suggestionFor(d, summary.replace(/。$/, "")) }),
};
await mergeEvidence(ctx, decisionId, evidence);
if (outcome === "recovered") {
@@ -0,0 +1,206 @@
import { symlink, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import {
retryOrganizeRecord,
storedImportRequest,
} from "../../src/modules/acquisition-organize/services/organize-retry.ts";
import {
assignOrganizeIdentity,
organizeDecisionHook,
} from "../../src/modules/acquisition-organize/services/takeover.ts";
import { autoInvestigate, checkRecovery } from "../../src/modules/agent/investigate.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
const actor = { type: "user", id: "identity-recovery" };
it("请求快照保留业务参数,不序列化运行身份与路径守卫", () => {
expect(
storedImportRequest({
sourcePaths: ["/cache/movie.mkv"],
namingMode: "keep",
action: "move",
trigger: "manual",
actorType: "user",
actorId: "secret",
taskId: "old",
idempotencyKey: "old",
dryRun: true,
pathGuard: async (p) => p,
}),
).toEqual({ sourcePaths: ["/cache/movie.mkv"], namingMode: "keep", action: "move" });
});
describe.skipIf(!hasPg)("按路径身份补齐后恢复原整理", () => {
let h: Harness;
let lib: TempTree;
let cache: TempTree;
let outside: TempTree;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
cache = await createTempTree({});
outside = await createTempTree({});
h = await createHarness("identity_recovery", {}, { withAgent: true });
await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: "http://127.0.0.1:1" });
}, 60_000);
afterAll(async () => {
await h?.close();
await lib?.cleanup();
await cache?.cleanup();
await outside?.cleanup();
});
async function unknown(name: string, extra = {}) {
const src = join(cache.root, `${name}.2022.1080p.mkv`);
await writeFile(src, Buffer.alloc(300_000, 1));
const plan = await planImport(h.d, {
sourcePaths: [src],
trigger: "manual",
actorType: "user",
actorId: actor.id,
...extra,
});
expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" });
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("subject_id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
return { src, plan, dec };
}
it("路径身份事项有文件证据和Agent入口,指定作品保留目录、命名与刮削参数", async () => {
const targetDir = join(lib.root, "custom");
const { src, plan, dec } = await unknown("Recovery.Path", {
action: "copy",
targetDir,
namingMode: "keep",
scrapeAfterImport: false,
minSizeBytes: 100,
});
expect(dec.evidence).toMatchObject({
sourcePaths: [src],
mainFile: src,
parsed: { title: "Recovery Path", year: 2022 },
});
expect((await h.api("GET", `/organization-records/${plan.recordId}`)).body.data.attention.agentIdentify).toBe(true);
const entityId = await seedEntity(h.db, { kind: "movie", title: "Recovery Path", year: 2022 });
const assigned = await assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: entityId }, actor, "标题和年份一致");
expect(assigned.status).toBe("running");
const record = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", assigned.recordId!)
.executeTakeFirstOrThrow();
expect(record.plan).toMatchObject({
retryOfRecordId: plan.recordId,
request: { sourcePaths: [src], targetDir, namingMode: "keep", scrapeAfterImport: false, minSizeBytes: 100 },
naming: { mode: "keep" },
});
const action = [{ capabilityId: "organize.assign_identity", input: { decisionId: dec.id }, status: "succeeded" }];
expect((await checkRecovery(h.ctx, action)).state).toBe("pending");
expect((await executeRecord(h.d, assigned.recordId!)).status).toBe("succeeded");
expect((await checkRecovery(h.ctx, action)).state).toBe("recovered");
const attention = await h.api("GET", "/organization-records?attention=true&organizeOnly=true");
expect(attention.body.data.items.map((r: { id: string }) => r.id)).not.toContain(plan.recordId);
});
it("人工指定恢复原路径请求,不要求关联下载任务", async () => {
const { plan, dec } = await unknown("Human.Path", { namingMode: "keep" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Human Path", year: 2022 });
const result = await organizeDecisionHook(h.d).execute(
dec,
{ optionKey: "select_entity", identity: { mediaEntityId: entityId } },
actor,
);
expect(result).toMatchObject({ status: "running", mediaEntityId: entityId });
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", result.recordId as string)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ retryOfRecordId: plan.recordId });
});
it("身份已找到但重新规划失败,保持原事项待处理", async () => {
const { dec } = await unknown("Blocked.Path", { targetLibraryRootId: "missing-root" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Blocked Path", year: 2022 });
await expect(assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: entityId }, actor, "标题一致")).rejects.toThrow(
/重新整理没能执行/,
);
expect(
(await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow()).status,
).toBe("open");
expect(
(
await checkRecovery(h.ctx, [
{ capabilityId: "organize.assign_identity", input: { decisionId: dec.id }, status: "succeeded" },
])
).state,
).toBe("failed");
});
it("恢复重新检查路径边界,拒绝后来被换成越界符号链接的源文件", async () => {
const { src, dec } = await unknown("Guarded.Path");
const entityId = await seedEntity(h.db, { kind: "movie", title: "Guarded Path", year: 2022 });
const escaped = join(outside.root, "escaped.mkv");
await writeFile(escaped, Buffer.alloc(300_000));
await unlink(src);
await symlink(escaped, src);
await expect(assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: entityId }, actor, "标题一致")).rejects.toThrow(
/不在已登记/,
);
expect(
(await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow()).status,
).toBe("open");
});
it("Move 恢复完成后稳定幂等键重放沿用结果,不再读取已移动的源文件", async () => {
const { plan } = await unknown("Replay.Path", { action: "move", namingMode: "keep" });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Replay Path", year: 2022 });
const options = { mediaEntityId: entityId, idempotencyKey: "recovery:replay-path" };
const first = await retryOrganizeRecord(h.d, plan.recordId!, actor, "test-replay", options);
expect((await executeRecord(h.d, first.recordId!)).status).toBe("succeeded");
const replay = await retryOrganizeRecord(h.d, plan.recordId!, actor, "test-replay", options);
expect(replay).toMatchObject({ recordId: first.recordId, taskId: first.taskId, status: "succeeded" });
});
it("旧记录没有请求快照时拒绝猜测一次性参数", async () => {
const { plan } = await unknown("Legacy.Path");
await h.db.updateTable("organization_record").set({ plan: "{}" }).where("id", "=", plan.recordId!).execute();
await expect(retryOrganizeRecord(h.d, plan.recordId!, actor, "test")).rejects.toThrow(/没有可恢复/);
});
it("整理文件季集事项默认调查,无写授权降级建议,资源站普通事项不开启全局恢复", async () => {
await h.setSettings("agent", { autoInvestigate: false, autoIdentifyOrganize: true });
const file = await h.db
.insertInto("decision")
.values({
kind: "episode_mapping",
title: "整理文件季集待确认",
dedupe_key: "episode:test-files",
evidence: JSON.stringify({ fileSamples: [{ recordId: "sample" }] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const out = await autoInvestigate(h.ctx, file.id);
expect(out).toMatchObject({ created: true, mode: "investigate" });
const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", out.runId!).executeTakeFirstOrThrow();
expect(run.mode).toBe("plan_only");
expect((await autoInvestigate(h.ctx, file.id)).created).toBe(false);
const ordinary = await h.db
.insertInto("decision")
.values({ kind: "episode_mapping", title: "资源站季集待确认", dedupe_key: "episode:test-ordinary" })
.returning("id")
.executeTakeFirstOrThrow();
expect((await autoInvestigate(h.ctx, ordinary.id)).created).toBe(false);
await h.setSettings("agent", { autoIdentifyOrganize: false });
const disabled = await h.db
.insertInto("decision")
.values({
kind: "episode_mapping",
title: "整理关闭",
dedupe_key: "episode:test-disabled",
evidence: JSON.stringify({ fileSamples: [{}] }),
})
.returning("id")
.executeTakeFirstOrThrow();
expect((await autoInvestigate(h.ctx, disabled.id)).created).toBe(false);
});
});