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:
1 parent
0dec0854df
commit
6280d52ec3
46 files changed
+5133
-111
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user