feat(server): W2-D agent assembly, session API, runAgent/resolveDecision and backend e2e

- Capabilities: all 14 registered (AO: inventory.query, download.plan/submit, organize.plan/execute,
  policy.preview/apply; agent: task.inspect/retry/cancel); startup asserts count == 14.
  Registry injects server-side authorize (automation_grant by action/scope/policy version/budget,
  one-shot approval bound to plan id + payload hash + revision + expiry; model claims ignored;
  automation switch), audit_event, server idempotency keys and a capability_invocation op ledger
  (migration 0021) used for crash reconciliation. download.plan creates draft plans only;
  download.submit promotes draft -> reserved after authorization.
- Agent session API: POST/GET /agent/runs, GET /agent/runs/{id} (events by seq, messages, report),
  POST .../messages (wake), POST .../abort; automation-grants, approvals, capabilities invoke, tasks.
- runAgent task: Pi or fake provider from settings (apiKey via secrets), events persisted to
  agent_run_event then published, resume + ledger reconciliation when pendingOperations > 0,
  waits for goal-linked downloads/imports and reports from business tables (submitted != imported).
- Background triggers: decision.created -> bounded plan_only investigation (dedupe per decision);
  decision.updated(resolved) -> resolveDecision executes chosen option.
- W2 closure: all OpenAPI routes registered in contracts api.ts (+ subset test); TMDB key from
  settings secret_ref with env fallback; PUT /sites/{id}/credentials documented as alias of
  /site-accounts; completion events documented as existing status events.
- e2e (real PG + OpenWorkflow + testkit mocks): RSS->library, agent delegation with grant/deny,
  background continuation, worker kill -9 recovery (single qB add), auto investigation dedupe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:12 +08:00
1 parent 0dec0854df
commit 6280d52ec3
46 files changed
+5133 -111

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { API_PREFIX, listEndpoints } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/http/app.ts";
import { EventBus } from "../src/http/sse.ts";
import { SERVER_MODULES } from "../src/modules/index.ts";
import type { ServerContext } from "../src/modules/types.ts";
import { DEV_KEY, json, memoryLogger } from "./helpers.ts";
// OpenAPI 里出现的每条 /api/v1 路由都必须登记在 @umt/contracts 的 Api 注册表(web 与测试都从那里取路径/schema)。
describe("Api 注册表覆盖", () => {
it("OpenAPI 文档中的每条路由都在 listEndpoints() 里", async () => {
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger: memoryLogger().logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
modules: SERVER_MODULES,
ctx: { role: "all" } as unknown as ServerContext,
});
const res = await app.request("/api/openapi.json");
expect(res.status).toBe(200);
const doc = (await json(res)) as { paths: Record<string, Record<string, unknown>> };
const served: string[] = [];
for (const [path, ops] of Object.entries(doc.paths)) {
if (!path.startsWith(`${API_PREFIX}/`)) continue; // /api/openapi.json 等非业务路由
const rel = path.slice(API_PREFIX.length);
for (const method of Object.keys(ops)) served.push(`${method.toUpperCase()} ${rel}`);
}
expect(served.length).toBeGreaterThan(0);
const registered = new Set(listEndpoints().map((e) => `${e.method.toUpperCase()} ${e.path}`));
const missing = served.filter((k) => !registered.has(k)).sort();
expect(missing, `以下路由未登记到 packages/contracts/src/api.ts:\n${missing.join("\n")}`).toEqual([]);
});
});
@@ -0,0 +1,158 @@
import { createMockRssServer, type MockRssServer } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { completeDownload, createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
// E1 RSS → 入库(全部经真实任务引擎 + outbox 中继):
// mock RSS(观众风格条目)→ ingestFeed → release.observed → resolveObservation(mock TMDB,密钥来自设置)
// → release.resolved → evaluateAcquisition(已发布策略:新作品 1080p Slot,发布即生成持续授权)→ 计划 + 提交 mock qB
// → 完成(落盘)→ download.status_changed(completed)→ verifyAndImport(Copy)→ /organization-records/changes 文件级记录
// → 媒体库详情 Slot=satisfied。
const hasPg = await pgAvailable();
const BASE = "https://audiences.example";
const TITLE = "Dune.Part.Two.2024.1080p.BluRay.x264-GRP";
describe.skipIf(!hasPg)("E1 RSS → 入库", () => {
let e: E2E;
let rss: MockRssServer;
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
e = await createE2E("e1");
base = await seedBase(e, { baseUrl: `${BASE}/` });
rss = await createMockRssServer({
passkey: "E2EPASSKEY",
items: [
{
guid: "aud-812345",
title: TITLE,
link: `${BASE}/details.php?id=812345&hit=1`,
enclosureUrl: `${BASE}/download.php?id=812345&passkey=E2EPASSKEY`,
size: 300_000,
pubDate: new Date(),
categories: ["电影"],
},
{
guid: "aud-812346",
title: "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA",
link: `${BASE}/details.php?id=812346&hit=1`,
enclosureUrl: `${BASE}/download.php?id=812346&passkey=E2EPASSKEY`,
size: 300_000,
pubDate: new Date(),
categories: ["电影"],
},
],
});
}, 120_000);
afterAll(async () => {
await rss?.close();
await e?.close();
}, 60_000);
it("发布 1080p 收录策略 → 持续授权", async () => {
const created = await e.api("POST", "/policy-revisions", {
policyKey: "default",
kind: "acquisition",
title: "新片 1080p",
scope: { mediaKinds: ["movie"] },
rules: {
newTitle: {
enabled: true,
windowDays: 3650,
mediaKinds: ["movie"],
slots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
},
},
});
expect(created.status).toBe(201);
const pub = await e.api("POST", `/policy-revisions/${created.body.data.id}/publish`, {
expectedRevision: created.body.data.revision,
});
expect(pub.status).toBe(200);
expect(pub.body.data.status).toBe("published");
const grants = await e.api("GET", "/automation-grants");
expect(grants.body.data.items.map((g: { action: string }) => g.action)).toContain("policy.acquisition");
});
it("RSS 抓取 → 识别 → 计划 → 提交 → 完成 → Copy 入库 → 整理记录增量可读 → Slot satisfied", async () => {
const urlRef = await e.booted.ctx.secrets.create({ purpose: "feed_url", plaintext: rss.feedUrl });
const feed = await e.db
.insertInto("feed_source")
.values({
site_id: base.siteId,
name: "观众-电影",
purpose: "movie",
url_secret_ref: urlRef,
url_display: rss.feedUrl.replace(/passkey=[^&]+/, "passkey=[REDACTED]"),
poll_interval_sec: 3600,
})
.returning("id")
.executeTakeFirstOrThrow();
const fetched = await e.api("POST", `/feed-sources/${feed.id}/fetch`);
expect(fetched.status).toBe(200);
// 识别:TMDB 客户端来自设置(secret_ref),建本地作品
const rel = await waitFor(
async () =>
e.db
.selectFrom("source_release")
.selectAll()
.where("title", "=", TITLE)
.where("resolution_status", "=", "resolved")
.executeTakeFirst(),
60_000,
"1080p 资源识别完成",
);
const entity = await e.db
.selectFrom("media_entity")
.selectAll()
.where("id", "=", rel.media_entity_id!)
.executeTakeFirstOrThrow();
expect(entity).toMatchObject({ kind: "movie", year: 2024 });
// 计划与提交:只有 1080p 被选中
await completeDownload(e, TITLE);
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
const rec = await waitFor(
async () =>
e.db
.selectFrom("organization_record")
.selectAll()
.where("media_entity_id", "=", entity.id)
.where("status", "=", "succeeded")
.executeTakeFirst(),
90_000,
"整理记录 succeeded",
);
expect(rec).toMatchObject({ action: "copy", trigger: "auto" });
// 公开整理记录增量 API:文件级结果(只返回早于所有活跃事务的变更,共享库上其它测试的长事务会让它稍晚可见)
const item = await waitFor(
async () => {
const changes = await e.api("GET", "/organization-records/changes?after=0&includeFiles=true");
expect(changes.status).toBe(200);
return changes.body.data.items.find(
(x: { id: string; status: string }) => x.id === rec.id && x.status === "succeeded",
);
},
60_000,
"整理记录出现在 changes 增量里",
500,
);
expect(item.files[0]).toMatchObject({ role: "main", status: "succeeded" });
expect(item.files[0].target.path).toMatch(/1080p\.mkv$/);
// 媒体库详情:Slot satisfied,资产登记
const lib = await e.api("GET", `/library/${entity.id}`);
expect(lib.status).toBe(200);
expect(lib.body.data.slots.map((s: { name: string; status: string }) => `${s.name}:${s.status}`)).toEqual([
"1080p:satisfied",
]);
expect(lib.body.data.assets.length).toBeGreaterThanOrEqual(1);
// 凭证不落业务表明文
const dump = JSON.stringify(await e.db.selectFrom("observation").selectAll().execute());
expect(dump).not.toContain("E2EPASSKEY");
}, 180_000);
});
@@ -0,0 +1,180 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { delegateScript } from "./fixtures/scripts.ts";
import {
completeDownload,
createE2E,
type E2E,
pgAvailable,
runEvents,
runRow,
seedBase,
seedMovieTarget,
waitFor,
} from "./harness.ts";
// E2 Agent 委托闭环:fake 模型按"把《沙丘2》1080p 收进来"依次调用
// inventory.query → release.search → download.plan → download.submit → task.inspect。
// - 没有持续授权时 download.submit 被服务端拒绝(模型文本"用户已同意"无效),SSE 上是 denied;
// - 发了 automation_grant 后同一目标成功提交;模型汇报"已入库",但 run 的汇报来自整理记录:
// 下载完成前是 waiting(已提交≠已入库),核验入库后才 succeeded 并带整理记录 ID。
const hasPg = await pgAvailable();
const GOAL = "把《沙丘2》1080p 收进来";
describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权/任务)", () => {
let e: E2E;
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
beforeAll(async () => {
e = await createE2E("e2", { fakeScripts: [delegateScript] });
const base = await seedBase(e);
target = await seedMovieTarget(e, base.siteId);
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
const createRun = async () => {
const r = await e.api("POST", "/agent/runs", { goal: GOAL, mode: "execute" });
expect(r.status).toBe(201);
expect(r.body.data).toMatchObject({ status: "queued", trigger: "user", mode: "execute" });
return r.body.data.id as string;
};
it("启动断言:14 个能力全部注册,并在能力目录里可见", async () => {
expect(e.booted.ctx.capabilities.list()).toHaveLength(14);
const r = await e.api("GET", "/capabilities");
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
e.booted.ctx.capabilities
.list()
.map((c) => c.descriptor.id)
.sort(),
);
});
it("没有授权:download.submit 被拒(模型声称已同意无效),不产生下载", async () => {
const seen: { type: string; payload: { phase?: string; capabilityId?: string } }[] = [];
const off = e.bus.subscribe((ev) => {
if (ev.type.startsWith("agent.")) seen.push(ev as never);
});
const runId = await createRun();
const run = await waitFor(
async () => {
const r = await runRow(e, runId);
return ["succeeded", "failed", "waiting"].includes(r.status) ? r : null;
},
60_000,
"run 结束",
);
off();
expect(run.status).toBe("succeeded");
const events = await runEvents(e, runId);
const submitEnd = events.find(
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
);
expect(submitEnd?.payload).toMatchObject({ isError: true });
expect(JSON.stringify(submitEnd?.payload)).toContain("unauthorized");
// SSE:未授权的调用标为 denied
expect(
seen.some(
(x) =>
x.type === "agent.tool_call" && x.payload.capabilityId === "download.submit" && x.payload.phase === "denied",
),
).toBe(true);
// 审计:capability.denied
const denied = await e.db
.selectFrom("audit_event")
.selectAll()
.where("action", "=", "capability.denied")
.where("target_id", "=", "download.submit")
.execute();
expect(denied.length).toBeGreaterThanOrEqual(1);
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
// 草案计划存在,但没有预留 Slot
const plans = await e.db.selectFrom("acquisition_plan").selectAll().execute();
expect(plans.map((p) => p.status)).toEqual(["draft"]);
const slot = await e.db
.selectFrom("acquisition_slot")
.select("status")
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("empty");
// 汇报不采信模型文本:没有可核查的业务变更
expect(run.summary).toContain("没有提交下载");
expect(run.summary).toContain("模型汇报(未经核查)");
}, 90_000);
it("有持续授权:提交成功 → 已提交≠已入库(waiting)→ 下载完成、核验入库后 succeeded,汇报来自整理记录", async () => {
const g = await e.api("POST", "/automation-grants", {
action: "download.submit",
scope: { mediaEntityIds: [target.entityId] },
reason: "e2e 授权",
});
expect(g.status).toBe(201);
const runId = await createRun();
// 模型已经说完"已入库",但业务上只是已提交:run 进入 waiting
const waiting = await waitFor(
async () => {
const r = await runRow(e, runId);
return r.status === "waiting" ? r : null;
},
60_000,
"run waiting",
);
expect(waiting.summary).toContain("已提交≠已入库");
const events = await runEvents(e, runId);
const completed = events.find((x) => x.type === "run_completed");
expect((completed!.payload as { finalText: string }).finalText).toContain("已入库");
const calls = events
.filter((x) => x.type === "tool_call_start")
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
expect(calls).toEqual(["inventory.query", "release.search", "download.plan", "download.submit", "task.inspect"]);
const submitEnd = events.find(
(x) => x.type === "tool_call_end" && (x.payload as { capabilityId: string }).capabilityId === "download.submit",
);
expect(submitEnd?.payload).toMatchObject({ isError: false });
// 账本:download.submit 记在本 run 名下,带授权 ID
const inv = await e.db
.selectFrom("capability_invocation")
.selectAll()
.where("agent_run_id", "=", runId)
.where("capability_id", "=", "download.submit")
.executeTakeFirstOrThrow();
expect(inv).toMatchObject({ status: "succeeded", grant_id: g.body.data.id });
// 草案被转为预留并提交
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const done = await waitFor(
async () => {
const r = await runRow(e, runId);
return r.status === "succeeded" ? r : null;
},
90_000,
"run succeeded",
);
const rec = await e.db
.selectFrom("organization_record")
.selectAll()
.where("status", "=", "succeeded")
.executeTakeFirstOrThrow();
expect(done.summary).toContain("已入库 1 个");
expect(done.summary).toContain(rec.id);
const detail = await e.api("GET", `/agent/runs/${runId}?afterSeq=-1&limit=500`);
expect(detail.body.data.report).toMatchObject({ state: "completed", imported: 1, submitted: 1 });
expect(detail.body.data.report.organization[0]).toMatchObject({ recordId: rec.id, status: "succeeded" });
// 断线补读:afterSeq 之后的事件
const tail = await e.api("GET", `/agent/runs/${runId}?afterSeq=3&limit=2`);
expect(tail.body.data.events.map((x: { seq: number }) => x.seq)).toEqual([4, 5]);
expect(tail.body.data.nextAfterSeq).toBe(5);
// 入库成功:Slot 满足
const slot = await e.db
.selectFrom("acquisition_slot")
.select("status")
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("satisfied");
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
}, 180_000);
});
@@ -0,0 +1,76 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { delegateScript } from "./fixtures/scripts.ts";
import {
completeDownload,
createE2E,
type E2E,
pgAvailable,
runRow,
seedBase,
seedMovieTarget,
waitFor,
} from "./harness.ts";
// E3 关闭聊天不影响后台:只发一次 POST /agent/runs(HTTP 请求里不跑模型,立即返回 queued),之后不再有任何 API 请求
// 也不订阅 SSE;持久任务独立推进:模型会话 → 提交 → (外部下载器完成)→ 核验入库 → run succeeded。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("E3 关闭聊天不影响后台", () => {
let e: E2E;
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
beforeAll(async () => {
e = await createE2E("e3", { fakeScripts: [delegateScript] });
const base = await seedBase(e);
target = await seedMovieTarget(e, base.siteId);
await e.db
.insertInto("automation_grant")
.values({ action: "download.submit", scope: JSON.stringify({}), granted_by: "user:e2e" })
.execute();
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
it("创建后不再请求,任务系统独立推进到 completed", async () => {
const t0 = Date.now();
const r = await e.api("POST", "/agent/runs", { goal: "把《沙丘2》1080p 收进来", mode: "execute" });
// HTTP 不驱动模型:立即返回 queued(模型会话在 worker 的持久任务里跑)
expect(r.status).toBe(201);
expect(r.body.data.status).toBe("queued");
expect(Date.now() - t0).toBeLessThan(5_000);
const runId = r.body.data.id as string;
const task = await e.db
.selectFrom("task")
.selectAll()
.where("id", "=", r.body.data.taskId)
.executeTakeFirstOrThrow();
expect(task).toMatchObject({ kind: "agentRun", subject_type: "agent_run", subject_id: runId });
expect(task.engine_run_id).toBeTruthy();
// "聊天已关闭":从这里开始只有外部世界(下载器)在变化
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const run = await waitFor(
async () => {
const x = await runRow(e, runId);
return x.status === "succeeded" ? x : null;
},
120_000,
"run succeeded",
);
expect(run.finished_at).not.toBeNull();
expect(run.summary).toContain("已入库 1 个");
const engineRun = await e.booted.ctx.engine.get(task.engine_run_id!);
expect(engineRun?.status).toBe("completed");
const t = await e.db.selectFrom("task").select("status").where("id", "=", task.id).executeTakeFirstOrThrow();
expect(t.status).toBe("succeeded");
const slot = await e.db
.selectFrom("acquisition_slot")
.select("status")
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("satisfied");
}, 180_000);
});
@@ -0,0 +1,142 @@
import { type ChildProcess, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { torrentFor } from "./fixtures/modules.ts";
import { crashScript } from "./fixtures/scripts.ts";
import {
createE2E,
type E2E,
PG_URL,
pgAvailable,
runEvents,
runRow,
seedBase,
seedMovieTarget,
waitFor,
} from "./harness.ts";
// E4 崩溃恢复:worker 子进程在 Agent 调用 download.submit 时被 kill -9;新 worker 在租约到期(约 30s)后接手:
// runAgent 的 drive 步骤重跑 → 打开同一 JSONL 会话(pendingOperations>0)→ 按幂等键对账账本里停在 running 的调用
// → resume(模型拿到"结果未知")→ 同参数再调 download.submit 得到账本结果;mock qB 只收到一次 add。
const hasPg = await pgAvailable();
const WORKER = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
const TITLE = "Dune.Part.Two.2024.1080p.BluRay.x264-GRP";
describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => {
let e: E2E;
const children: ChildProcess[] = [];
const spawnWorker = (): Promise<ChildProcess> => {
const child = spawn(process.execPath, ["run", "-A", WORKER], {
cwd: SERVER_DIR,
env: {
...process.env,
DATABASE_URL: PG_URL,
BIZ_SCHEMA: e.biz,
OW_SCHEMA: e.ow,
SESSION_DIR: e.sessionDir,
},
stdio: ["ignore", "pipe", "pipe"],
});
children.push(child);
let err = "";
child.stderr!.on("data", (d) => {
err += d;
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`worker 启动超时: ${err}`)), 60_000);
child.stdout!.on("data", (d) => {
if (String(d).includes("worker ready")) {
clearTimeout(timer);
resolve(child);
}
});
child.on("exit", (code) => {
clearTimeout(timer);
reject(new Error(`worker 提前退出 code=${code}: ${err}`));
});
});
};
beforeAll(async () => {
e = await createE2E("e4", { worker: false, fakeScripts: [crashScript] });
const base = await seedBase(e);
await seedMovieTarget(e, base.siteId);
await e.db
.insertInto("automation_grant")
.values({ action: "download.submit", scope: JSON.stringify({}), granted_by: "user:e2e" })
.execute();
}, 120_000);
afterAll(async () => {
for (const c of children) if (c.exitCode === null && c.signalCode === null) c.kill("SIGKILL");
await e?.close();
}, 60_000);
it("download.submit 途中 kill worker → 新 worker resume,不重复提交,最终入库", async () => {
const w1 = await spawnWorker();
const r = await e.api("POST", "/agent/runs", { goal: "把《沙丘2》1080p 收进来", mode: "execute" });
expect(r.status).toBe(201);
const runId = r.body.data.id as string;
// Agent 调到 download.submit(账本行出现)的瞬间 kill -9
await waitFor(
async () =>
e.db
.selectFrom("capability_invocation")
.select("id")
.where("agent_run_id", "=", runId)
.where("capability_id", "=", "download.submit")
.executeTakeFirst(),
60_000,
"download.submit 开始",
10,
);
w1.kill("SIGKILL");
await new Promise((res) => w1.once("exit", res));
const before = await runRow(e, runId);
expect(["running", "queued"]).toContain(before.status);
await spawnWorker();
// 新 worker 接手(租约约 30s 到期)后提交进入 qB,外部下载器完成
const hash = torrentFor(TITLE).hash;
await waitFor(async () => e.qb.get(hash), 120_000, "qB 收到种子", 500);
await e.qb.complete(hash, { writeFiles: true });
const done = await waitFor(
async () => {
const x = await runRow(e, runId);
return x.status === "succeeded" ? x : null;
},
120_000,
"run succeeded",
500,
);
expect(done.summary).toContain("已入库 1 个");
// 不重复提交:qB 只收到一次 add;download.submit 账本只有一条(同幂等键)
expect(e.qb.callsTo("torrents/add")).toHaveLength(1);
const inv = await e.db
.selectFrom("capability_invocation")
.selectAll()
.where("agent_run_id", "=", runId)
.where("capability_id", "=", "download.submit")
.execute();
expect(inv).toHaveLength(1);
expect(inv[0]!.status).toBe("succeeded");
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(1);
// 事件:续跑标记 + 恢复的工具调用
const events = await runEvents(e, runId);
expect(events.some((x) => x.type === "run_started" && (x.payload as { resumed: boolean }).resumed)).toBe(true);
const recovered = events.filter((x) => x.type === "tool_call_end" && (x.payload as { recovery: boolean }).recovery);
expect(recovered.map((x) => (x.payload as { capabilityId: string }).capabilityId)).toContain("download.submit");
// (run_id, seq) 连续不冲突
const seqs = events.map((x) => Number(x.seq));
expect(new Set(seqs).size).toBe(seqs.length);
const starts = events
.filter((x) => x.type === "tool_call_start" && !(x.payload as { recovery: boolean }).recovery)
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
expect(starts.slice(-2)).toEqual(["download.submit", "task.inspect"]);
}, 300_000);
});
@@ -0,0 +1,147 @@
import { TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { INVESTIGATE_CAPABILITIES } from "../../src/modules/agent/runner.ts";
import { investigateScript } from "./fixtures/scripts.ts";
import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts";
// E5 后台唤醒:识别歧义产生 decision.created → outbox → resolveDecision → autoInvestigate 开启时入队有界 runAgent
// (plan_only、白名单只读 + identity.resolve、小预算、trigger=event);同一 decision 只一个 run(dedupe_key)。
const hasPg = await pgAvailable();
const twin = (id: number): TmdbFixture => ({
type: "movie",
id,
original_title: "Twins",
original_language: "en",
origin_country: ["US"],
date: "1988-12-09",
titles: { "zh-CN": "龙兄鼠弟", "en-US": "Twins" },
popularity: 10 + id / 1000,
alternative_titles: [],
external_ids: {},
});
describe.skipIf(!hasPg)("E5 后台唤醒有界调查", () => {
let e: E2E;
let siteId: string;
beforeAll(async () => {
e = await createE2E("e5", {
fakeScripts: [investigateScript],
tmdbFixtures: [...TMDB_FIXTURES, twin(111), twin(112)],
});
siteId = (await seedBase(e)).siteId;
await e.settings({ agent: { autoInvestigate: true, investigateMaxToolCalls: 6 } });
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
it("识别歧义 → 自动发起只读调查 → 结论回写待处理事项;重复事件只一个 run", async () => {
const rel = await e.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: "990001", title: "Twins.1988.1080p.BluRay.x264-GRP" })
.returning("id")
.executeTakeFirstOrThrow();
await e.booted.ctx.engine.enqueue(
"resolveObservation",
{ sourceReleaseId: rel.id },
{ idempotencyKey: `e5:resolve:${rel.id}`, traceId: rel.id },
);
const decision = await waitFor(
async () =>
e.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "identity_ambiguous")
.where("subject_id", "=", rel.id)
.executeTakeFirst(),
60_000,
"识别歧义 decision",
);
const run = await waitFor(
async () =>
e.db
.selectFrom("agent_run")
.selectAll()
.where("dedupe_key", "=", `investigate:${decision.id}`)
.executeTakeFirst(),
60_000,
"自动调查 run",
);
expect(run).toMatchObject({ trigger: "event", mode: "plan_only", created_by: "system:auto-investigate" });
expect(run.goal).toContain(decision.id);
expect(run.scope).toMatchObject({
capabilities: [...INVESTIGATE_CAPABILITIES],
allowedKinds: ["read", "preview", "propose"],
});
expect(run.budget).toMatchObject({ maxToolCalls: 6 });
const finished = await waitFor(
async () => {
const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
return ["succeeded", "failed", "budget_exceeded"].includes(r.status) ? r : null;
},
60_000,
"调查 run 结束",
);
expect(finished.status).toBe("succeeded");
const events = await runEvents(e, run.id);
const invoked = events
.filter((x) => x.type === "tool_call_start")
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
expect(invoked).toEqual(["catalog.lookup", "inventory.query"]);
// 白名单外的写能力:被拒绝,没有执行
const rejected = events.filter((x) => x.type === "tool_call_rejected");
expect(rejected.map((x) => (x.payload as { toolName: string }).toolName)).toEqual(["download__submit"]);
expect(
await e.db.selectFrom("capability_invocation").select("id").where("agent_run_id", "=", run.id).execute(),
).toHaveLength(0);
// 结论回写:待处理事项交还人工并带调查摘要
const d2 = await e.db.selectFrom("decision").selectAll().where("id", "=", decision.id).executeTakeFirstOrThrow();
expect(d2.status).toBe("awaiting_user");
expect(d2.agent_run_id).toBe(run.id);
expect((d2.evidence as { agentInvestigation?: { runId: string } }).agentInvestigation?.runId).toBe(run.id);
// 同一根因的重复事件(不同 outbox 键)与事项重新打开后的再次触发:仍只有一个 run
await e.db.updateTable("decision").set({ status: "open" }).where("id", "=", decision.id).execute();
for (const k of ["dup-1", "dup-2"]) {
await e.booted.ctx.outbox.emit(
e.db,
"decision.created",
{ decisionId: decision.id, kind: "identity_ambiguous", severity: "warning", title: decision.title },
{ idempotencyKey: `e5:${k}` },
);
}
await waitFor(
async () => {
const rows = await e.db
.selectFrom("outbox_event")
.select("status")
.where("idempotency_key", "in", ["e5:dup-1", "e5:dup-2"])
.execute();
return rows.length === 2 && rows.every((r) => r.status === "dispatched");
},
30_000,
"重复事件已投递",
);
// 等 resolveDecision 跑完
await waitFor(
async () => {
const runs = await e.booted.ctx.engine.list({ taskName: "resolveDecision" });
return runs.length >= 3 && runs.every((r) => r.status === "completed") ? runs : null;
},
30_000,
"resolveDecision 完成",
);
const runs = await e.db
.selectFrom("agent_run")
.select("id")
.where("dedupe_key", "=", `investigate:${decision.id}`)
.execute();
expect(runs).toHaveLength(1);
expect(await e.db.selectFrom("agent_run").select("id").execute()).toHaveLength(1);
}, 180_000);
});
+68
View File
@@ -0,0 +1,68 @@
import { createHash } from "node:crypto";
import { createQbClient } from "@umt/adapters";
import type { FakeScript } from "@umt/agent";
import { makeTorrent } from "@umt/testkit";
import { createAcquisitionOrganizeModule } from "../../../src/modules/acquisition-organize/index.ts";
import { createAgentModule } from "../../../src/modules/agent/index.ts";
import { authSettingsModule } from "../../../src/modules/auth-settings/index.ts";
import { createSourcesIdentityModule } from "../../../src/modules/sources-identity/index.ts";
import type { ServerModule } from "../../../src/modules/types.ts";
// e2e 模块装配:真实四个模块,外部依赖指向 testkit mock(qB 账号 admin/adminadmin、固定磁盘用量、按标题生成的种子)。
// harness(API/worker 同进程)与崩溃恢复用的 worker 子进程共用这一份,保证两边行为一致。
export const TORRENT_SIZE = 300_000;
/** 按发布标题确定性地生成种子(同标题 → 同 infohash,父子进程一致) */
export function torrentFor(title: string) {
const seed = createHash("sha256").update(title).digest().readUInt32BE(0);
return makeTorrent({ name: `${title}.mkv`, length: TORRENT_SIZE, pieceSeed: seed });
}
export interface E2EModuleOptions {
sessionDir: string;
fakeScripts?: FakeScript[];
/** 下载同步周期(毫秒) */
syncIntervalMs?: number;
}
export function buildModules(o: E2EModuleOptions): ServerModule[] {
return [
authSettingsModule,
// TMDB 不注入:走设置 metadata.tmdbApiKeySecretRef / tmdbBaseUrl(W2 收口 b)
createSourcesIdentityModule({ schedulerIntervalMs: 3_600_000 }),
createAcquisitionOrganizeModule({
downloader: async (inst) =>
createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
username: "admin",
password: "adminadmin",
lookup: { attempts: 5, intervalMs: 50 },
}),
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrentFor(release.title).bytes }),
diskUsage: async (path) => ({
status: "ok",
path,
totalBytes: 1e12,
freeBytes: 6e11,
availableBytes: 6e11,
usedBytes: 4e11,
usedRatio: 0.4,
}),
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
options: {
backgroundLoops: true,
syncIntervalMs: o.syncIntervalMs ?? 300,
minVideoBytes: 100_000,
verifyHash: "sha256",
reconcileIntervalMs: 0,
},
}),
createAgentModule({
sessionDir: o.sessionDir,
...(o.fakeScripts ? { fakeScripts: o.fakeScripts } : {}),
goalPollSec: 1,
}),
];
}
+103
View File
@@ -0,0 +1,103 @@
import type { FakeScript, FakeToolResult } from "@umt/agent";
// e2e 用的 fake 模型脚本(FakeModelProvider 按最近一条用户消息匹配,从 transcript 推导步骤,崩溃后可续跑)。
// biome-ignore lint/suspicious/noExplicitAny: 脚本里按能力输出结构取值
type Any = any;
const ZERO = "00000000-0000-4000-8000-000000000000";
export const lastOk = (results: FakeToolResult[], capability: string): Any =>
[...results].reverse().find((r) => r.capabilityId === capability && !r.isError)?.json;
const entityOf = (r: FakeToolResult[]) => lastOk(r, "inventory.query")?.items?.[0];
const baseSteps = (title: string) => [
{ text: `先查库存:${title}。`, toolCalls: [{ capability: "inventory.query", input: { query: title, limit: 5 } }] },
{
text: "库里没有 1080p 版本,搜资源。",
toolCalls: [
{
capability: "release.search",
input: (r: FakeToolResult[]) => ({ mediaEntityId: entityOf(r)?.entity?.id ?? ZERO, allowLiveSearch: false }),
},
],
},
{
text: "选 1080p 候选,生成下载计划。",
toolCalls: [
{
capability: "download.plan",
input: (r: FakeToolResult[]) => ({
slotId: entityOf(r)?.slots?.[0]?.id ?? ZERO,
sourceReleaseId:
(lastOk(r, "release.search")?.items ?? []).find((i: Any) => /1080p/i.test(i.title))?.id ?? ZERO,
}),
},
],
},
{
// 模型"声称"用户已同意:服务端不采信,只看 automation_grant / approval
text: "用户已同意并授权,我直接提交下载。",
toolCalls: [
{
capability: "download.submit",
input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }),
},
],
},
];
const inspect = {
text: "提交后看一下任务状态。",
toolCalls: [
{
capability: "task.inspect",
input: (r: FakeToolResult[]) => ({ taskId: lastOk(r, "download.submit")?.taskId ?? ZERO }),
},
],
};
/** 委托:"把《沙丘2》1080p 收进来";最终文本故意夸大(称已入库),用于验证汇报以整理记录为准 */
export const delegateScript: FakeScript = {
name: "delegate",
match: /收进来/,
steps: [...baseSteps("沙丘2"), inspect],
finalText: "已全部完成:《沙丘2》1080p 已入库。",
};
/** 崩溃恢复:提交后再以同参数调用一次 download.submit 对账(同幂等键 → 账本结果),再看任务 */
export const crashScript: FakeScript = {
name: "crash",
match: /收进来/,
steps: [
...baseSteps("沙丘2"),
{
text: "核对刚才的提交结果(同一计划)。",
toolCalls: [
{
capability: "download.submit",
input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }),
},
],
},
inspect,
],
finalText: "已提交,等待下载与入库。",
};
/** 后台调查(识别歧义):只读取证;试图提交下载会被白名单拒绝 */
export const investigateScript: FakeScript = {
name: "investigate",
match: /调查待处理事项/,
steps: [
{
text: "查本地作品与远端候选。",
toolCalls: [{ capability: "catalog.lookup", input: { query: "Twins", limit: 5 } }],
},
{ text: "查库存。", toolCalls: [{ capability: "inventory.query", input: { query: "Twins", limit: 5 } }] },
{ text: "顺手提交下载。", toolCalls: [{ capability: "download.submit", input: { planId: ZERO } }] },
],
finalText: "两个同名候选(1988)证据相当,无法自动确定;建议人工在待处理中心选择。",
};
export const ALL_SCRIPTS: FakeScript[] = [delegateScript, investigateScript];
+45
View File
@@ -0,0 +1,45 @@
// 崩溃恢复 e2e 的 worker 子进程:与 harness 同一套模块装配(fixtures/modules.ts),连同一业务/引擎 schema。
// 环境变量:BIZ_SCHEMA / OW_SCHEMA / SESSION_DIR(可选 DATABASE_URL)。就绪后输出 "worker ready";SIGTERM 优雅退出。
import type { DB } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../../src/config.ts";
import { bootstrap } from "../../../src/context.ts";
import { EventBus } from "../../../src/http/sse.ts";
import { createLogger } from "../../../src/logger.ts";
import { startWorker } from "../../../src/worker/index.ts";
import { buildModules } from "./modules.ts";
import { crashScript } from "./scripts.ts";
const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
const biz = Deno.env.get("BIZ_SCHEMA")!;
const ow = Deno.env.get("OW_SCHEMA")!;
const sessionDir = Deno.env.get("SESSION_DIR")!;
await import("@umt/db"); // 注册 int8 解析器
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 8, options: `-c search_path=${biz},public` }),
}),
});
const logger = createLogger({ level: Deno.env.get("E2E_LOG_LEVEL") === "debug" ? "debug" : "warn" });
const config = ConfigSchema.parse({
DATABASE_URL: url,
SECRET_MASTER_KEY: "e2e-master-key-0123456789",
NODE_ENV: "development",
});
const bus = new EventBus(1000);
const modules = buildModules({ sessionDir, fakeScripts: [crashScript] });
const booted = await bootstrap({ role: "worker", config, logger, db, events: bus, modules, engineSchema: ow });
const worker = await startWorker({
logger,
heartbeatMs: 60_000,
bus,
runtime: { ctx: booted.ctx, modules, setRelay: booted.outbox.setRelay, outboxIntervalMs: 100, concurrency: 4 },
});
console.log(`worker ready pid=${Deno.pid}`);
Deno.addSignalListener("SIGTERM", async () => {
await worker.stop().catch(() => {});
await booted.engine.stop().catch(() => {});
await db.destroy().catch(() => {});
Deno.exit(0);
});
+317
View File
@@ -0,0 +1,317 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FakeScript } from "@umt/agent";
import { type DB, migrateToLatest } from "@umt/db";
import {
createMockQb,
createMockTmdb,
createTempTree,
type MockQb,
type MockTmdb,
type TempTree,
TMDB_FIXTURES,
type TmdbFixture,
} from "@umt/testkit";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { type Bootstrapped, bootstrap } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import type { ServerModule } from "../../src/modules/types.ts";
import { type RunningWorker, startWorker } from "../../src/worker/index.ts";
import { withSchemaLock } from "../auth-settings/harness.ts";
import { buildModules } from "./fixtures/modules.ts";
// 端到端夹具:真实 PG(每个文件独立业务 schema + 独立引擎 schema)+ 真实四个模块 + OpenWorkflow 引擎/worker/outbox 中继
// + testkit mock(qB / TMDB / RSS)+ 临时目录(缓存卷、媒体库、Agent 会话)。HTTP 走 createApp(DEV key = admin)。
export const PG_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-e2e-key";
export const MASTER_KEY = "e2e-master-key-0123456789";
/** mock TMDB 的 v3 key(32 位十六进制,经设置 metadata.tmdbApiKey 录入) */
export const TMDB_KEY = "0123456789abcdef0123456789abcdef";
export async function pgAvailable(): Promise<boolean> {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: PG_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
type Loose = any;
export interface E2E {
name: string;
biz: string;
ow: string;
db: Kysely<DB>;
booted: Bootstrapped;
modules: ServerModule[];
bus: EventBus;
worker: RunningWorker | null;
qb: MockQb;
tmdb: MockTmdb;
lib: TempTree;
cache: TempTree;
sessionDir: string;
api(method: string, path: string, body?: unknown): Promise<{ status: number; body: Loose }>;
/** PATCH /settings 并断言成功 */
settings(patch: Record<string, unknown>): Promise<Loose>;
startWorker(): Promise<void>;
close(): Promise<void>;
}
export interface E2EOptions {
fakeScripts?: FakeScript[];
/** false:只做 API/引擎客户端(崩溃恢复用例由子进程跑 worker) */
worker?: boolean;
tmdbFixtures?: TmdbFixture[];
}
export async function waitFor<T>(
fn: () => Promise<T | null | undefined | false>,
ms: number,
what: string,
every = 200,
): Promise<T> {
const deadline = Date.now() + ms;
let last: unknown;
while (Date.now() < deadline) {
try {
const v = await fn();
if (v) return v as T;
} catch (err) {
last = err;
}
await new Promise((r) => setTimeout(r, every));
}
throw new Error(`等待超时:${what}${last ? `(${(last as Error).message})` : ""}`);
}
export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2E> {
const uniq = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const biz = `e2e_${name}_${uniq}`;
const ow = `${biz}_ow`;
const admin = new pg.Client({ connectionString: PG_URL });
await admin.connect();
await admin.query(`create schema ${biz}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 10, options: `-c search_path=${biz},public` }),
}),
});
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: biz }));
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
const tmdb = await createMockTmdb({ apiKey: TMDB_KEY, fixtures: opts.tmdbFixtures ?? TMDB_FIXTURES });
const lib = await createTempTree({}, { sentinel: true, prefix: "umt-e2e-lib-" });
const cache = await createTempTree({}, { prefix: "umt-e2e-cache-" });
const sessionDir = await mkdtemp(join(tmpdir(), "umt-e2e-sessions-"));
const logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "silent" });
const config = ConfigSchema.parse({
DATABASE_URL: PG_URL,
SECRET_MASTER_KEY: MASTER_KEY,
DEV_ADMIN_KEY: DEV_KEY,
NODE_ENV: "development",
});
const bus = new EventBus(5000);
const modules = buildModules({ sessionDir, ...(opts.fakeScripts ? { fakeScripts: opts.fakeScripts } : {}) });
const booted = await bootstrap({
role: "all",
config,
logger,
db,
events: bus,
modules,
engineSchema: ow,
engineMigrations: true,
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "e2e",
db,
modules,
ctx: booted.ctx,
});
let worker: RunningWorker | null = null;
const e: E2E = {
name,
biz,
ow,
db,
booted,
modules,
bus,
worker,
qb,
tmdb,
lib,
cache,
sessionDir,
async api(method, path, body) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${DEV_KEY}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async settings(patch) {
const r = await e.api("PATCH", "/settings", patch);
if (r.status !== 200) throw new Error(`PATCH /settings 失败:${JSON.stringify(r.body)}`);
return r.body.data;
},
async startWorker() {
worker = await startWorker({
logger,
heartbeatMs: 60_000,
bus,
runtime: {
ctx: booted.ctx,
modules,
setRelay: booted.outbox.setRelay,
outboxIntervalMs: 100,
concurrency: 4,
},
});
e.worker = worker;
},
async close() {
await worker?.stop().catch(() => {});
await booted.engine.stop().catch(() => {});
for (const m of [...modules].reverse()) await m.stop?.();
await db.destroy();
await qb.close();
await tmdb.close();
await lib.cleanup();
await cache.cleanup();
await rm(sessionDir, { recursive: true, force: true });
await withSchemaLock(async () => {
const c = new pg.Client({ connectionString: PG_URL });
await c.connect();
await c.query(`drop schema if exists ${biz} cascade`);
await c.query(`drop schema if exists ${ow} cascade`);
await c.end();
});
},
};
// 通用设置:fake 模型、TMDB 走设置里的 secret、结算快一点
await e.settings({
pi: { provider: "fake", model: "fake-model" },
metadata: { tmdbApiKey: TMDB_KEY, tmdbBaseUrl: `${tmdb.url}/3` },
retention: { seedRetentionDays: 0 },
});
if (opts.worker !== false) await e.startWorker();
return e;
}
/** 媒体库根 + qB 实例(保存路径 = 缓存临时目录)+ 站点 */
export async function seedBase(e: E2E, site: { key?: string; baseUrl?: string } = {}) {
const root = await e.db
.insertInto("library_root")
.values({ name: `lib-${e.name}`, media_kind: "mixed", local_path: e.lib.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const inst = await e.db
.insertInto("downloader_instance")
.values({
name: `qb-${e.name}`,
base_url: e.qb.url,
username: "admin",
settings: JSON.stringify({ savePath: e.cache.root }),
})
.returning("id")
.executeTakeFirstOrThrow();
const s = await e.db
.insertInto("site")
.values({
key: site.key ?? "audiences",
name: "观众",
adapter: "nexusphp",
base_url: site.baseUrl ?? "https://audiences.example/",
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { rootId: root.id, instanceId: inst.id, siteId: s.id };
}
/** 作品 + 订阅 + 1080p Slot + 两个已识别的站点资源(720p / 1080p);不发 release.resolved,只有 Agent 会去规划 */
export async function seedMovieTarget(e: E2E, siteId: string) {
const entity = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const sub = await e.db
.insertInto("subscription")
.values({ media_entity_id: entity.id, media_kind: "movie", mode: "once" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await e.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entity.id,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const rel = async (title: string, n: number) =>
(
await e.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(880000 + n),
title,
size_bytes: 300_000,
media_entity_id: entity.id,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const r720 = await rel("Dune.Part.Two.2024.720p.WEB-DL.x264-AAA", 1);
const r1080 = await rel("Dune.Part.Two.2024.1080p.BluRay.x264-GRP", 2);
return { entityId: entity.id, slotId: slot.id, r720, r1080 };
}
export async function runRow(e: E2E, runId: string) {
return e.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
}
export async function runEvents(e: E2E, runId: string) {
return e.db.selectFrom("agent_run_event").selectAll().where("run_id", "=", runId).orderBy("seq").execute();
}
/** 等 mock qB 收到某个种子后置为完成并落盘 */
export async function completeDownload(e: E2E, title: string, ms = 60_000) {
const { torrentFor } = await import("./fixtures/modules.ts");
const hash = torrentFor(title).hash;
await waitFor(async () => e.qb.get(hash), ms, `qB 收到 ${title}`);
await e.qb.complete(hash, { writeFiles: true });
return hash;
}
export { sql };