refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries

This commit is contained in:
root committed 2026-10-13 14:37:03 +08:00
1 parent 3f1c6d02f6
commit 27c0f27434
268 files changed
+7468 -7852

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
{
"name": "@umt/contracts",
"version": "0.3.44",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
}
}
+7 -1
View File
@@ -4,5 +4,11 @@
"type": "module",
"exports": {
".": "./src/index.ts"
}
},
"version": "0.3.44",
"scripts": {
"test": "bun x --bun vitest run",
"typecheck": "bun x --bun tsc --noEmit -p tsconfig.json"
},
"dependencies": {}
}
+31
View File
@@ -76,6 +76,37 @@ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSiz
return `/api/v1/img/tmdb/${size}/${p}`;
}
/**
* 外部图片代理允许的域名:豆瓣图床(img1–9.doubanio.com 等)与 Bangumi 图床。豆瓣图床对不带 Referer 的请求回 418,
* 浏览器 no-referrer 直连拿不到图,所以经服务端带上来源站的 Referer 取(MP SECURITY_IMAGE_DOMAINS 同此)。
*/
export const EXTERNAL_IMAGE_HOSTS: readonly { suffix: string; referer: string }[] = [
{ suffix: ".doubanio.com", referer: "https://movie.douban.com/" },
{ suffix: "lain.bgm.tv", referer: "https://bgm.tv/" },
];
/** 外部图片地址是否在代理白名单里(只认 https、图片扩展名) */
export function externalImageReferer(url: string): string | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
if (u.protocol !== "https:" || !/\.(jpe?g|png|webp)$/i.test(u.pathname)) return null;
const host = u.hostname.toLowerCase();
const hit = EXTERNAL_IMAGE_HOSTS.find((h) =>
h.suffix.startsWith(".") ? host.endsWith(h.suffix) : host === h.suffix,
);
return hit?.referer ?? null;
}
/** 豆瓣 / Bangumi 封面 → 本站图片代理 /api/v1/img/ext?u=…;不在白名单的原样返回 */
export function externalImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
return externalImageReferer(url) ? `/api/v1/img/ext?u=${encodeURIComponent(url)}` : url;
}
// ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ──
/** region 名:小写字母、数字、`.`、`_`、`-` */
+27 -1
View File
@@ -40,6 +40,7 @@ import {
SiteKind,
SiteLastHealth,
SiteLoginResult,
SiteReleasePage,
SiteUserDataPoint,
SourceRelease,
} from "./sources.ts";
@@ -116,7 +117,31 @@ export const ReleaseSearchOutput = z.object({
});
export const ReleaseInspectInput = z.object({ sourceReleaseId: Id, refreshDetail: z.boolean().default(false) });
export const ReleaseInspectOutput = z.object({ release: SourceRelease, inventory: z.array(MediaAsset) });
export const ReleaseInspectOutput = z.object({
release: SourceRelease,
inventory: z.array(MediaAsset),
page: SiteReleasePage.nullable().optional(),
refreshError: z.string().nullable().optional(),
});
export const ReleaseStaticFacts = z
.object({
resolution: z.enum(["4320p", "2160p", "1440p", "1080p", "1080i", "720p", "576p", "480p"]).optional(),
videoCodec: z.enum(["H.265", "H.264", "AV1", "VC-1", "MPEG-2", "XviD", "DivX"]).optional(),
source: z.enum(["REMUX", "BluRay-Disc", "BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"]).optional(),
releaseGroup: z.string().trim().min(1).max(200).optional(),
})
.strict();
export type ReleaseStaticFacts = z.infer<typeof ReleaseStaticFacts>;
export const ReleaseApplyFactsInput = z
.object({
decisionId: Id,
sourceReleaseId: Id,
facts: ReleaseStaticFacts,
evidence: z.string().trim().min(10).max(2000),
evidenceUrl: z.url().optional(),
})
.strict();
export const ReleaseApplyFactsOutput = z.object({ resolved: z.boolean(), remaining: z.array(z.string()) });
export const IdentityResolveInput = z
.object({
@@ -1136,6 +1161,7 @@ export const CAPABILITY_IO = {
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
"release.inspect": { input: ReleaseInspectInput, output: ReleaseInspectOutput },
"release.apply_facts": { input: ReleaseApplyFactsInput, output: ReleaseApplyFactsOutput },
"identity.resolve": { input: IdentityResolveInput, output: IdentityResolveOutput },
"download.plan": { input: DownloadPlanInput, output: DownloadPlanOutput },
"download.submit": { input: DownloadSubmitInput, output: DownloadSubmitOutput },
+14
View File
@@ -57,6 +57,7 @@ export const CAPABILITY_IDS = [
"catalog.lookup",
"release.search",
"release.inspect",
"release.apply_facts",
"identity.resolve",
"download.plan",
"download.submit",
@@ -185,6 +186,18 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
true,
true,
),
"release.apply_facts": d(
"release.apply_facts",
"补全资源属性",
"查证资源详情后,仅补全事项里缺失的分辨率、编码、来源或发布组;规则重新判断收录。不改变已有事实和策略。",
"execute",
"ReleaseApplyFacts",
"organize.execute",
"medium",
true,
true,
"safe",
),
"download.plan": d(
"download.plan",
"准备下载",
@@ -704,6 +717,7 @@ export const CAPABILITY_ACTIVITY: Record<KnownCapabilityId, CapabilityActivity>
"catalog.lookup": act("查", "作品信息"),
"release.search": act("搜", "资源", "次"),
"release.inspect": act("看", "资源", "个"),
"release.apply_facts": act("补全", "资源属性", "个"),
"identity.resolve": act("识别", "作品", "个"),
"download.plan": { ...act("准备下载", "资源", "个"), bare: true },
"download.submit": act("提交", "下载", "个"),
+1
View File
@@ -184,6 +184,7 @@ export const ReleaseResolvedPayload = z.object({
});
export const DecisionCreatedPayload = z.object({
dedupeKey: z.string().optional(),
decisionId: Id,
kind: DecisionKind,
severity: Severity,
-9
View File
@@ -1,6 +1,5 @@
import type { KnownCapabilityId } from "./capability.ts";
import type { EventType } from "./events.ts";
import type { HookPoint } from "./hooks.ts";
// 功能模块约定(ADR-006):外围功能各自声明一份 manifest,汇总成功能清单。
// 功能清单只给智能体看(系统提示的「功能清单」一节),不做开关;前端插槽默认直接显示。
@@ -31,8 +30,6 @@ export interface FeatureManifest {
pages: readonly string[];
/** 挂在哪些前端插槽 */
slots: readonly SlotPosition[];
/** 注册的链式钩子 */
hooks: readonly HookPoint[];
}
/** 功能清单。新增外围功能:在这里加一份,服务端模块 manifest 指向它(featureManifest(id)) */
@@ -47,7 +44,6 @@ export const FEATURES = [
capabilities: [],
pages: ["作品页「补字幕」", "整理记录行菜单「补字幕」", "站点页「字幕站」"],
slots: ["entity.actions", "organize.record.actions"],
hooks: [],
},
{
id: "browser",
@@ -64,7 +60,6 @@ export const FEATURES = [
],
pages: ["站点页「原站视图」"],
slots: [],
hooks: [],
},
{
id: "desktop",
@@ -75,7 +70,6 @@ export const FEATURES = [
capabilities: [],
pages: ["原站视图「桌面模式」"],
slots: [],
hooks: [],
},
{
id: "websearch",
@@ -86,7 +80,6 @@ export const FEATURES = [
capabilities: ["web.search", "web.fetch"],
pages: ["设置 → 智能体 → 网页搜索"],
slots: [],
hooks: [],
},
{
id: "mcp",
@@ -97,7 +90,6 @@ export const FEATURES = [
capabilities: [],
pages: ["设置 → 智能体 → 工具与 MCP"],
slots: [],
hooks: [],
},
{
id: "schedules",
@@ -108,7 +100,6 @@ export const FEATURES = [
capabilities: ["schedule.draft_check"],
pages: ["日志 → 定时任务"],
slots: [],
hooks: [],
},
] as const satisfies readonly FeatureManifest[];
-44
View File
@@ -1,44 +0,0 @@
// 链式钩子挂点(ADR-006,参考 MoviePilot ChainEventType):同步、按注册顺序执行、可改写或拦截。
// 这里只登记挂点名称与语义(给功能清单、智能体提示与文档用);入参 / 返回值类型在服务端
// apps/server/src/hooks/points.ts,因为它们用到 domain 的解析结果等类型。
/**
* - rewrite:每个钩子拿到上一个钩子的结果,返回新值或 undefined(不改);
* - intercept:任一钩子返回拒绝原因即停止,后面的钩子不再执行。
*/
export type HookKind = "rewrite" | "intercept";
export const HOOK_POINTS = {
"name.recognize": {
kind: "rewrite",
label: "名称识别",
when: "资源标题解析之后、按名称召回作品之前",
effect: "改写解析结果(标题、年份、季集、外部 ID)",
},
"organize.rename": {
kind: "rewrite",
label: "整理改名",
when: "按命名模板生成目标路径之后",
effect: "改写主视频的目标文件名(同主干的字幕等附属文件跟着改)",
},
"organize.intercept": {
kind: "intercept",
label: "整理拦截",
when: "整理计划生成后、写整理记录之前",
effect: "返回原因即拒绝本次整理(记录为失败,原因 intercepted:<原因>)",
},
"resource.selection": {
kind: "rewrite",
label: "资源选择",
when: "订阅 / 收录版本重评时候选排好序之后、逐条评估之前",
effect: "调整候选顺序或过滤掉一部分(不能加入新候选)",
},
"subscription.completion.check": {
kind: "intercept",
label: "订阅完成判定",
when: "订阅满足完成条件、标记完成之前",
effect: "追加完成条件:返回原因即本次不完成,订阅继续追踪",
},
} as const satisfies Record<string, { kind: HookKind; label: string; when: string; effect: string }>;
export type HookPoint = keyof typeof HOOK_POINTS;
-1
View File
@@ -15,7 +15,6 @@ export * from "./episode-mapping.ts";
export * from "./events.ts";
export * from "./execution.ts";
export * from "./features.ts";
export * from "./hooks.ts";
export * from "./identify-words.ts";
export * from "./identity.ts";
export * from "./instances.ts";
+14 -2
View File
@@ -85,7 +85,7 @@ export const Subscription = EntityBase.extend({
mediaEntityTitle: z.string().nullable().optional(),
});
export type Subscription = z.infer<typeof Subscription>;
/** 自动补全其他版本:inherit = 取引用规则组(后者优先)的 autoFill,都没设为开 */
/** 自动补全其他版本:inherit = 取主版本收录规则链(后者优先)的 autoFill,都没设为关 */
export const AutoFillMode = z.enum(["inherit", "on", "off"]);
export type AutoFillMode = z.infer<typeof AutoFillMode>;
/** 补全预算:只约束非主版本的自动补全计划(按订阅、按自然日);同时仍计入全局每日预算 */
@@ -121,7 +121,8 @@ export type SubscriptionEffect = z.infer<typeof SubscriptionEffect>;
* siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关);
* stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词;
* include/exclude 订阅级关键词正则(作用于全部收录版本);libraryRootId 库根;downloadSavePath 下载保存路径;
* autoFill/fillBudget 多版本自动补全;resolution/source/effect 质量预设(MP resolution/quality/effect)。
* autoFill/fillBudget 多版本自动补全;resolution/source/effect 仅保留 MP 导入/API 质量预设兼容。
* 正常界面逐版本维护质量和规则引用,普通订阅保存保留旧兼容字段。
* include/exclude 保存时同步为订阅私有规则组(scope.subscriptionId,随订阅删除),评估只读规则组。
* 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…),
* 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。
@@ -714,6 +715,17 @@ export type PreferenceSuppressionUpdate = z.infer<typeof PreferenceSuppressionUp
// ── 收录策略规则与候选评估(W2-C) ──
/** 分类名称沿用整理分类;显式选择策略时不再按分类过滤。 */
export const AcquisitionPolicyScope = z
.object({
mediaKinds: z.array(SubscriptionMediaKind).optional(),
categories: z
.object({ movie: z.array(z.string().min(1)).optional(), tv: z.array(z.string().min(1)).optional() })
.optional(),
})
.passthrough();
export type AcquisitionPolicyScope = z.infer<typeof AcquisitionPolicyScope>;
/** Slot 模板:策略规则里用于自动建 Slot(新作品收录 / 订阅缺省 Slot);字段同 AcquisitionSlot 规则列 */
export const SlotTemplate = z.object({
name: z.string().min(1).max(64),
+8 -4
View File
@@ -229,16 +229,20 @@ describe("关键合同行为", () => {
}
});
it("capabilitySpec 组装目录元数据与真实 schema;有副作用的类别不可重放", () => {
it("capabilitySpec 组装目录元数据与真实 schema;写入仅明确的幂等例外可重放", () => {
for (const id of C.CAPABILITY_IDS) {
const spec = C.capabilitySpec(id);
const { inputSchema: _i, outputSchema: _o, ...meta } = C.CAPABILITY_CATALOG[id];
expect(spec).toMatchObject(meta);
expect(spec.inputSchema).toBe(C.CAPABILITY_IO[id].input);
expect(spec.outputSchema).toBe(C.CAPABILITY_IO[id].output);
// 例外:site.health_check 只读首页并写回健康列,幂等可重跑;metadata.enrich 只存补全建议(7 天标题缓存),重跑无副作用
if (spec.kind !== "read" && spec.kind !== "preview" && id !== "site.health_check" && id !== "metadata.enrich")
expect(spec.replay, id).toBe("never");
// 健康列、补全建议、带事项与事件去重的资源事实可幂等重跑;其它写入仍不可重放。
if (spec.kind !== "read" && spec.kind !== "preview") {
if (["site.health_check", "metadata.enrich", "release.apply_facts"].includes(id)) {
expect(spec.replay, id).toBe("safe");
expect(spec.idempotent, id).toBe(true);
} else expect(spec.replay, id).toBe("never");
}
}
});